From 688fac73e9ed402e3dad4bde6d740dd33b9a9e4f Mon Sep 17 00:00:00 2001 From: Thinh Lam Date: Tue, 30 Jun 2026 09:38:30 +0700 Subject: [PATCH] sciagent code + Gitea Actions CI/CD Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 23 + .env.example | 105 + .gitea/workflows/ci-cd.yml | 99 + .gitignore | 41 + LICENSE | 201 + Posgresdb/crud_examples.sql | 137 + Posgresdb/schema.sql | 422 + Posgresdb/test_schema.sql | 83 + README.md | 254 + be0/.env.example | 29 + be0/CHAT_ASSISTANT_README.md | 223 + be0/Dockerfile | 34 + be0/GOVERNANCE_LAYER_STATUS.md | 172 + be0/TROUBLESHOOTING_CHAT.md | 150 + be0/__init__.py | 0 be0/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 173 bytes be0/__pycache__/main.cpython-311.pyc | Bin 0 -> 205004 bytes be0/__pycache__/main.cpython-313.pyc | Bin 0 -> 185886 bytes ...rnance and Risk Management Policy v1.5.pdf | Bin 0 -> 547769 bytes be0/entrypoint.sh | 46 + be0/main.py | 3726 +++++++ be0/migrations/001_initiative_schema.sql | 251 + .../002_application_storage_extensions.sql | 71 + be0/migrations/003_review_documents.sql | 22 + .../004_application_admin_results.sql | 18 + .../004_evidence_artifact_review.sql | 13 + be0/migrations/006_user_notifications.sql | 26 + .../007_user_roles_email_policy_admin.sql | 33 + be0/migrations/008_audit_events.sql | 38 + ...009_backup_artifact_roles_storage_kind.sql | 35 + be0/migrations/010_user_staff_profiles.sql | 114 + be0/migrations/011_academic_titles_vn.sql | 19 + be0/migrations/012_password_reset.sql | 19 + be0/migrations/013_email_verification.sql | 21 + be0/migrations/014_registration_otp.sql | 20 + be0/migrations/015_document_templates.sql | 24 + be0/migrations/016_research_projects.sql | 133 + be0/migrations/017_imagehub_datasets.sql | 76 + .../018_imagehub_segmentation_links.sql | 21 + be0/migrations/019_imagehub_cloud_import.sql | 53 + .../020_imagehub_dataset_stages.sql | 26 + be0/migrations/021_imagehub_task_pipeline.sql | 37 + .../022_imagehub_task_annotations.sql | 8 + .../023_imagehub_dataset_members.sql | 23 + .../024_imagehub_dataset_project_link.sql | 13 + .../025_imagehub_task_review_events.sql | 25 + .../026_imagehub_file_folder_path.sql | 21 + .../027_imagehub_dataset_label_map.sql | 12 + be0/requirements-dev.txt | 6 + be0/requirements.txt | 43 + ...pply_initiative_migrations.cpython-313.pyc | Bin 0 -> 7359 bytes .../repair_split_submission.cpython-313.pyc | Bin 0 -> 4692 bytes be0/scripts/add_ump_ideas.py | 93 + be0/scripts/apply-migration-007.sh | 86 + be0/scripts/apply_initiative_migrations.py | 533 + be0/scripts/repair_split_submission.py | 90 + be0/src/__init__.py | 0 be0/src/__pycache__/MCP.cpython-311.pyc | Bin 0 -> 3354 bytes .../Memory_Manager.cpython-311.pyc | Bin 0 -> 4536 bytes .../__pycache__/Memory_Manager.cpython-38.pyc | Bin 0 -> 6521 bytes .../__pycache__/Pdf_Manager.cpython-311.pyc | Bin 0 -> 2220 bytes .../Response_Manager.cpython-311.pyc | Bin 0 -> 4245 bytes be0/src/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 129 bytes be0/src/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 112 bytes be0/src/__pycache__/__init__.cpython-38.pyc | Bin 0 -> 111 bytes .../admin_audit_routes.cpython-311.pyc | Bin 0 -> 13701 bytes .../admin_audit_routes.cpython-313.pyc | Bin 0 -> 12312 bytes .../admin_user_profile_routes.cpython-311.pyc | Bin 0 -> 35864 bytes .../admin_user_profile_routes.cpython-313.pyc | Bin 0 -> 32478 bytes be0/src/__pycache__/audit.cpython-311.pyc | Bin 0 -> 9191 bytes be0/src/__pycache__/audit.cpython-313.pyc | Bin 0 -> 8111 bytes be0/src/__pycache__/auth_api.cpython-311.pyc | Bin 0 -> 82239 bytes be0/src/__pycache__/auth_api.cpython-313.pyc | Bin 0 -> 74691 bytes ...auth_credential_middleware.cpython-311.pyc | Bin 0 -> 4130 bytes ...auth_credential_middleware.cpython-313.pyc | Bin 0 -> 3741 bytes be0/src/__pycache__/auth_jwt.cpython-311.pyc | Bin 0 -> 3143 bytes be0/src/__pycache__/auth_jwt.cpython-313.pyc | Bin 0 -> 2886 bytes be0/src/__pycache__/auth_mail.cpython-311.pyc | Bin 0 -> 11099 bytes be0/src/__pycache__/auth_mail.cpython-313.pyc | Bin 0 -> 10328 bytes .../auth_rate_limit.cpython-311.pyc | Bin 0 -> 3583 bytes .../auth_rate_limit.cpython-313.pyc | Bin 0 -> 4110 bytes .../awareness_manager.cpython-311.pyc | Bin 0 -> 3698 bytes .../chat_assistant.cpython-311.pyc | Bin 0 -> 14674 bytes .../chat_assistant.cpython-313.pyc | Bin 0 -> 13129 bytes .../compliance_verifier.cpython-311.pyc | Bin 0 -> 8791 bytes .../compliance_verifier.cpython-313.pyc | Bin 0 -> 8180 bytes be0/src/__pycache__/config.cpython-311.pyc | Bin 0 -> 516 bytes be0/src/__pycache__/config.cpython-38.pyc | Bin 0 -> 365 bytes be0/src/__pycache__/main_new.cpython-313.pyc | Bin 0 -> 2775 bytes .../memory_manager.cpython-313.pyc | Bin 0 -> 118 bytes be0/src/__pycache__/schemas.cpython-311.pyc | Bin 0 -> 3779 bytes .../staff_profile_domain.cpython-311.pyc | Bin 0 -> 6425 bytes .../staff_profile_domain.cpython-313.pyc | Bin 0 -> 5993 bytes .../structure_analysis.cpython-311.pyc | Bin 0 -> 3150 bytes .../structure_analysis.cpython-313.pyc | Bin 0 -> 2714 bytes .../template_manager.cpython-311.pyc | Bin 0 -> 4516 bytes be0/src/__pycache__/text_io.cpython-311.pyc | Bin 0 -> 2404 bytes be0/src/__pycache__/text_io.cpython-38.pyc | Bin 0 -> 2498 bytes be0/src/__pycache__/utils.cpython-311.pyc | Bin 0 -> 21088 bytes be0/src/__pycache__/utils.cpython-313.pyc | Bin 0 -> 18963 bytes be0/src/__pycache__/utils.cpython-38.pyc | Bin 0 -> 2789 bytes be0/src/admin_audit_routes.py | 235 + be0/src/admin_user_profile_routes.py | 609 ++ be0/src/application/__init__.py | 6 + be0/src/application/identity/__init__.py | 1 + be0/src/application/identity/dto.py | 24 + be0/src/application/identity/ports.py | 54 + .../identity/use_cases/__init__.py | 1 + .../identity/use_cases/authenticate_user.py | 80 + be0/src/audit.py | 176 + be0/src/auth_api.py | 1527 +++ be0/src/auth_credential_middleware.py | 72 + be0/src/auth_jwt.py | 58 + be0/src/auth_mail.py | 189 + be0/src/auth_rate_limit.py | 89 + be0/src/be01/README.md | 86 + .../build_template.cpython-313.pyc | Bin 0 -> 35427 bytes .../docx_normalize.cpython-311.pyc | Bin 0 -> 87799 bytes .../docx_normalize.cpython-313.pyc | Bin 0 -> 78324 bytes .../__pycache__/docx_to_pdf.cpython-311.pyc | Bin 0 -> 4775 bytes .../__pycache__/docx_to_pdf.cpython-313.pyc | Bin 0 -> 3854 bytes ...ort_applications_list_xlsx.cpython-311.pyc | Bin 0 -> 6347 bytes ...ort_applications_list_xlsx.cpython-313.pyc | Bin 0 -> 5641 bytes .../fill_application_form.cpython-311.pyc | Bin 0 -> 7114 bytes .../fill_application_form.cpython-313.pyc | Bin 0 -> 6258 bytes .../__pycache__/fill_template.cpython-313.pyc | Bin 0 -> 1812 bytes .../official_to_data_blank.cpython-311.pyc | Bin 0 -> 41188 bytes .../official_to_data_blank.cpython-313.pyc | Bin 0 -> 36812 bytes be0/src/be01/build_template.py | 583 + be0/src/be01/data_blank.json | 133 + be0/src/be01/data_sop.json | 123 + be0/src/be01/docx_normalize.py | 1405 +++ be0/src/be01/docx_to_pdf.py | 96 + be0/src/be01/export_applications_list_xlsx.py | 90 + be0/src/be01/fill_application_form.py | 121 + be0/src/be01/fill_template.py | 45 + be0/src/be01/filled_sop.docx | Bin 0 -> 45637 bytes be0/src/be01/official_to_data_blank.py | 414 + be0/src/be01/template_sang_kien.docx | Bin 0 -> 43209 bytes be0/src/chat_assistant.py | 344 + be0/src/compliance_verifier.py | 142 + be0/src/domain/__init__.py | 5 + be0/src/domain/identity/__init__.py | 6 + be0/src/domain/identity/entities.py | 41 + be0/src/domain/identity/errors.py | 25 + be0/src/domain/identity/repository.py | 28 + be0/src/domain/identity/services.py | 94 + be0/src/domain/identity/value_objects.py | 74 + be0/src/imagehub_routes.py | 1981 ++++ be0/src/imagehub_segmentation.py | 166 + be0/src/imagehub_task_pipeline.py | 136 + .../__pycache__/settings.cpython-313.pyc | Bin 0 -> 3093 bytes be0/src/infrastructure/config/settings.py | 74 + .../qdrant_service.cpython-311.pyc | Bin 0 -> 14829 bytes .../qdrant_service.cpython-313.pyc | Bin 0 -> 13597 bytes .../vector_db/qdrant_service.py | 246 + be0/src/initiative_db/__init__.py | 17 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 528 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 444 bytes .../application_admin_results.cpython-311.pyc | Bin 0 -> 15417 bytes .../application_admin_results.cpython-313.pyc | Bin 0 -> 14970 bytes .../application_backup.cpython-311.pyc | Bin 0 -> 15504 bytes .../application_backup.cpython-313.pyc | Bin 0 -> 14228 bytes .../application_storage.cpython-311.pyc | Bin 0 -> 17260 bytes .../application_storage.cpython-313.pyc | Bin 0 -> 17950 bytes .../__pycache__/backup_naming.cpython-311.pyc | Bin 0 -> 5200 bytes .../__pycache__/backup_naming.cpython-313.pyc | Bin 0 -> 4842 bytes .../__pycache__/drafts.cpython-311.pyc | Bin 0 -> 13571 bytes .../__pycache__/drafts.cpython-313.pyc | Bin 0 -> 12798 bytes .../__pycache__/engine.cpython-311.pyc | Bin 0 -> 6565 bytes .../__pycache__/engine.cpython-313.pyc | Bin 0 -> 5913 bytes .../__pycache__/models.cpython-311.pyc | Bin 0 -> 31399 bytes .../__pycache__/models.cpython-313.pyc | Bin 0 -> 23479 bytes .../repair_split_submission.cpython-313.pyc | Bin 0 -> 15867 bytes .../submission_readiness.cpython-311.pyc | Bin 0 -> 33070 bytes .../submission_readiness.cpython-313.pyc | Bin 0 -> 28098 bytes .../__pycache__/submissions.cpython-311.pyc | Bin 0 -> 74553 bytes .../__pycache__/submissions.cpython-313.pyc | Bin 0 -> 70711 bytes .../user_notifications.cpython-311.pyc | Bin 0 -> 12445 bytes .../user_notifications.cpython-313.pyc | Bin 0 -> 11355 bytes .../application_admin_results.py | 317 + be0/src/initiative_db/application_backup.py | 342 + be0/src/initiative_db/application_storage.py | 420 + be0/src/initiative_db/backup_naming.py | 80 + be0/src/initiative_db/drafts.py | 252 + be0/src/initiative_db/engine.py | 115 + be0/src/initiative_db/models.py | 907 ++ .../initiative_db/repair_split_submission.py | 315 + be0/src/initiative_db/submission_readiness.py | 398 + be0/src/initiative_db/submissions.py | 1359 +++ be0/src/initiative_db/user_notifications.py | 207 + be0/src/internal_control/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 146 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 129 bytes .../access_control/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 144 bytes .../cloud_infrastructure_controls/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 159 bytes .../data_integrity_security/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 153 bytes .../it_asset_management/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 149 bytes .../it_change_management/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 150 bytes .../it_governance/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 160 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 143 bytes .../__pycache__/document_io.cpython-311.pyc | Bin 0 -> 8209 bytes .../__pycache__/document_io.cpython-313.pyc | Bin 0 -> 7692 bytes .../memory_manager.cpython-313.pyc | Bin 0 -> 149 bytes .../response_manager.cpython-313.pyc | Bin 0 -> 151 bytes .../it_governance/document_io.py | 151 + .../it_governance/memory_manager.py | 0 .../it_governance/response_manager.py | 0 .../it_operations/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 143 bytes .../logical_physical_security/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 155 bytes .../monitoring_logging/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 148 bytes .../network_security/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 146 bytes .../patch_vuln_management/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 151 bytes .../security_awareness_training/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 157 bytes .../system_dev_lifecycle/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 150 bytes .../__pycache__/sdlc_planning.cpython-313.pyc | Bin 0 -> 15708 bytes .../system_dev_lifecycle/sdlc_planning.py | 561 + .../__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 160 bytes .../vendor_management/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 147 bytes be0/src/memory_manager.py | 0 be0/src/minio/002_add_upload_status.py | 51 + be0/src/minio/__init__.py | 0 .../002_add_upload_status.cpython-313.pyc | Bin 0 -> 2060 bytes .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 135 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 118 bytes .../__pycache__/attachments.cpython-313.pyc | Bin 0 -> 14393 bytes .../minio/__pycache__/cleanup.cpython-313.pyc | Bin 0 -> 5276 bytes .../minio/__pycache__/storage.cpython-311.pyc | Bin 0 -> 18886 bytes .../minio/__pycache__/storage.cpython-313.pyc | Bin 0 -> 17014 bytes .../test_storage.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 15355 bytes .../__pycache__/test_storage.cpython-313.pyc | Bin 0 -> 10572 bytes be0/src/minio/attachments.py | 322 + be0/src/minio/cleanup.py | 121 + be0/src/minio/storage.py | 407 + be0/src/minio/test_storage.py | 131 + be0/src/research_routes.py | 827 ++ be0/src/shared_kernel/__init__.py | 5 + be0/src/shared_kernel/entity.py | 21 + be0/src/shared_kernel/errors.py | 40 + be0/src/shared_kernel/value_object.py | 15 + be0/src/staff_profile_domain.py | 123 + be0/src/structure_analysis.py | 57 + be0/src/template_routes.py | 402 + be0/src/test/__init__.py | 1 + .../test/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 117 bytes ...x_pseudo_fill.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 3489 bytes .../test_docx_pseudo_fill.cpython-313.pyc | Bin 0 -> 3311 bytes be0/src/test/pseudo_data_blank.json | 107 + be0/src/test/test_docx_pseudo_fill.py | 51 + be0/src/utils.py | 407 + be0/template_application_form.docx | 0 ...uth_register_staff_fixture.cpython-313.pyc | Bin 0 -> 847 bytes .../security_token_fixture.cpython-313.pyc | Bin 0 -> 1609 bytes ..._audit_routes.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 2322 bytes .../test_admin_audit_routes.cpython-313.pyc | Bin 0 -> 2144 bytes ...cation_backup.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 5252 bytes .../test_application_backup.cpython-313.pyc | Bin 0 -> 5074 bytes ...on_drafts_get.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 2159 bytes ...est_application_drafts_get.cpython-313.pyc | Bin 0 -> 1981 bytes ...b_integration.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 42648 bytes ...pplications_db_integration.cpython-313.pyc | Bin 0 -> 40736 bytes ...t_integration.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 12191 bytes ...password_reset_integration.cpython-313.pyc | Bin 0 -> 11987 bytes ...y_integration.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 8929 bytes ...st_auth_policy_integration.cpython-313.pyc | Bin 0 -> 8751 bytes ...st_backup_e2e.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 16403 bytes .../test_backup_e2e.cpython-313.pyc | Bin 0 -> 15528 bytes ...lookup_routes.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 2130 bytes ...st_dashboard_lookup_routes.cpython-313.pyc | Bin 0 -> 1952 bytes .../test_docx_normalize.cpython-311.pyc | Bin 0 -> 24414 bytes ...ocx_normalize.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 45095 bytes .../test_docx_normalize.cpython-313.pyc | Bin 0 -> 2404 bytes ...ve_resolution.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 8001 bytes ...ence_initiative_resolution.cpython-313.pyc | Bin 0 -> 6347 bytes ..._kind_parsing.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 2409 bytes ...test_evidence_kind_parsing.cpython-313.pyc | Bin 0 -> 2231 bytes ...k_ban_cam_ket.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 10309 bytes ..._to_data_blank_ban_cam_ket.cpython-313.pyc | Bin 0 -> 5630 bytes ..._blank_don_vi.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 3062 bytes ...icial_to_data_blank_don_vi.cpython-313.pyc | Bin 0 -> 2884 bytes ...istration_otp.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 8524 bytes .../test_registration_otp.cpython-313.pyc | Bin 0 -> 8414 bytes ...ack_alignment.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 19805 bytes ...gistration_stack_alignment.cpython-313.pyc | Bin 0 -> 18933 bytes ...it_submission.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 3278 bytes ...st_repair_split_submission.cpython-313.pyc | Bin 0 -> 3100 bytes ...curity_routes.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 11837 bytes .../test_security_routes.cpython-313.pyc | Bin 0 -> 11724 bytes ...rofile_domain.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 5069 bytes .../test_staff_profile_domain.cpython-313.pyc | Bin 0 -> 4891 bytes ...ion_readiness.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 3124 bytes .../test_submission_readiness.cpython-313.pyc | Bin 0 -> 2946 bytes ...research_kind.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 2258 bytes ...s_projection_research_kind.cpython-313.pyc | Bin 0 -> 2080 bytes ...cations_merit.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 2702 bytes ...t_user_notifications_merit.cpython-313.pyc | Bin 0 -> 2524 bytes be0/tests/auth_register_staff_fixture.py | 16 + .../minimal_submit_bundle.cpython-313.pyc | Bin 0 -> 3559 bytes be0/tests/fixtures/minimal_submit_bundle.py | 99 + be0/tests/security_token_fixture.py | 32 + be0/tests/test_admin_audit_routes.py | 27 + be0/tests/test_application_backup.py | 99 + be0/tests/test_application_drafts_get.py | 33 + be0/tests/test_applications_db_integration.py | 797 ++ .../test_auth_password_reset_integration.py | 217 + be0/tests/test_auth_policy_integration.py | 126 + be0/tests/test_authenticate_user.py | 146 + be0/tests/test_backup_e2e.py | 257 + be0/tests/test_dashboard_lookup_routes.py | 31 + be0/tests/test_docx_normalize.py | 678 ++ .../test_evidence_initiative_resolution.py | 108 + be0/tests/test_evidence_kind_parsing.py | 35 + be0/tests/test_filename_normalize.py | 76 + be0/tests/test_identity_domain.py | 133 + be0/tests/test_imagehub_datasets.py | 407 + be0/tests/test_imagehub_segmentation.py | 157 + be0/tests/test_imagehub_tasks.py | 157 + ...test_official_to_data_blank_ban_cam_ket.py | 71 + .../test_official_to_data_blank_don_vi.py | 72 + be0/tests/test_registration_otp.py | 135 + .../test_registration_stack_alignment.py | 309 + be0/tests/test_repair_split_submission.py | 54 + be0/tests/test_research_routes.py | 403 + be0/tests/test_security_routes.py | 158 + be0/tests/test_staff_profile_domain.py | 95 + be0/tests/test_submission_readiness.py | 47 + ...st_submissions_projection_research_kind.py | 43 + be0/tests/test_user_notifications_merit.py | 66 + ...pplication_form_pdf_export.cpython-313.pyc | Bin 0 -> 7635 bytes be0/tools/e2e_application_form_pdf_export.py | 140 + be0/tools/e2e_sample_official_bieu_mau.json | 181 + database/crud_examples.sql | 171 + database/schema.sql | 441 + database/test_schema.sql | 83 + deploy/nginx/minio-s3-proxy.conf.example | 47 + docker-compose.prod.yml | 211 + docker-compose.yml | 286 + ..._APPLICANT_NOTIFICATION_SYSTEM_ANALYSIS.md | 221 + ...LICATIONS_ADMIN_RESULT_RADICAL_FIX_PLAN.md | 175 + docs/ARCHITECTURE_REDESIGN.md | 871 ++ docs/ARCHITECTURE_SUMMARY.md | 94 + docs/FIX_CHAT_ERROR.md | 139 + docs/HANDOFF.md | 41 + docs/PDF_TEMPLATE_IMPLEMENTATION.md | 985 ++ docs/PDF_converter.md | 363 + docs/PDF_preview.md | 313 + docs/SIMPLIFIED_TECH_STACK.md | 343 + docs/Signup.md | 113 + docs/TECH_STACK_COMPARISON.md | 259 + ...pplication-files-persistence-and-backup.md | 336 + docs/architecture-improvement-proposals.md | 209 + .../medical-imaging-3d-viewer-spec.md | 500 + docs/architecture/platform_architecture.md | 356 + docs/audit-log-manager-implementation.md | 101 + docs/audit-log-manager-plan.md | 143 + docs/auth-implementation-feedback.md | 62 + docs/auth-registration-and-user-management.md | 286 + docs/backend-clean-architecture.md | 47 + docs/deploy-production-docker.md | 232 + docs/deploy-stack-overview.md | 150 + docs/document-templates-feature.md | 40 + ...fe0-dashboard-data-refresh-architecture.md | 298 + docs/feedback-data-management.md | 200 + docs/feedback.md | 207 + docs/minio-behind-https.md | 43 + docs/otp-registration-state-machine.md | 185 + docs/research-project-cockpit-feature.md | 93 + docs/sang-kien-01-to-tham-dinh-cap-don-vi.md | 262 + docs/security-incident-rcc-ump-2026-05-27.md | 173 + docs/user-profile-manager-db-review.md | 296 + ...user-profile-manager-state-machine-plan.md | 285 + fe0/.env.e2e.example | 8 + fe0/.gitignore | 24 + fe0/CHAT_INTEGRATION.md | 141 + fe0/DEBUG_NETWORK_ERROR.md | 102 + fe0/Dockerfile | 20 + fe0/Dockerfile.prod | 23 + fe0/FRONTEND_ARCHITECTURE.md | 658 ++ fe0/FRONTEND_MIGRATION_GUIDE.md | 124 + fe0/FRONTEND_SUMMARY.md | 110 + fe0/QA_APPLICANT_FRESH_FORMS.md | 66 + fe0/README.md | 73 + fe0/bun.lockb | Bin 0 -> 197327 bytes fe0/components.json | 20 + fe0/e2e/backup-admin-download.spec.ts | 155 + ...initiative-application-form-typing.spec.ts | 42 + fe0/eslint.config.js | 66 + fe0/index.html | 22 + fe0/nginx/default.conf | 45 + fe0/package-lock.json | 9612 +++++++++++++++++ fe0/package.json | 108 + fe0/playwright.config.ts | 26 + fe0/postcss.config.js | 6 + .../assets/bieu_mau_sang_kien_template.json | 235 + fe0/public/assets/data_blank.json | 152 + .../assets/template_application_form.docx | Bin 0 -> 17545 bytes .../assets/template_application_forrm.docx | Bin 0 -> 10310 bytes .../assets/typescript_component_guide (1).md | 627 ++ fe0/public/logo.png | Bin 0 -> 415923 bytes fe0/public/logo.svg | 4 + fe0/scripts/save-report-to-public.mjs | 117 + fe0/src/App.css | 51 + fe0/src/App.tsx | 137 + fe0/src/admin/audit/AuditEventDetailSheet.tsx | 111 + fe0/src/admin/audit/AuditLogFilters.tsx | 120 + fe0/src/admin/audit/AuditLogManagerPage.tsx | 143 + fe0/src/admin/audit/AuditLogTable.tsx | 149 + fe0/src/admin/auth/RegistrationPage.tsx | 8 + fe0/src/admin/auth/index.ts | 2 + fe0/src/applicant/audit/actionLabels.ts | 16 + fe0/src/applicant/auth/RegistrationPage.tsx | 9 + fe0/src/applicant/auth/index.ts | 2 + fe0/src/audit/adminAuditApi.ts | 23 + fe0/src/audit/formatAuditTime.ts | 19 + fe0/src/audit/jsonMicrodiffLines.ts | 32 + fe0/src/audit/types.ts | 61 + fe0/src/auth/LoginRegisterCard.tsx | 161 + fe0/src/auth/index.ts | 8 + fe0/src/auth/institutionalEmail.ts | 11 + fe0/src/auth/primaryRole.ts | 12 + fe0/src/auth/registration/OtpSixInputs.tsx | 48 + .../auth/registration/RegistrationWithOtp.tsx | 738 ++ fe0/src/auth/registration/constants.ts | 8 + fe0/src/auth/registration/passwordPolicy.ts | 21 + fe0/src/auth/sessionUser.ts | 30 + fe0/src/components/ArticleCard.tsx | 75 + fe0/src/components/ChatAssistant.tsx | 265 + .../ContributionConfirmationForm.tsx | 984 ++ fe0/src/components/DashboardHeader.tsx | 30 + fe0/src/components/DashboardSidebar.tsx | 179 + fe0/src/components/Header.tsx | 183 + fe0/src/components/HeroSection.tsx | 67 + ...ationForm.draftTyping.integration.test.tsx | 51 + .../components/InitiativeApplicationForm.tsx | 1466 +++ .../components/InitiativeEvaluationForm.tsx | 6 + fe0/src/components/InitiativeReportForm.tsx | 1147 ++ fe0/src/components/IntroSection.tsx | 16 + fe0/src/components/SignUpModal.tsx | 192 + fe0/src/components/UserMenu.tsx | 103 + fe0/src/components/admin/AIManagementTab.tsx | 429 + .../admin/ApplicationBackupDownloadButton.tsx | 68 + .../admin/ApprovedApplicationsList.tsx | 693 ++ fe0/src/components/admin/DashboardSidebar.tsx | 183 + .../components/admin/IdeasManagementTab.tsx | 370 + fe0/src/components/admin/OverviewTab.tsx | 165 + .../components/admin/RolePermissionsTab.tsx | 202 + fe0/src/components/admin/SearchableSelect.tsx | 86 + fe0/src/components/admin/SelectOptions.tsx | 35 + ...ttedApplicationButton.integration.test.tsx | 156 + .../admin/ViewSubmittedApplicationButton.tsx | 44 + .../profile/AdminUserProfilesManager.tsx | 663 ++ fe0/src/components/admin/profile/index.ts | 1 + .../result/ConsideredInitiativesList.tsx | 12 + .../admin/result/DecidedApplicationsPanel.tsx | 17 + .../components/admin/result/ResultManager.tsx | 280 + ...invalidateAdminApplicationResultQueries.ts | 18 + .../AdminApplicationFormDocxPreview.tsx | 36 + .../admin/review/AdminApproveReviewDialog.tsx | 134 + .../admin/review/AdminDocxTemplatePreview.tsx | 141 + .../review/AdminEvaluationFormActions.tsx | 31 + .../admin/review/AdminRejectReviewDialog.tsx | 105 + .../review/AdminStaffReadonlyReviewDialog.tsx | 210 + .../StaffApplicationBundleReviewTab.tsx | 276 + .../applicationMeritCategoryHint.test.ts | 35 + .../review/applicationMeritCategoryHint.ts | 106 + .../buildInitiativeDraftFromReviewTabs.ts | 35 + .../councilEvaluationSnapshotContext.tsx | 42 + .../admin/review/docxTemplateCompleteness.ts | 51 + .../evaluationCategoryRecommendation.test.ts | 57 + .../evaluationCategoryRecommendation.ts | 136 + .../admin/review/reviewOutcomeStorage.ts | 43 + .../applicant/ApplicantSidebarMenuItem.tsx | 56 + .../applicant/AuthorArticleCommitmentForm.tsx | 270 + .../components/applicant/DashboardSidebar.tsx | 133 + .../applicant/DomesticJournalHonestyForm.tsx | 250 + .../ReferenceMaterialHonestyForm.tsx | 229 + .../components/applicant/WorkplaceSelect.tsx | 60 + .../components/applicant/applicationDrafts.ts | 107 + .../ApplicantRegistrationDashboard.tsx | 227 + .../dashboard/applicantDraftSessionUtils.ts | 49 + .../components/applicant/dashboard/index.ts | 1 + .../evidence_viewer/GraphProcessor.tsx | 169 + .../evidence_viewer/GraphVisualizer2.tsx | 527 + .../evidence_viewer/ITProjectKnowledge.tsx | 39 + .../evidence_viewer/KnowledgePro.tsx | 29 + .../evidence_viewer/KnowledgeViz.tsx | 42 + .../graphComponents/GraphToolbar.tsx | 179 + .../graphComponents/PropertyInspector.tsx | 469 + .../checklists/AddDocumentModal.tsx | 141 + .../checklists/ChecklistHeader.tsx | 42 + .../checklists/DocumentModal.tsx | 210 + .../checklists/DocumentTable.tsx | 134 + .../graphComponents/checklists/FilterBar.tsx | 86 + .../checklists/ITProjectManager.tsx | 465 + .../checklists/ImportModal.tsx | 83 + .../checklists/OveralSidebar.tsx | 71 + .../checklists/iso27001-questionnaire-ui.tsx | 635 ++ .../checklists/iso27001-questionnaire.tsx | 477 + .../commonControlFramework/CCFDashboard.tsx | 374 + .../commonControlFramework/CCFNavigation.tsx | 38 + .../documents/Announcements.tsx | 0 .../documents/DocDashboard.tsx | 70 + .../graphComponents/documents/DocDropdown.tsx | 65 + .../graphComponents/documents/DocList.tsx | 76 + .../graphComponents/documents/Outputs.tsx | 457 + .../documents/QuestionList.tsx | 19 + .../documents/iso27001-questionnaire-ui.tsx | 631 ++ .../documents/iso27001-questionnaire.tsx | 477 + .../documents/knowledge_graph_viz.tsx | 423 + .../documents/pdfProcessor.tsx | 235 + .../graphCanva/DragManager.tsx | 121 + .../graphCanva/colorManager.tsx | 117 + .../graphComponents/graphCanva/constant.tsx | 77 + .../graphCanva/linkManager.tsx | 235 + .../graphCanva/nodeManager.tsx | 156 + .../graphComponents/graphCanvas.tsx | 430 + .../graphComponents/graphGov.tsx | 65 + .../graphConfig/graphConfig.tsx | 71 + .../evidence_viewer/graphTypes/graphTypes.tsx | 51 + .../graphUtils/enhancedGraphUtils.tsx | 124 + .../evidence_viewer/graphUtils/graphUtils.tsx | 65 + .../graphUtils/loadGraphData.tsx | 46 + .../submittedEvidence/EmbeddedPdfViewer.tsx | 8 + .../submittedEvidence/evidencePreviewUtils.ts | 7 + .../history/ApplicantHistoryCrudDialog.tsx | 107 + .../history/ApplicantHistoryPanel.tsx | 232 + .../history/ApplicantHistoryTable.tsx | 221 + .../ApplicationFormDocxPreview.tsx | 492 + .../ApplicationFormOfficialPdfActions.tsx | 160 + .../initiative-draft/DraftContext.tsx | 364 + .../initiative-draft/DraftJsonPanel.tsx | 180 + .../OfficialFormPdfPreviewDialog.tsx | 341 + .../PdfTextLayoutEditorDialog.tsx | 324 + .../initiative-draft/ReviewPanel.tsx | 55 + .../ReviewSnapshotSections.tsx | 265 + .../initiative-draft/TemplateFiller.tsx | 752 ++ .../applicationAuthorsContributionTable.ts | 55 + .../applicationEvidenceApi.ts | 5 + .../applicationFormDocxPreview.css | 68 + .../applicationFormDocxPreviewFormat.ts | 67 + .../applicationFormOfficialPdf.ts | 112 + .../bieuMauPreviewSections.ts | 138 + .../bieu_mau_sang_kien_template.json | 235 + .../buildDocxTemplateExportBundle.ts | 40 + .../contributionDraftTypes.ts | 16 + .../contributionRepresentativeAuthorSync.ts | 60 + .../dashboardDraftSync.integration.test.tsx | 72 + .../docxApplicationFormPreviewOptions.ts | 17 + .../downloadMergedTemplateJson.ts | 78 + .../initiative-draft/draftJsonFullShape.ts | 233 + .../initiative-draft/draftStorage.ts | 68 + .../initiative-draft/draftToTemplateData.ts | 122 + .../mapBanCamKetToOfficialBieuMau.ts | 94 + .../mapDraftToOfficialBieuMau.ts | 282 + ...ferenceMaterialHonestyToOfficialBieuMau.ts | 70 + ...esearchDomesticHonestyToOfficialBieuMau.ts | 74 + .../mergeContributionWorkUnits.ts | 52 + .../officialBieuMauToTemplateDataRecord.ts | 135 + .../officialFormPreviewFieldEdits.test.ts | 52 + .../officialFormPreviewFieldEdits.ts | 156 + .../applicant/initiative-draft/pdfExport.tsx | 11 + .../initiative-draft/pdfLayoutEditor.ts | 138 + .../reportApplicationFieldMirror.ts | 32 + .../reportAuthorEvaluationMerge.ts | 80 + .../reportContentSummaryMerge.ts | 69 + .../applicant/initiative-draft/serializers.ts | 73 + .../applicant/initiative-draft/types.ts | 62 + .../applicant/initiative-draft/useDraft.ts | 12 + .../applicant/initiativeFormTypes.ts | 415 + .../components/applicant/lib/dataParser.ts | 79 + .../components/applicant/lib/pdfExporter.ts | 51 + .../applicant/lib/placeholderDetector.ts | 64 + .../applicant/lib/templateEngine.ts | 49 + .../components/applicant/lib/templateTypes.ts | 48 + fe0/src/components/applicant/pdfExport.tsx | 714 ++ .../profile/ApplicantProfileView.tsx | 310 + .../profile/ApplicantStaffProfileSection.tsx | 205 + fe0/src/components/applicant/profile/index.ts | 2 + .../applicant/submitInitiativePdf.ts | 80 + .../application-review/DocxToPdfViewer.tsx | 2 + .../application-review/ReviewFormsPanel.tsx | 242 + .../StaffApplicationReviewShell.tsx | 152 + .../application-review/docx-to-pdf-demo.html | 477 + .../docxToPdf/DocxToPdfViewer.tsx | 446 + .../docxToPdf/convertDocxToPdfBlob.ts | 288 + fe0/src/components/auth/PermissionGate.tsx | 88 + fe0/src/components/auth/ProtectedRoute.tsx | 63 + .../components/council/ApplicationIdeaRow.tsx | 82 + .../council/ApprovedApplicationsList.tsx | 551 + .../components/council/SearchableSelect.tsx | 86 + fe0/src/components/council/SelectOptions.tsx | 35 + .../council/demoApplicationInstances.ts | 39 + .../evaluation/InitiativeEvaluationForm.tsx | 698 ++ .../evidence/ApplicationEvidenceDashboard.tsx | 247 + .../ApplicationEvidenceFilePreview.tsx | 207 + .../evidence/ApplicationEvidenceLayout.tsx | 97 + .../ApplicationEvidenceManagePage.tsx | 225 + .../ApplicationEvidencePreviewPage.tsx | 68 + .../ApplicationEvidenceViewerPanel.tsx | 36 + .../evidence/EvidenceFileTypeIcon.tsx | 43 + .../evidence/EvidenceFilesTableCell.tsx | 71 + .../evidence/EvidenceVaultLinkButton.tsx | 65 + .../evidence/applicationEvidenceConstants.ts | 7 + .../evidence/applicationEvidenceRowUtils.ts | 38 + .../evidence/evidenceStaffReviewAck.ts | 34 + .../notifications/NotificationBell.tsx | 41 + .../notifications/NotificationManager.tsx | 158 + .../profile/ProfileVerificationBadge.tsx | 19 + .../profile/StaffProfileFormFields.tsx | 160 + .../profile/academicTitleOptions.ts | 17 + fe0/src/components/profile/index.ts | 6 + .../profile/staffProfileValidation.test.ts | 58 + .../profile/staffProfileValidation.ts | 37 + fe0/src/components/profile/types.ts | 19 + fe0/src/components/ui/accordion.tsx | 52 + fe0/src/components/ui/alert-dialog.tsx | 104 + fe0/src/components/ui/alert.tsx | 43 + fe0/src/components/ui/aspect-ratio.tsx | 5 + fe0/src/components/ui/avatar.tsx | 38 + fe0/src/components/ui/badge.tsx | 29 + fe0/src/components/ui/breadcrumb.tsx | 90 + fe0/src/components/ui/button.tsx | 47 + fe0/src/components/ui/calendar.tsx | 54 + fe0/src/components/ui/card.tsx | 43 + fe0/src/components/ui/carousel.tsx | 224 + fe0/src/components/ui/chart.tsx | 303 + fe0/src/components/ui/checkbox.tsx | 26 + fe0/src/components/ui/collapsible.tsx | 9 + fe0/src/components/ui/command.tsx | 132 + fe0/src/components/ui/context-menu.tsx | 178 + fe0/src/components/ui/dialog.tsx | 95 + fe0/src/components/ui/drawer.tsx | 87 + fe0/src/components/ui/dropdown-menu.tsx | 179 + fe0/src/components/ui/form.tsx | 129 + fe0/src/components/ui/hover-card.tsx | 27 + fe0/src/components/ui/input-otp.tsx | 61 + fe0/src/components/ui/input.tsx | 22 + fe0/src/components/ui/label.tsx | 17 + fe0/src/components/ui/menubar.tsx | 207 + fe0/src/components/ui/navigation-menu.tsx | 120 + fe0/src/components/ui/pagination.tsx | 81 + fe0/src/components/ui/popover.tsx | 29 + fe0/src/components/ui/progress.tsx | 23 + fe0/src/components/ui/radio-group.tsx | 36 + fe0/src/components/ui/resizable.tsx | 37 + fe0/src/components/ui/scroll-area.tsx | 38 + fe0/src/components/ui/select.tsx | 143 + fe0/src/components/ui/separator.tsx | 20 + fe0/src/components/ui/sheet.tsx | 107 + fe0/src/components/ui/sidebar.tsx | 637 ++ fe0/src/components/ui/skeleton.tsx | 7 + fe0/src/components/ui/slider.tsx | 23 + fe0/src/components/ui/sonner.tsx | 27 + fe0/src/components/ui/switch.tsx | 27 + fe0/src/components/ui/table.tsx | 72 + fe0/src/components/ui/tabs.tsx | 53 + fe0/src/components/ui/textarea.tsx | 21 + fe0/src/components/ui/toast.tsx | 111 + fe0/src/components/ui/toaster.tsx | 24 + fe0/src/components/ui/toggle-group.tsx | 49 + fe0/src/components/ui/toggle.tsx | 37 + fe0/src/components/ui/tooltip.tsx | 28 + fe0/src/components/ui/use-toast.ts | 3 + fe0/src/contexts/AuthContext.tsx | 241 + fe0/src/data/articles.ts | 284 + fe0/src/data/departmentOptions.ts | 45 + fe0/src/data/mockApplications.ts | 115 + fe0/src/features/chat/hooks/useChat.ts | 69 + fe0/src/features/chat/services/chatService.ts | 305 + fe0/src/features/chat/types/chat.types.ts | 32 + .../features/workflows/hooks/useWorkflows.ts | 141 + .../workflows/services/workflowService.ts | 81 + .../workflows/types/workflow.types.ts | 74 + fe0/src/hooks/use-mobile.tsx | 19 + fe0/src/hooks/use-toast.ts | 186 + .../useApplicantRegistrationState.test.tsx | 65 + .../hooks/useApplicantRegistrationState.ts | 197 + fe0/src/hooks/useApplicationReviewDetail.ts | 72 + fe0/src/hooks/useEvidenceStaffReviewAck.ts | 45 + .../useVisibilityAwareRefetchInterval.ts | 31 + fe0/src/index.css | 285 + fe0/src/layouts/DashboardLayout.tsx | 26 + fe0/src/lib/applicantApplicationsApi.ts | 40 + fe0/src/lib/applicantHonestyPrerequisites.ts | 205 + .../lib/applicantRegistrationSession.test.ts | 97 + fe0/src/lib/applicantRegistrationSession.ts | 59 + fe0/src/lib/applicantSubmissionRecord.ts | 84 + fe0/src/lib/applicationAdminResultApi.ts | 96 + fe0/src/lib/applicationBackupDownload.ts | 88 + fe0/src/lib/applicationEvidenceApi.ts | 95 + fe0/src/lib/applicationFormDocxApi.ts | 48 + fe0/src/lib/applicationReviewApi.ts | 45 + fe0/src/lib/applicationReviewNavigation.ts | 63 + .../auth-service.register.contract.test.ts | 73 + fe0/src/lib/auth-service.ts | 467 + fe0/src/lib/dashboardNavigation.ts | 24 + fe0/src/lib/docxJustifyMitigationCss.ts | 42 + fe0/src/lib/docxPreviewClient.ts | 28 + fe0/src/lib/docxTableReflow.ts | 68 + fe0/src/lib/evidenceFileKind.ts | 43 + fe0/src/lib/evidencePreviewUtils.ts | 27 + fe0/src/lib/evidenceUploadLimits.ts | 4 + fe0/src/lib/initiativeSubmitWorkflow.ts | 64 + fe0/src/lib/networkTimeout.ts | 49 + fe0/src/lib/officialFormLayoutApi.ts | 48 + fe0/src/lib/officialFormPdfFileName.ts | 47 + fe0/src/lib/permissions.ts | 118 + fe0/src/lib/pizzipClient.ts | 10 + fe0/src/lib/resolveApplicationAssetUrl.ts | 18 + .../lib/resolveApplicationDraftCaseId.test.ts | 41 + fe0/src/lib/resolveApplicationDraftCaseId.ts | 33 + fe0/src/lib/reviewDocumentApi.ts | 42 + .../sharedDocxOfficialFormRenderOptions.ts | 8 + fe0/src/lib/user-profile-service.ts | 265 + fe0/src/lib/userNotificationsApi.ts | 45 + fe0/src/lib/utils.ts | 6 + fe0/src/lib/vnDateFormat.ts | 126 + fe0/src/main.tsx | 5 + fe0/src/pages/About.tsx | 116 + .../pages/AdminApplicationReviewPage.test.tsx | 205 + fe0/src/pages/AdminApplicationReviewPage.tsx | 9 + fe0/src/pages/AdminPanel.tsx | 80 + fe0/src/pages/AdminProjectsPage.tsx | 36 + fe0/src/pages/AdminUserProfilesPage.tsx | 14 + fe0/src/pages/ApplicantProfilePage.tsx | 23 + fe0/src/pages/ApplicationReviewPage.tsx | 497 + fe0/src/pages/Article.tsx | 241 + fe0/src/pages/Authors.tsx | 117 + fe0/src/pages/Contact.tsx | 185 + .../pages/CouncilApplicationReviewPage.tsx | 8 + fe0/src/pages/Dashboard.tsx | 27 + fe0/src/pages/ForgotPasswordPage.tsx | 109 + fe0/src/pages/Growth.tsx | 61 + fe0/src/pages/Index.tsx | 98 + fe0/src/pages/Login.tsx | 5 + fe0/src/pages/NotFound.tsx | 24 + fe0/src/pages/NotificationsPage.tsx | 11 + fe0/src/pages/Privacy.tsx | 139 + fe0/src/pages/RenewFormPanel.tsx | 57 + fe0/src/pages/ResetPasswordPage.tsx | 131 + fe0/src/pages/StyleGuide.tsx | 199 + fe0/src/pages/Terms.tsx | 135 + fe0/src/pages/Travel.tsx | 60 + fe0/src/pages/Unauthorized.tsx | 67 + fe0/src/pages/VerifyEmailPage.tsx | 93 + .../applicantWorkflow.integration.test.tsx | 194 + fe0/src/shared/api/client.queryRetry.test.ts | 42 + fe0/src/shared/api/client.ts | 390 + .../components/feedback/LoadingSpinner.tsx | 31 + fe0/src/shared/config/env.test.ts | 27 + fe0/src/shared/config/env.ts | 56 + fe0/src/shared/config/polling.ts | 17 + fe0/src/shared/types/api.types.ts | 40 + fe0/src/shared/utils/testConnection.ts | 38 + fe0/src/shared/utils/token.ts | 25 + fe0/src/test-setup.ts | 7 + fe0/src/test/ApplicantFormAutofillButton.tsx | 24 + fe0/src/test/applicantFormTestFixtures.ts | 366 + fe0/src/test/applicantFormTestFlags.ts | 6 + fe0/src/types/applicantPrefill.ts | 5 + fe0/src/vite-env.d.ts | 1 + fe0/tailwind.config.ts | 95 + fe0/test-results/.last-run.json | 4 + fe0/tsconfig.app.json | 31 + fe0/tsconfig.json | 16 + fe0/tsconfig.node.json | 22 + fe0/vite | 102 + fe0/vite.config.ts | 62 + fe0/vite_react_shadcn_ts@0.0.0 | 0 fe0/vitest.config.ts | 16 + frontend_admin/Dockerfile | 22 + frontend_admin/Dockerfile.prod | 34 + frontend_admin/index.html | 15 + frontend_admin/nginx/default.conf | 59 + frontend_admin/package.json | 33 + frontend_admin/postcss.config.js | 6 + frontend_admin/public/logo.png | Bin 0 -> 788437 bytes frontend_admin/src/App.tsx | 52 + .../src/admin/audit/AuditEventDetailSheet.tsx | 111 + .../src/admin/audit/AuditLogFilters.tsx | 120 + .../src/admin/audit/AuditLogManagerPage.tsx | 143 + .../src/admin/audit/AuditLogTable.tsx | 149 + .../src/applicant/audit/actionLabels.ts | 16 + frontend_admin/src/audit/adminAuditApi.ts | 23 + frontend_admin/src/audit/formatAuditTime.ts | 19 + .../src/audit/jsonMicrodiffLines.ts | 32 + frontend_admin/src/audit/types.ts | 61 + frontend_admin/src/index.css | 285 + frontend_admin/src/layouts/AdminLayout.tsx | 97 + frontend_admin/src/lib/adminNav.ts | 98 + frontend_admin/src/main.tsx | 14 + frontend_admin/src/pages/ComingSoonPage.tsx | 29 + frontend_admin/src/pages/DashboardPage.tsx | 77 + frontend_admin/src/pages/DatasetsPage.tsx | 126 + frontend_admin/src/pages/LoginPage.tsx | 5 + .../src/pages/ResearchReviewPage.tsx | 293 + frontend_admin/src/pages/TemplatesPage.tsx | 404 + frontend_admin/tailwind.config.ts | 95 + frontend_admin/tsconfig.json | 23 + frontend_admin/vite.config.ts | 39 + frontend_investigator/Dockerfile | 21 + frontend_investigator/Dockerfile.prod | 34 + frontend_investigator/index.html | 15 + frontend_investigator/nginx/default.conf | 63 + frontend_investigator/package.json | 35 + frontend_investigator/postcss.config.js | 6 + frontend_investigator/public/logo.png | Bin 0 -> 788437 bytes frontend_investigator/src/App.tsx | 87 + .../components/DatasetFileViewerDialog.tsx | 430 + .../components/SegmentationUploadDialog.tsx | 151 + .../applicant/ApplicantSidebarMenuItem.tsx | 30 + .../components/applicant/DashboardSidebar.tsx | 174 + .../src/components/cockpit/CockpitDetail.tsx | 180 + .../src/components/cockpit/CockpitSidebar.tsx | 147 + .../src/components/cockpit/CockpitWidgets.tsx | 226 + .../components/cockpit/DetailPrimitives.tsx | 67 + .../components/cockpit/TeamManagementView.tsx | 454 + .../src/components/cockpit/cockpitConfig.ts | 208 + .../components/cockpit/detailConfig.test.ts | 78 + .../src/components/cockpit/detailConfig.ts | 149 + .../proposal/ProposalFormFields.tsx | 262 + .../src/components/proposal/proposalSchema.ts | 504 + .../src/components/totalSegmentatorLabels.ts | 46 + .../src/components/ui/sidebar.tsx | 560 + .../features/data-import/application/ports.ts | 49 + .../application/runDirectUpload.test.ts | 61 + .../application/runDirectUpload.ts | 46 + .../data-import/domain/bucketPath.test.ts | 36 + .../features/data-import/domain/bucketPath.ts | 53 + .../domain/cloudImport/reducer.test.ts | 55 + .../data-import/domain/cloudImport/reducer.ts | 56 + .../data-import/domain/cloudImport/states.ts | 60 + .../features/data-import/domain/dataTypes.ts | 38 + .../domain/directUpload/guards.test.ts | 36 + .../data-import/domain/directUpload/guards.ts | 56 + .../domain/directUpload/reducer.test.ts | 80 + .../domain/directUpload/reducer.ts | 87 + .../data-import/domain/directUpload/states.ts | 65 + .../data-import/domain/formats.test.ts | 51 + .../features/data-import/domain/formats.ts | 80 + .../src/features/data-import/domain/index.ts | 23 + .../domain/itemsList/parse.test.ts | 65 + .../data-import/domain/itemsList/parse.ts | 131 + .../domain/mapping/fileToTask.test.ts | 94 + .../data-import/domain/mapping/fileToTask.ts | 101 + .../domain/mapping/mhdCompanions.test.ts | 30 + .../domain/mapping/mhdCompanions.ts | 41 + .../data-import/domain/mapping/paths.ts | 49 + .../data-import/domain/nnunet/core.test.ts | 590 + .../data-import/domain/nnunet/core.ts | 544 + .../domain/nnunet/fromItemsList.test.ts | 42 + .../domain/nnunet/fromItemsList.ts | 37 + .../domain/nnunet/wizardState.test.ts | 105 + .../data-import/domain/nnunet/wizardState.ts | 181 + .../domain/samplePath/reducer.test.ts | 27 + .../data-import/domain/samplePath/reducer.ts | 29 + .../data-import/domain/samplePath/states.ts | 34 + .../src/features/data-import/domain/types.ts | 62 + .../infrastructure/httpDirectUploadGateway.ts | 22 + .../presentation/components/PrivacyNotice.tsx | 17 + .../components/UploadDataDialog.tsx | 257 + .../presentation/hooks/useDirectUpload.ts | 67 + .../nnunet/NnunetOrganizerPage.tsx | 267 + .../presentation/nnunet/OutputPane.tsx | 112 + .../presentation/nnunet/dataSteps.tsx | 246 + .../data-import/presentation/nnunet/steps.tsx | 244 + .../domain/folderTree.test.ts | 64 + .../dataset-workspace/domain/folderTree.ts | 105 + .../domain/workspaceModel.test.ts | 135 + .../domain/workspaceModel.ts | 265 + .../presentation/DatasetWorkspaceView.tsx | 728 ++ .../presentation/FolderTreeView.tsx | 101 + .../presentation/FolderUploadButtons.tsx | 58 + .../presentation/WorkspacePanel.tsx | 124 + .../project-workflow/domain/taskView.test.ts | 220 + .../project-workflow/domain/taskView.ts | 216 + .../presentation/AnnotationTool.tsx | 343 + .../presentation/DataPage.tsx | 538 + .../presentation/InitialsAvatar.tsx | 51 + .../presentation/ProductivitySidebar.tsx | 201 + .../project-workflow/presentation/TaskRow.tsx | 306 + frontend_investigator/src/index.css | 285 + .../src/layouts/DashboardLayout.tsx | 62 + frontend_investigator/src/main.tsx | 14 + .../src/pages/CockpitPage.tsx | 465 + .../src/pages/ComingSoonPage.tsx | 28 + .../src/pages/DatasetCreatePage.tsx | 149 + .../src/pages/DatasetDetailPage.tsx | 381 + .../src/pages/DatasetSettingsPage.tsx | 612 ++ .../src/pages/DatasetsListPage.tsx | 92 + .../src/pages/ImageSequenceDemoPage.tsx | 103 + frontend_investigator/src/pages/LoginPage.tsx | 5 + .../src/pages/ProjectsListPage.tsx | 248 + .../src/pages/ProposalFormPage.tsx | 232 + .../src/pages/VideoViewerDemoPage.tsx | 94 + frontend_investigator/tailwind.config.ts | 95 + frontend_investigator/tsconfig.json | 25 + frontend_investigator/vite.config.ts | 49 + frontend_investigator/vitest.config.ts | 12 + frontend_publisher/Dockerfile | 21 + frontend_publisher/Dockerfile.prod | 34 + frontend_publisher/index.html | 15 + frontend_publisher/nginx/default.conf | 63 + frontend_publisher/package.json | 35 + frontend_publisher/postcss.config.js | 6 + .../public/fonts/roboto-bold.ttf | Bin 0 -> 133184 bytes .../public/fonts/roboto-regular.ttf | Bin 0 -> 141952 bytes frontend_publisher/public/logo.png | Bin 0 -> 788437 bytes frontend_publisher/src/App.tsx | 72 + .../applicant/ApplicantSidebarMenuItem.tsx | 30 + .../components/applicant/DashboardSidebar.tsx | 120 + .../src/components/ui/sidebar.tsx | 560 + .../src/features/papers/api/papersApi.ts | 41 + .../features/papers/domain/governance.test.ts | 56 + .../src/features/papers/domain/governance.ts | 100 + .../features/papers/domain/paperModel.test.ts | 93 + .../src/features/papers/domain/paperModel.ts | 165 + .../papers/presentation/PaperPdfDocument.tsx | 88 + .../presentation/ProvenanceIntegrityPanel.tsx | 101 + .../papers/presentation/PublishGateDialog.tsx | 94 + frontend_publisher/src/index.css | 285 + .../src/layouts/DashboardLayout.tsx | 62 + frontend_publisher/src/main.tsx | 14 + .../src/pages/ComingSoonPage.tsx | 28 + .../src/pages/GovernancePage.tsx | 148 + frontend_publisher/src/pages/LoginPage.tsx | 5 + .../src/pages/PaperComposerPage.tsx | 297 + .../src/pages/PaperPreviewPage.tsx | 152 + .../src/pages/PublishablePapersList.tsx | 192 + frontend_publisher/tailwind.config.ts | 95 + frontend_publisher/tsconfig.json | 24 + frontend_publisher/vite.config.ts | 45 + frontend_publisher/vitest.config.ts | 12 + frontend_user/Dockerfile | 22 + frontend_user/Dockerfile.prod | 35 + frontend_user/index.html | 15 + frontend_user/nginx/default.conf | 63 + frontend_user/package.json | 33 + frontend_user/postcss.config.js | 6 + frontend_user/public/logo.png | Bin 0 -> 788437 bytes frontend_user/src/App.tsx | 62 + .../ContributionConfirmationForm.tsx | 936 ++ .../components/InitiativeApplicationForm.tsx | 1437 +++ .../components/InitiativeEvaluationForm.tsx | 13 + .../src/components/InitiativeReportForm.tsx | 1120 ++ .../src/components/admin/SelectOptions.ts | 35 + .../applicant/ApplicantSidebarMenuItem.tsx | 30 + .../applicant/AuthorArticleCommitmentForm.tsx | 265 + .../components/applicant/DashboardSidebar.tsx | 124 + .../applicant/DomesticJournalHonestyForm.tsx | 250 + .../ReferenceMaterialHonestyForm.tsx | 229 + .../components/applicant/WorkplaceSelect.tsx | 61 + .../ApplicantRegistrationDashboard.tsx | 230 + .../history/ApplicantHistoryCrudDialog.tsx | 105 + .../history/ApplicantHistoryPanel.tsx | 241 + .../history/ApplicantHistoryTable.tsx | 228 + .../ApplicationFormDocxPreview.tsx | 490 + .../initiative-draft/ReviewPanel.tsx | 56 + .../ReviewSnapshotSections.tsx | 267 + .../applicant/initiative-draft/pdfExport.ts | 11 + .../WorkspaceTabPlaceholder.tsx | 47 + .../src/components/applicant/pdfExport.tsx | 720 ++ .../applicant/submitInitiativePdf.ts | 80 + .../application-review/ReviewFormsPanel.tsx | 143 + .../evidence/EvidenceFileTypeIcon.tsx | 43 + .../evidence/EvidenceFilesTableCell.tsx | 70 + frontend_user/src/components/ui/sidebar.tsx | 560 + frontend_user/src/data/mockApplications.ts | 115 + .../hooks/useApplicantRegistrationState.ts | 196 + .../src/hooks/useApplicationReviewDetail.ts | 71 + frontend_user/src/index.css | 285 + frontend_user/src/layouts/DashboardLayout.tsx | 38 + .../src/lib/applicantApplicationsApi.ts | 40 + .../src/lib/applicantSubmissionRecord.ts | 84 + frontend_user/src/lib/applicationReviewApi.ts | 46 + .../src/lib/applicationReviewNavigation.ts | 63 + frontend_user/src/lib/evidenceFileKind.ts | 43 + .../src/lib/initiativeSubmitWorkflow.ts | 64 + .../src/lib/resolveApplicationAssetUrl.ts | 18 + .../src/lib/resolveApplicationDraftCaseId.ts | 33 + frontend_user/src/main.tsx | 14 + .../src/pages/ApplicationReviewPage.tsx | 493 + frontend_user/src/pages/ComingSoonPage.tsx | 28 + frontend_user/src/pages/DashboardPage.tsx | 14 + frontend_user/src/pages/LoginPage.tsx | 5 + frontend_user/src/pages/TemplatesFillPage.tsx | 192 + frontend_user/tailwind.config.ts | 95 + frontend_user/tsconfig.json | 23 + frontend_user/vite.config.ts | 39 + package-lock.json | 7890 ++++++++++++++ package.json | 21 + scripts/apply-migration-007-postgres.sh | 5 + scripts/build-all.cmd | 16 + scripts/build-all.ps1 | 129 + scripts/build-word-template.py | 221 + scripts/check-prod-stack.sh | 55 + scripts/deploy-prod.sh | 59 + scripts/deployment/.env.deploy.example | 53 + scripts/deployment/00-enable-ssh-ONELINER.txt | 25 + scripts/deployment/00-enable-ssh-server.ps1 | 143 + .../deployment/01-install-prerequisites.ps1 | 167 + scripts/deployment/03-setup-iis-sites.ps1 | 159 + scripts/deployment/04-install-gitea.ps1 | 147 + scripts/deployment/05-install-act-runner.ps1 | 98 + scripts/deployment/06-setup-ssl.ps1 | 114 + scripts/deployment/ENABLE-SSH-INSTRUCTIONS.md | 52 + .../deployment/iis-web-configs/api.web.config | 56 + .../deployment/iis-web-configs/spa.web.config | 77 + scripts/deployment/inventory.ps1 | 56 + scripts/deployment/sql/01-create-database.sql | 78 + scripts/deployment/sql/02-migrate.sql | 653 ++ .../deployment/sql/03-add-reports-docs.sql | 175 + scripts/health-check.cmd | 7 + scripts/health-check.ps1 | 53 + scripts/seed-sample-initiatives.py | 327 + scripts/setup-gitea-runner.sh | 130 + scripts/split-word-template.py | 240 + scripts/start-frontends.cmd | 22 + scripts/start-frontends.ps1 | 24 + scripts/sync-postgres-app-password.sh | 74 + scripts/test-verify-prod-env.sh | 43 + scripts/verify-prod-env.sh | 115 + shared/package.json | 61 + shared/src/api/client.ts | 318 + shared/src/auth/AuthProvider.tsx | 105 + shared/src/auth/ForgotPasswordPage.tsx | 117 + shared/src/auth/LoginRegisterCard.tsx | 180 + shared/src/auth/RegistrationWithOtp.tsx | 732 ++ shared/src/auth/ResetPasswordPage.tsx | 140 + shared/src/auth/authOperations.ts | 181 + shared/src/auth/institutionalEmail.ts | 12 + shared/src/auth/registration/OtpSixInputs.tsx | 53 + shared/src/auth/registration/constants.ts | 8 + .../src/auth/registration/passwordPolicy.ts | 22 + shared/src/components/ui/alert-dialog.tsx | 111 + shared/src/components/ui/alert.tsx | 43 + shared/src/components/ui/badge.tsx | 29 + shared/src/components/ui/button.tsx | 47 + shared/src/components/ui/calendar.tsx | 54 + shared/src/components/ui/card.tsx | 43 + shared/src/components/ui/checkbox.tsx | 26 + shared/src/components/ui/command.tsx | 132 + shared/src/components/ui/dialog.tsx | 95 + shared/src/components/ui/dropdown-menu.tsx | 179 + shared/src/components/ui/input.tsx | 22 + shared/src/components/ui/label.tsx | 17 + shared/src/components/ui/popover.tsx | 29 + shared/src/components/ui/radio-group.tsx | 36 + shared/src/components/ui/resizable.tsx | 37 + shared/src/components/ui/scroll-area.tsx | 38 + shared/src/components/ui/select.tsx | 143 + shared/src/components/ui/separator.tsx | 20 + shared/src/components/ui/sheet.tsx | 107 + shared/src/components/ui/skeleton.tsx | 7 + shared/src/components/ui/sonner.tsx | 32 + shared/src/components/ui/switch.tsx | 41 + shared/src/components/ui/table.tsx | 72 + shared/src/components/ui/tabs.tsx | 53 + shared/src/components/ui/textarea.tsx | 21 + shared/src/components/ui/tooltip.tsx | 28 + .../video-viewer/ImageSequenceViewer.tsx | 517 + .../src/components/video-viewer/VideoQuad.tsx | 57 + .../video-viewer/VideoQuadViewer.tsx | 285 + .../video-viewer/imageSequenceModel.test.ts | 96 + .../video-viewer/imageSequenceModel.ts | 57 + shared/src/components/video-viewer/index.ts | 11 + .../video-viewer/playbackModel.test.ts | 95 + .../components/video-viewer/playbackModel.ts | 55 + shared/src/components/video-viewer/types.ts | 65 + .../video-viewer/usePlaybackSync.ts | 141 + .../components/viewer/AnnotationOverlay.tsx | 254 + .../viewer/NiftiQuadViewRenderer.tsx | 1250 +++ .../components/viewer/QuadViewRenderer.tsx | 821 ++ .../viewer/UnifiedQuadViewRenderer.tsx | 190 + .../viewer/ViewRotationControls.tsx | 129 + shared/src/components/viewer/index.ts | 14 + .../src/components/viewer/labelMask.test.ts | 46 + shared/src/components/viewer/labelMask.ts | 32 + .../src/components/viewer/niftiLoader.test.ts | 106 + shared/src/components/viewer/niftiLoader.ts | 285 + shared/src/components/viewer/types.ts | 92 + shared/src/components/viewer/useDicomData.ts | 219 + shared/src/components/viewer/useNiftiData.ts | 49 + shared/src/config/env.ts | 56 + shared/src/data/departmentOptions.ts | 45 + shared/src/index.ts | 79 + .../ApplicationFormOfficialPdfActions.tsx | 160 + shared/src/initiative/DocxToPdfViewer.tsx | 446 + shared/src/initiative/DraftContext.tsx | 377 + .../OfficialFormPdfPreviewDialog.tsx | 341 + .../initiative/PdfTextLayoutEditorDialog.tsx | 324 + .../initiative/applicantDraftSessionUtils.ts | 49 + shared/src/initiative/applicantPrefill.ts | 5 + .../applicationAuthorsContributionTable.ts | 55 + shared/src/initiative/applicationDrafts.ts | 107 + .../src/initiative/applicationEvidenceApi.ts | 95 + .../initiative/applicationFormOfficialPdf.ts | 112 + shared/src/initiative/bieuMauTemplate.ts | 5 + .../bieu_mau_sang_kien_template.json | 235 + .../buildDocxTemplateExportBundle.ts | 40 + .../buildInitiativeDraftFromReviewTabs.ts | 30 + .../src/initiative/contributionDraftTypes.ts | 16 + .../contributionRepresentativeAuthorSync.ts | 60 + shared/src/initiative/convertDocxToPdfBlob.ts | 288 + shared/src/initiative/draftJsonFullShape.ts | 233 + shared/src/initiative/draftStorage.ts | 68 + shared/src/initiative/draftToTemplateData.ts | 122 + shared/src/initiative/initiativeFormTypes.ts | 415 + .../mapBanCamKetToOfficialBieuMau.ts | 94 + .../initiative/mapDraftToOfficialBieuMau.ts | 282 + ...ferenceMaterialHonestyToOfficialBieuMau.ts | 70 + ...esearchDomesticHonestyToOfficialBieuMau.ts | 74 + .../initiative/mergeContributionWorkUnits.ts | 52 + .../officialBieuMauToTemplateDataRecord.ts | 135 + .../officialFormPreviewFieldEdits.ts | 156 + shared/src/initiative/pdfLayoutEditor.ts | 138 + .../reportApplicationFieldMirror.ts | 32 + .../initiative/reportAuthorEvaluationMerge.ts | 80 + .../initiative/reportContentSummaryMerge.ts | 69 + shared/src/initiative/reviewTabs.ts | 14 + shared/src/initiative/serializers.ts | 73 + shared/src/initiative/templateTypes.ts | 48 + shared/src/initiative/types.ts | 62 + .../src/lib/applicantHonestyPrerequisites.ts | 205 + .../src/lib/applicantRegistrationSession.ts | 64 + shared/src/lib/applicationFormDocxApi.ts | 48 + shared/src/lib/applicationReviewApi.ts | 12 + shared/src/lib/docxJustifyMitigationCss.ts | 42 + shared/src/lib/docxTableReflow.ts | 68 + shared/src/lib/evidenceUploadLimits.ts | 4 + shared/src/lib/imagehubApi.ts | 431 + shared/src/lib/imagehubViewer.test.ts | 73 + shared/src/lib/imagehubViewer.ts | 40 + shared/src/lib/networkTimeout.ts | 49 + shared/src/lib/officialFormLayoutApi.ts | 48 + shared/src/lib/officialFormPdfFileName.ts | 47 + shared/src/lib/permissions.ts | 127 + shared/src/lib/researchApi.ts | 155 + shared/src/lib/reviewDocumentApi.ts | 42 + .../sharedDocxOfficialFormRenderOptions.ts | 8 + shared/src/lib/templateApi.ts | 101 + shared/src/lib/utils.ts | 6 + shared/src/lib/vnDateFormat.ts | 126 + .../src/profile/ProfileVerificationBadge.tsx | 19 + shared/src/profile/StaffProfileFormFields.tsx | 160 + shared/src/profile/academicTitleOptions.ts | 17 + shared/src/profile/index.ts | 5 + shared/src/profile/staffProfileValidation.ts | 44 + shared/src/profile/types.ts | 19 + shared/src/utils/token.ts | 25 + shared/tsconfig.json | 17 + shared/vitest.config.ts | 10 + 1167 files changed, 158244 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/ci-cd.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Posgresdb/crud_examples.sql create mode 100644 Posgresdb/schema.sql create mode 100644 Posgresdb/test_schema.sql create mode 100644 README.md create mode 100644 be0/.env.example create mode 100644 be0/CHAT_ASSISTANT_README.md create mode 100644 be0/Dockerfile create mode 100644 be0/GOVERNANCE_LAYER_STATUS.md create mode 100644 be0/TROUBLESHOOTING_CHAT.md create mode 100644 be0/__init__.py create mode 100644 be0/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/__pycache__/main.cpython-311.pyc create mode 100644 be0/__pycache__/main.cpython-313.pyc create mode 100644 be0/assets/data/pdf/UNObank_IT Governance and Risk Management Policy v1.5.pdf create mode 100755 be0/entrypoint.sh create mode 100644 be0/main.py create mode 100644 be0/migrations/001_initiative_schema.sql create mode 100644 be0/migrations/002_application_storage_extensions.sql create mode 100644 be0/migrations/003_review_documents.sql create mode 100644 be0/migrations/004_application_admin_results.sql create mode 100644 be0/migrations/004_evidence_artifact_review.sql create mode 100644 be0/migrations/006_user_notifications.sql create mode 100644 be0/migrations/007_user_roles_email_policy_admin.sql create mode 100644 be0/migrations/008_audit_events.sql create mode 100644 be0/migrations/009_backup_artifact_roles_storage_kind.sql create mode 100644 be0/migrations/010_user_staff_profiles.sql create mode 100644 be0/migrations/011_academic_titles_vn.sql create mode 100644 be0/migrations/012_password_reset.sql create mode 100644 be0/migrations/013_email_verification.sql create mode 100644 be0/migrations/014_registration_otp.sql create mode 100644 be0/migrations/015_document_templates.sql create mode 100644 be0/migrations/016_research_projects.sql create mode 100644 be0/migrations/017_imagehub_datasets.sql create mode 100644 be0/migrations/018_imagehub_segmentation_links.sql create mode 100644 be0/migrations/019_imagehub_cloud_import.sql create mode 100644 be0/migrations/020_imagehub_dataset_stages.sql create mode 100644 be0/migrations/021_imagehub_task_pipeline.sql create mode 100644 be0/migrations/022_imagehub_task_annotations.sql create mode 100644 be0/migrations/023_imagehub_dataset_members.sql create mode 100644 be0/migrations/024_imagehub_dataset_project_link.sql create mode 100644 be0/migrations/025_imagehub_task_review_events.sql create mode 100644 be0/migrations/026_imagehub_file_folder_path.sql create mode 100644 be0/migrations/027_imagehub_dataset_label_map.sql create mode 100644 be0/requirements-dev.txt create mode 100644 be0/requirements.txt create mode 100644 be0/scripts/__pycache__/apply_initiative_migrations.cpython-313.pyc create mode 100644 be0/scripts/__pycache__/repair_split_submission.cpython-313.pyc create mode 100644 be0/scripts/add_ump_ideas.py create mode 100755 be0/scripts/apply-migration-007.sh create mode 100644 be0/scripts/apply_initiative_migrations.py create mode 100644 be0/scripts/repair_split_submission.py create mode 100644 be0/src/__init__.py create mode 100644 be0/src/__pycache__/MCP.cpython-311.pyc create mode 100644 be0/src/__pycache__/Memory_Manager.cpython-311.pyc create mode 100644 be0/src/__pycache__/Memory_Manager.cpython-38.pyc create mode 100644 be0/src/__pycache__/Pdf_Manager.cpython-311.pyc create mode 100644 be0/src/__pycache__/Response_Manager.cpython-311.pyc create mode 100644 be0/src/__pycache__/__init__.cpython-311.pyc create mode 100644 be0/src/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/__pycache__/__init__.cpython-38.pyc create mode 100644 be0/src/__pycache__/admin_audit_routes.cpython-311.pyc create mode 100644 be0/src/__pycache__/admin_audit_routes.cpython-313.pyc create mode 100644 be0/src/__pycache__/admin_user_profile_routes.cpython-311.pyc create mode 100644 be0/src/__pycache__/admin_user_profile_routes.cpython-313.pyc create mode 100644 be0/src/__pycache__/audit.cpython-311.pyc create mode 100644 be0/src/__pycache__/audit.cpython-313.pyc create mode 100644 be0/src/__pycache__/auth_api.cpython-311.pyc create mode 100644 be0/src/__pycache__/auth_api.cpython-313.pyc create mode 100644 be0/src/__pycache__/auth_credential_middleware.cpython-311.pyc create mode 100644 be0/src/__pycache__/auth_credential_middleware.cpython-313.pyc create mode 100644 be0/src/__pycache__/auth_jwt.cpython-311.pyc create mode 100644 be0/src/__pycache__/auth_jwt.cpython-313.pyc create mode 100644 be0/src/__pycache__/auth_mail.cpython-311.pyc create mode 100644 be0/src/__pycache__/auth_mail.cpython-313.pyc create mode 100644 be0/src/__pycache__/auth_rate_limit.cpython-311.pyc create mode 100644 be0/src/__pycache__/auth_rate_limit.cpython-313.pyc create mode 100644 be0/src/__pycache__/awareness_manager.cpython-311.pyc create mode 100644 be0/src/__pycache__/chat_assistant.cpython-311.pyc create mode 100644 be0/src/__pycache__/chat_assistant.cpython-313.pyc create mode 100644 be0/src/__pycache__/compliance_verifier.cpython-311.pyc create mode 100644 be0/src/__pycache__/compliance_verifier.cpython-313.pyc create mode 100644 be0/src/__pycache__/config.cpython-311.pyc create mode 100644 be0/src/__pycache__/config.cpython-38.pyc create mode 100644 be0/src/__pycache__/main_new.cpython-313.pyc create mode 100644 be0/src/__pycache__/memory_manager.cpython-313.pyc create mode 100644 be0/src/__pycache__/schemas.cpython-311.pyc create mode 100644 be0/src/__pycache__/staff_profile_domain.cpython-311.pyc create mode 100644 be0/src/__pycache__/staff_profile_domain.cpython-313.pyc create mode 100644 be0/src/__pycache__/structure_analysis.cpython-311.pyc create mode 100644 be0/src/__pycache__/structure_analysis.cpython-313.pyc create mode 100644 be0/src/__pycache__/template_manager.cpython-311.pyc create mode 100644 be0/src/__pycache__/text_io.cpython-311.pyc create mode 100644 be0/src/__pycache__/text_io.cpython-38.pyc create mode 100644 be0/src/__pycache__/utils.cpython-311.pyc create mode 100644 be0/src/__pycache__/utils.cpython-313.pyc create mode 100644 be0/src/__pycache__/utils.cpython-38.pyc create mode 100644 be0/src/admin_audit_routes.py create mode 100644 be0/src/admin_user_profile_routes.py create mode 100644 be0/src/application/__init__.py create mode 100644 be0/src/application/identity/__init__.py create mode 100644 be0/src/application/identity/dto.py create mode 100644 be0/src/application/identity/ports.py create mode 100644 be0/src/application/identity/use_cases/__init__.py create mode 100644 be0/src/application/identity/use_cases/authenticate_user.py create mode 100644 be0/src/audit.py create mode 100644 be0/src/auth_api.py create mode 100644 be0/src/auth_credential_middleware.py create mode 100644 be0/src/auth_jwt.py create mode 100644 be0/src/auth_mail.py create mode 100644 be0/src/auth_rate_limit.py create mode 100644 be0/src/be01/README.md create mode 100644 be0/src/be01/__pycache__/build_template.cpython-313.pyc create mode 100644 be0/src/be01/__pycache__/docx_normalize.cpython-311.pyc create mode 100644 be0/src/be01/__pycache__/docx_normalize.cpython-313.pyc create mode 100644 be0/src/be01/__pycache__/docx_to_pdf.cpython-311.pyc create mode 100644 be0/src/be01/__pycache__/docx_to_pdf.cpython-313.pyc create mode 100644 be0/src/be01/__pycache__/export_applications_list_xlsx.cpython-311.pyc create mode 100644 be0/src/be01/__pycache__/export_applications_list_xlsx.cpython-313.pyc create mode 100644 be0/src/be01/__pycache__/fill_application_form.cpython-311.pyc create mode 100644 be0/src/be01/__pycache__/fill_application_form.cpython-313.pyc create mode 100644 be0/src/be01/__pycache__/fill_template.cpython-313.pyc create mode 100644 be0/src/be01/__pycache__/official_to_data_blank.cpython-311.pyc create mode 100644 be0/src/be01/__pycache__/official_to_data_blank.cpython-313.pyc create mode 100644 be0/src/be01/build_template.py create mode 100644 be0/src/be01/data_blank.json create mode 100644 be0/src/be01/data_sop.json create mode 100644 be0/src/be01/docx_normalize.py create mode 100644 be0/src/be01/docx_to_pdf.py create mode 100644 be0/src/be01/export_applications_list_xlsx.py create mode 100644 be0/src/be01/fill_application_form.py create mode 100644 be0/src/be01/fill_template.py create mode 100644 be0/src/be01/filled_sop.docx create mode 100644 be0/src/be01/official_to_data_blank.py create mode 100644 be0/src/be01/template_sang_kien.docx create mode 100644 be0/src/chat_assistant.py create mode 100644 be0/src/compliance_verifier.py create mode 100644 be0/src/domain/__init__.py create mode 100644 be0/src/domain/identity/__init__.py create mode 100644 be0/src/domain/identity/entities.py create mode 100644 be0/src/domain/identity/errors.py create mode 100644 be0/src/domain/identity/repository.py create mode 100644 be0/src/domain/identity/services.py create mode 100644 be0/src/domain/identity/value_objects.py create mode 100644 be0/src/imagehub_routes.py create mode 100644 be0/src/imagehub_segmentation.py create mode 100644 be0/src/imagehub_task_pipeline.py create mode 100644 be0/src/infrastructure/config/__pycache__/settings.cpython-313.pyc create mode 100644 be0/src/infrastructure/config/settings.py create mode 100644 be0/src/infrastructure/vector_db/__pycache__/qdrant_service.cpython-311.pyc create mode 100644 be0/src/infrastructure/vector_db/__pycache__/qdrant_service.cpython-313.pyc create mode 100644 be0/src/infrastructure/vector_db/qdrant_service.py create mode 100644 be0/src/initiative_db/__init__.py create mode 100644 be0/src/initiative_db/__pycache__/__init__.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/application_admin_results.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/application_admin_results.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/application_backup.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/application_backup.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/application_storage.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/application_storage.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/backup_naming.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/backup_naming.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/drafts.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/drafts.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/engine.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/engine.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/models.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/models.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/repair_split_submission.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/submission_readiness.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/submission_readiness.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/submissions.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/submissions.cpython-313.pyc create mode 100644 be0/src/initiative_db/__pycache__/user_notifications.cpython-311.pyc create mode 100644 be0/src/initiative_db/__pycache__/user_notifications.cpython-313.pyc create mode 100644 be0/src/initiative_db/application_admin_results.py create mode 100644 be0/src/initiative_db/application_backup.py create mode 100644 be0/src/initiative_db/application_storage.py create mode 100644 be0/src/initiative_db/backup_naming.py create mode 100644 be0/src/initiative_db/drafts.py create mode 100644 be0/src/initiative_db/engine.py create mode 100644 be0/src/initiative_db/models.py create mode 100644 be0/src/initiative_db/repair_split_submission.py create mode 100644 be0/src/initiative_db/submission_readiness.py create mode 100644 be0/src/initiative_db/submissions.py create mode 100644 be0/src/initiative_db/user_notifications.py create mode 100644 be0/src/internal_control/__init__.py create mode 100644 be0/src/internal_control/__pycache__/__init__.cpython-311.pyc create mode 100644 be0/src/internal_control/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/access_control/__init__.py create mode 100644 be0/src/internal_control/access_control/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/cloud_infrastructure_controls/__init__.py create mode 100644 be0/src/internal_control/cloud_infrastructure_controls/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/data_integrity_security/__init__.py create mode 100644 be0/src/internal_control/data_integrity_security/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/it_asset_management/__init__.py create mode 100644 be0/src/internal_control/it_asset_management/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/it_change_management/__init__.py create mode 100644 be0/src/internal_control/it_change_management/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/it_governance/__init__.py create mode 100644 be0/src/internal_control/it_governance/__pycache__/__init__.cpython-311.pyc create mode 100644 be0/src/internal_control/it_governance/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/it_governance/__pycache__/document_io.cpython-311.pyc create mode 100644 be0/src/internal_control/it_governance/__pycache__/document_io.cpython-313.pyc create mode 100644 be0/src/internal_control/it_governance/__pycache__/memory_manager.cpython-313.pyc create mode 100644 be0/src/internal_control/it_governance/__pycache__/response_manager.cpython-313.pyc create mode 100644 be0/src/internal_control/it_governance/document_io.py create mode 100644 be0/src/internal_control/it_governance/memory_manager.py create mode 100644 be0/src/internal_control/it_governance/response_manager.py create mode 100644 be0/src/internal_control/it_operations/__init__.py create mode 100644 be0/src/internal_control/it_operations/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/logical_physical_security/__init__.py create mode 100644 be0/src/internal_control/logical_physical_security/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/monitoring_logging/__init__.py create mode 100644 be0/src/internal_control/monitoring_logging/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/network_security/__init__.py create mode 100644 be0/src/internal_control/network_security/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/patch_vuln_management/__init__.py create mode 100644 be0/src/internal_control/patch_vuln_management/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/security_awareness_training/__init__.py create mode 100644 be0/src/internal_control/security_awareness_training/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/system_dev_lifecycle/__init__.py create mode 100644 be0/src/internal_control/system_dev_lifecycle/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/system_dev_lifecycle/__pycache__/sdlc_planning.cpython-313.pyc create mode 100644 be0/src/internal_control/system_dev_lifecycle/sdlc_planning.py create mode 100644 be0/src/internal_control/system_interface_data_transfer/__init__.py create mode 100644 be0/src/internal_control/system_interface_data_transfer/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/internal_control/vendor_management/__init__.py create mode 100644 be0/src/internal_control/vendor_management/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/memory_manager.py create mode 100644 be0/src/minio/002_add_upload_status.py create mode 100644 be0/src/minio/__init__.py create mode 100644 be0/src/minio/__pycache__/002_add_upload_status.cpython-313.pyc create mode 100644 be0/src/minio/__pycache__/__init__.cpython-311.pyc create mode 100644 be0/src/minio/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/minio/__pycache__/attachments.cpython-313.pyc create mode 100644 be0/src/minio/__pycache__/cleanup.cpython-313.pyc create mode 100644 be0/src/minio/__pycache__/storage.cpython-311.pyc create mode 100644 be0/src/minio/__pycache__/storage.cpython-313.pyc create mode 100644 be0/src/minio/__pycache__/test_storage.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/src/minio/__pycache__/test_storage.cpython-313.pyc create mode 100644 be0/src/minio/attachments.py create mode 100644 be0/src/minio/cleanup.py create mode 100644 be0/src/minio/storage.py create mode 100644 be0/src/minio/test_storage.py create mode 100644 be0/src/research_routes.py create mode 100644 be0/src/shared_kernel/__init__.py create mode 100644 be0/src/shared_kernel/entity.py create mode 100644 be0/src/shared_kernel/errors.py create mode 100644 be0/src/shared_kernel/value_object.py create mode 100644 be0/src/staff_profile_domain.py create mode 100644 be0/src/structure_analysis.py create mode 100644 be0/src/template_routes.py create mode 100644 be0/src/test/__init__.py create mode 100644 be0/src/test/__pycache__/__init__.cpython-313.pyc create mode 100644 be0/src/test/__pycache__/test_docx_pseudo_fill.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/src/test/__pycache__/test_docx_pseudo_fill.cpython-313.pyc create mode 100644 be0/src/test/pseudo_data_blank.json create mode 100644 be0/src/test/test_docx_pseudo_fill.py create mode 100644 be0/src/utils.py create mode 100644 be0/template_application_form.docx create mode 100644 be0/tests/__pycache__/auth_register_staff_fixture.cpython-313.pyc create mode 100644 be0/tests/__pycache__/security_token_fixture.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_admin_audit_routes.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_admin_audit_routes.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_application_backup.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_application_backup.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_application_drafts_get.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_application_drafts_get.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_applications_db_integration.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_applications_db_integration.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_auth_password_reset_integration.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_auth_password_reset_integration.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_auth_policy_integration.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_auth_policy_integration.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_backup_e2e.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_backup_e2e.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_dashboard_lookup_routes.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_dashboard_lookup_routes.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_docx_normalize.cpython-311.pyc create mode 100644 be0/tests/__pycache__/test_docx_normalize.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_docx_normalize.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_evidence_initiative_resolution.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_evidence_initiative_resolution.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_evidence_kind_parsing.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_evidence_kind_parsing.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_official_to_data_blank_ban_cam_ket.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_official_to_data_blank_ban_cam_ket.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_official_to_data_blank_don_vi.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_official_to_data_blank_don_vi.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_registration_otp.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_registration_otp.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_registration_stack_alignment.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_registration_stack_alignment.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_repair_split_submission.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_repair_split_submission.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_security_routes.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_security_routes.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_staff_profile_domain.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_staff_profile_domain.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_submission_readiness.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_submission_readiness.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_submissions_projection_research_kind.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_submissions_projection_research_kind.cpython-313.pyc create mode 100644 be0/tests/__pycache__/test_user_notifications_merit.cpython-313-pytest-8.3.4.pyc create mode 100644 be0/tests/__pycache__/test_user_notifications_merit.cpython-313.pyc create mode 100644 be0/tests/auth_register_staff_fixture.py create mode 100644 be0/tests/fixtures/__pycache__/minimal_submit_bundle.cpython-313.pyc create mode 100644 be0/tests/fixtures/minimal_submit_bundle.py create mode 100644 be0/tests/security_token_fixture.py create mode 100644 be0/tests/test_admin_audit_routes.py create mode 100644 be0/tests/test_application_backup.py create mode 100644 be0/tests/test_application_drafts_get.py create mode 100644 be0/tests/test_applications_db_integration.py create mode 100644 be0/tests/test_auth_password_reset_integration.py create mode 100644 be0/tests/test_auth_policy_integration.py create mode 100644 be0/tests/test_authenticate_user.py create mode 100644 be0/tests/test_backup_e2e.py create mode 100644 be0/tests/test_dashboard_lookup_routes.py create mode 100644 be0/tests/test_docx_normalize.py create mode 100644 be0/tests/test_evidence_initiative_resolution.py create mode 100644 be0/tests/test_evidence_kind_parsing.py create mode 100644 be0/tests/test_filename_normalize.py create mode 100644 be0/tests/test_identity_domain.py create mode 100644 be0/tests/test_imagehub_datasets.py create mode 100644 be0/tests/test_imagehub_segmentation.py create mode 100644 be0/tests/test_imagehub_tasks.py create mode 100644 be0/tests/test_official_to_data_blank_ban_cam_ket.py create mode 100644 be0/tests/test_official_to_data_blank_don_vi.py create mode 100644 be0/tests/test_registration_otp.py create mode 100644 be0/tests/test_registration_stack_alignment.py create mode 100644 be0/tests/test_repair_split_submission.py create mode 100644 be0/tests/test_research_routes.py create mode 100644 be0/tests/test_security_routes.py create mode 100644 be0/tests/test_staff_profile_domain.py create mode 100644 be0/tests/test_submission_readiness.py create mode 100644 be0/tests/test_submissions_projection_research_kind.py create mode 100644 be0/tests/test_user_notifications_merit.py create mode 100644 be0/tools/__pycache__/e2e_application_form_pdf_export.cpython-313.pyc create mode 100644 be0/tools/e2e_application_form_pdf_export.py create mode 100644 be0/tools/e2e_sample_official_bieu_mau.json create mode 100644 database/crud_examples.sql create mode 100644 database/schema.sql create mode 100644 database/test_schema.sql create mode 100644 deploy/nginx/minio-s3-proxy.conf.example create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docs/ADMIN_APPLICANT_NOTIFICATION_SYSTEM_ANALYSIS.md create mode 100644 docs/ADMIN_APPLICATIONS_ADMIN_RESULT_RADICAL_FIX_PLAN.md create mode 100644 docs/ARCHITECTURE_REDESIGN.md create mode 100644 docs/ARCHITECTURE_SUMMARY.md create mode 100644 docs/FIX_CHAT_ERROR.md create mode 100644 docs/HANDOFF.md create mode 100644 docs/PDF_TEMPLATE_IMPLEMENTATION.md create mode 100644 docs/PDF_converter.md create mode 100644 docs/PDF_preview.md create mode 100644 docs/SIMPLIFIED_TECH_STACK.md create mode 100644 docs/Signup.md create mode 100644 docs/TECH_STACK_COMPARISON.md create mode 100644 docs/application-files-persistence-and-backup.md create mode 100644 docs/architecture-improvement-proposals.md create mode 100644 docs/architecture/medical-imaging-3d-viewer-spec.md create mode 100644 docs/architecture/platform_architecture.md create mode 100644 docs/audit-log-manager-implementation.md create mode 100644 docs/audit-log-manager-plan.md create mode 100644 docs/auth-implementation-feedback.md create mode 100644 docs/auth-registration-and-user-management.md create mode 100644 docs/backend-clean-architecture.md create mode 100644 docs/deploy-production-docker.md create mode 100644 docs/deploy-stack-overview.md create mode 100644 docs/document-templates-feature.md create mode 100644 docs/fe0-dashboard-data-refresh-architecture.md create mode 100644 docs/feedback-data-management.md create mode 100644 docs/feedback.md create mode 100644 docs/minio-behind-https.md create mode 100644 docs/otp-registration-state-machine.md create mode 100644 docs/research-project-cockpit-feature.md create mode 100644 docs/sang-kien-01-to-tham-dinh-cap-don-vi.md create mode 100644 docs/security-incident-rcc-ump-2026-05-27.md create mode 100644 docs/user-profile-manager-db-review.md create mode 100644 docs/user-profile-manager-state-machine-plan.md create mode 100644 fe0/.env.e2e.example create mode 100644 fe0/.gitignore create mode 100644 fe0/CHAT_INTEGRATION.md create mode 100644 fe0/DEBUG_NETWORK_ERROR.md create mode 100644 fe0/Dockerfile create mode 100644 fe0/Dockerfile.prod create mode 100644 fe0/FRONTEND_ARCHITECTURE.md create mode 100644 fe0/FRONTEND_MIGRATION_GUIDE.md create mode 100644 fe0/FRONTEND_SUMMARY.md create mode 100644 fe0/QA_APPLICANT_FRESH_FORMS.md create mode 100644 fe0/README.md create mode 100644 fe0/bun.lockb create mode 100644 fe0/components.json create mode 100644 fe0/e2e/backup-admin-download.spec.ts create mode 100644 fe0/e2e/initiative-application-form-typing.spec.ts create mode 100644 fe0/eslint.config.js create mode 100644 fe0/index.html create mode 100644 fe0/nginx/default.conf create mode 100644 fe0/package-lock.json create mode 100644 fe0/package.json create mode 100644 fe0/playwright.config.ts create mode 100644 fe0/postcss.config.js create mode 100644 fe0/public/assets/bieu_mau_sang_kien_template.json create mode 100644 fe0/public/assets/data_blank.json create mode 100644 fe0/public/assets/template_application_form.docx create mode 100644 fe0/public/assets/template_application_forrm.docx create mode 100644 fe0/public/assets/typescript_component_guide (1).md create mode 100644 fe0/public/logo.png create mode 100644 fe0/public/logo.svg create mode 100644 fe0/scripts/save-report-to-public.mjs create mode 100644 fe0/src/App.css create mode 100644 fe0/src/App.tsx create mode 100644 fe0/src/admin/audit/AuditEventDetailSheet.tsx create mode 100644 fe0/src/admin/audit/AuditLogFilters.tsx create mode 100644 fe0/src/admin/audit/AuditLogManagerPage.tsx create mode 100644 fe0/src/admin/audit/AuditLogTable.tsx create mode 100644 fe0/src/admin/auth/RegistrationPage.tsx create mode 100644 fe0/src/admin/auth/index.ts create mode 100644 fe0/src/applicant/audit/actionLabels.ts create mode 100644 fe0/src/applicant/auth/RegistrationPage.tsx create mode 100644 fe0/src/applicant/auth/index.ts create mode 100644 fe0/src/audit/adminAuditApi.ts create mode 100644 fe0/src/audit/formatAuditTime.ts create mode 100644 fe0/src/audit/jsonMicrodiffLines.ts create mode 100644 fe0/src/audit/types.ts create mode 100644 fe0/src/auth/LoginRegisterCard.tsx create mode 100644 fe0/src/auth/index.ts create mode 100644 fe0/src/auth/institutionalEmail.ts create mode 100644 fe0/src/auth/primaryRole.ts create mode 100644 fe0/src/auth/registration/OtpSixInputs.tsx create mode 100644 fe0/src/auth/registration/RegistrationWithOtp.tsx create mode 100644 fe0/src/auth/registration/constants.ts create mode 100644 fe0/src/auth/registration/passwordPolicy.ts create mode 100644 fe0/src/auth/sessionUser.ts create mode 100644 fe0/src/components/ArticleCard.tsx create mode 100644 fe0/src/components/ChatAssistant.tsx create mode 100644 fe0/src/components/ContributionConfirmationForm.tsx create mode 100644 fe0/src/components/DashboardHeader.tsx create mode 100644 fe0/src/components/DashboardSidebar.tsx create mode 100644 fe0/src/components/Header.tsx create mode 100644 fe0/src/components/HeroSection.tsx create mode 100644 fe0/src/components/InitiativeApplicationForm.draftTyping.integration.test.tsx create mode 100644 fe0/src/components/InitiativeApplicationForm.tsx create mode 100644 fe0/src/components/InitiativeEvaluationForm.tsx create mode 100644 fe0/src/components/InitiativeReportForm.tsx create mode 100644 fe0/src/components/IntroSection.tsx create mode 100644 fe0/src/components/SignUpModal.tsx create mode 100644 fe0/src/components/UserMenu.tsx create mode 100644 fe0/src/components/admin/AIManagementTab.tsx create mode 100644 fe0/src/components/admin/ApplicationBackupDownloadButton.tsx create mode 100644 fe0/src/components/admin/ApprovedApplicationsList.tsx create mode 100644 fe0/src/components/admin/DashboardSidebar.tsx create mode 100644 fe0/src/components/admin/IdeasManagementTab.tsx create mode 100644 fe0/src/components/admin/OverviewTab.tsx create mode 100644 fe0/src/components/admin/RolePermissionsTab.tsx create mode 100644 fe0/src/components/admin/SearchableSelect.tsx create mode 100644 fe0/src/components/admin/SelectOptions.tsx create mode 100644 fe0/src/components/admin/ViewSubmittedApplicationButton.integration.test.tsx create mode 100644 fe0/src/components/admin/ViewSubmittedApplicationButton.tsx create mode 100644 fe0/src/components/admin/profile/AdminUserProfilesManager.tsx create mode 100644 fe0/src/components/admin/profile/index.ts create mode 100644 fe0/src/components/admin/result/ConsideredInitiativesList.tsx create mode 100644 fe0/src/components/admin/result/DecidedApplicationsPanel.tsx create mode 100644 fe0/src/components/admin/result/ResultManager.tsx create mode 100644 fe0/src/components/admin/result/invalidateAdminApplicationResultQueries.ts create mode 100644 fe0/src/components/admin/review/AdminApplicationFormDocxPreview.tsx create mode 100644 fe0/src/components/admin/review/AdminApproveReviewDialog.tsx create mode 100644 fe0/src/components/admin/review/AdminDocxTemplatePreview.tsx create mode 100644 fe0/src/components/admin/review/AdminEvaluationFormActions.tsx create mode 100644 fe0/src/components/admin/review/AdminRejectReviewDialog.tsx create mode 100644 fe0/src/components/admin/review/AdminStaffReadonlyReviewDialog.tsx create mode 100644 fe0/src/components/admin/review/StaffApplicationBundleReviewTab.tsx create mode 100644 fe0/src/components/admin/review/applicationMeritCategoryHint.test.ts create mode 100644 fe0/src/components/admin/review/applicationMeritCategoryHint.ts create mode 100644 fe0/src/components/admin/review/buildInitiativeDraftFromReviewTabs.ts create mode 100644 fe0/src/components/admin/review/councilEvaluationSnapshotContext.tsx create mode 100644 fe0/src/components/admin/review/docxTemplateCompleteness.ts create mode 100644 fe0/src/components/admin/review/evaluationCategoryRecommendation.test.ts create mode 100644 fe0/src/components/admin/review/evaluationCategoryRecommendation.ts create mode 100644 fe0/src/components/admin/review/reviewOutcomeStorage.ts create mode 100644 fe0/src/components/applicant/ApplicantSidebarMenuItem.tsx create mode 100644 fe0/src/components/applicant/AuthorArticleCommitmentForm.tsx create mode 100644 fe0/src/components/applicant/DashboardSidebar.tsx create mode 100644 fe0/src/components/applicant/DomesticJournalHonestyForm.tsx create mode 100644 fe0/src/components/applicant/ReferenceMaterialHonestyForm.tsx create mode 100644 fe0/src/components/applicant/WorkplaceSelect.tsx create mode 100644 fe0/src/components/applicant/applicationDrafts.ts create mode 100644 fe0/src/components/applicant/dashboard/ApplicantRegistrationDashboard.tsx create mode 100644 fe0/src/components/applicant/dashboard/applicantDraftSessionUtils.ts create mode 100644 fe0/src/components/applicant/dashboard/index.ts create mode 100644 fe0/src/components/applicant/evidence_viewer/GraphProcessor.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/GraphVisualizer2.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/ITProjectKnowledge.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/KnowledgePro.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/KnowledgeViz.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/GraphToolbar.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/PropertyInspector.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/checklists/AddDocumentModal.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/checklists/ChecklistHeader.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/checklists/DocumentModal.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/checklists/DocumentTable.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/checklists/FilterBar.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/checklists/ITProjectManager.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/checklists/ImportModal.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/checklists/OveralSidebar.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/checklists/iso27001-questionnaire-ui.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/checklists/iso27001-questionnaire.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/commonControlFramework/CCFDashboard.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/commonControlFramework/CCFNavigation.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/documents/Announcements.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/documents/DocDashboard.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/documents/DocDropdown.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/documents/DocList.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/documents/Outputs.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/documents/QuestionList.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/documents/iso27001-questionnaire-ui.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/documents/iso27001-questionnaire.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/documents/knowledge_graph_viz.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/documents/pdfProcessor.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/graphCanva/DragManager.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/graphCanva/colorManager.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/graphCanva/constant.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/graphCanva/linkManager.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/graphCanva/nodeManager.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/graphCanvas.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphComponents/graphGov.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphConfig/graphConfig.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphTypes/graphTypes.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphUtils/enhancedGraphUtils.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphUtils/graphUtils.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/graphUtils/loadGraphData.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/submittedEvidence/EmbeddedPdfViewer.tsx create mode 100644 fe0/src/components/applicant/evidence_viewer/submittedEvidence/evidencePreviewUtils.ts create mode 100644 fe0/src/components/applicant/history/ApplicantHistoryCrudDialog.tsx create mode 100644 fe0/src/components/applicant/history/ApplicantHistoryPanel.tsx create mode 100644 fe0/src/components/applicant/history/ApplicantHistoryTable.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/ApplicationFormDocxPreview.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/ApplicationFormOfficialPdfActions.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/DraftContext.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/DraftJsonPanel.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/OfficialFormPdfPreviewDialog.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/PdfTextLayoutEditorDialog.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/ReviewPanel.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/ReviewSnapshotSections.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/TemplateFiller.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/applicationAuthorsContributionTable.ts create mode 100644 fe0/src/components/applicant/initiative-draft/applicationEvidenceApi.ts create mode 100644 fe0/src/components/applicant/initiative-draft/applicationFormDocxPreview.css create mode 100644 fe0/src/components/applicant/initiative-draft/applicationFormDocxPreviewFormat.ts create mode 100644 fe0/src/components/applicant/initiative-draft/applicationFormOfficialPdf.ts create mode 100644 fe0/src/components/applicant/initiative-draft/bieuMauPreviewSections.ts create mode 100644 fe0/src/components/applicant/initiative-draft/bieu_mau_sang_kien_template.json create mode 100644 fe0/src/components/applicant/initiative-draft/buildDocxTemplateExportBundle.ts create mode 100644 fe0/src/components/applicant/initiative-draft/contributionDraftTypes.ts create mode 100644 fe0/src/components/applicant/initiative-draft/contributionRepresentativeAuthorSync.ts create mode 100644 fe0/src/components/applicant/initiative-draft/dashboardDraftSync.integration.test.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/docxApplicationFormPreviewOptions.ts create mode 100644 fe0/src/components/applicant/initiative-draft/downloadMergedTemplateJson.ts create mode 100644 fe0/src/components/applicant/initiative-draft/draftJsonFullShape.ts create mode 100644 fe0/src/components/applicant/initiative-draft/draftStorage.ts create mode 100644 fe0/src/components/applicant/initiative-draft/draftToTemplateData.ts create mode 100644 fe0/src/components/applicant/initiative-draft/mapBanCamKetToOfficialBieuMau.ts create mode 100644 fe0/src/components/applicant/initiative-draft/mapDraftToOfficialBieuMau.ts create mode 100644 fe0/src/components/applicant/initiative-draft/mapReferenceMaterialHonestyToOfficialBieuMau.ts create mode 100644 fe0/src/components/applicant/initiative-draft/mapResearchDomesticHonestyToOfficialBieuMau.ts create mode 100644 fe0/src/components/applicant/initiative-draft/mergeContributionWorkUnits.ts create mode 100644 fe0/src/components/applicant/initiative-draft/officialBieuMauToTemplateDataRecord.ts create mode 100644 fe0/src/components/applicant/initiative-draft/officialFormPreviewFieldEdits.test.ts create mode 100644 fe0/src/components/applicant/initiative-draft/officialFormPreviewFieldEdits.ts create mode 100644 fe0/src/components/applicant/initiative-draft/pdfExport.tsx create mode 100644 fe0/src/components/applicant/initiative-draft/pdfLayoutEditor.ts create mode 100644 fe0/src/components/applicant/initiative-draft/reportApplicationFieldMirror.ts create mode 100644 fe0/src/components/applicant/initiative-draft/reportAuthorEvaluationMerge.ts create mode 100644 fe0/src/components/applicant/initiative-draft/reportContentSummaryMerge.ts create mode 100644 fe0/src/components/applicant/initiative-draft/serializers.ts create mode 100644 fe0/src/components/applicant/initiative-draft/types.ts create mode 100644 fe0/src/components/applicant/initiative-draft/useDraft.ts create mode 100644 fe0/src/components/applicant/initiativeFormTypes.ts create mode 100644 fe0/src/components/applicant/lib/dataParser.ts create mode 100644 fe0/src/components/applicant/lib/pdfExporter.ts create mode 100644 fe0/src/components/applicant/lib/placeholderDetector.ts create mode 100644 fe0/src/components/applicant/lib/templateEngine.ts create mode 100644 fe0/src/components/applicant/lib/templateTypes.ts create mode 100644 fe0/src/components/applicant/pdfExport.tsx create mode 100644 fe0/src/components/applicant/profile/ApplicantProfileView.tsx create mode 100644 fe0/src/components/applicant/profile/ApplicantStaffProfileSection.tsx create mode 100644 fe0/src/components/applicant/profile/index.ts create mode 100644 fe0/src/components/applicant/submitInitiativePdf.ts create mode 100644 fe0/src/components/application-review/DocxToPdfViewer.tsx create mode 100644 fe0/src/components/application-review/ReviewFormsPanel.tsx create mode 100644 fe0/src/components/application-review/StaffApplicationReviewShell.tsx create mode 100644 fe0/src/components/application-review/docx-to-pdf-demo.html create mode 100644 fe0/src/components/application-review/docxToPdf/DocxToPdfViewer.tsx create mode 100644 fe0/src/components/application-review/docxToPdf/convertDocxToPdfBlob.ts create mode 100644 fe0/src/components/auth/PermissionGate.tsx create mode 100644 fe0/src/components/auth/ProtectedRoute.tsx create mode 100644 fe0/src/components/council/ApplicationIdeaRow.tsx create mode 100644 fe0/src/components/council/ApprovedApplicationsList.tsx create mode 100644 fe0/src/components/council/SearchableSelect.tsx create mode 100644 fe0/src/components/council/SelectOptions.tsx create mode 100644 fe0/src/components/council/demoApplicationInstances.ts create mode 100644 fe0/src/components/council/evaluation/InitiativeEvaluationForm.tsx create mode 100644 fe0/src/components/evidence/ApplicationEvidenceDashboard.tsx create mode 100644 fe0/src/components/evidence/ApplicationEvidenceFilePreview.tsx create mode 100644 fe0/src/components/evidence/ApplicationEvidenceLayout.tsx create mode 100644 fe0/src/components/evidence/ApplicationEvidenceManagePage.tsx create mode 100644 fe0/src/components/evidence/ApplicationEvidencePreviewPage.tsx create mode 100644 fe0/src/components/evidence/ApplicationEvidenceViewerPanel.tsx create mode 100644 fe0/src/components/evidence/EvidenceFileTypeIcon.tsx create mode 100644 fe0/src/components/evidence/EvidenceFilesTableCell.tsx create mode 100644 fe0/src/components/evidence/EvidenceVaultLinkButton.tsx create mode 100644 fe0/src/components/evidence/applicationEvidenceConstants.ts create mode 100644 fe0/src/components/evidence/applicationEvidenceRowUtils.ts create mode 100644 fe0/src/components/evidence/evidenceStaffReviewAck.ts create mode 100644 fe0/src/components/notifications/NotificationBell.tsx create mode 100644 fe0/src/components/notifications/NotificationManager.tsx create mode 100644 fe0/src/components/profile/ProfileVerificationBadge.tsx create mode 100644 fe0/src/components/profile/StaffProfileFormFields.tsx create mode 100644 fe0/src/components/profile/academicTitleOptions.ts create mode 100644 fe0/src/components/profile/index.ts create mode 100644 fe0/src/components/profile/staffProfileValidation.test.ts create mode 100644 fe0/src/components/profile/staffProfileValidation.ts create mode 100644 fe0/src/components/profile/types.ts create mode 100644 fe0/src/components/ui/accordion.tsx create mode 100644 fe0/src/components/ui/alert-dialog.tsx create mode 100644 fe0/src/components/ui/alert.tsx create mode 100644 fe0/src/components/ui/aspect-ratio.tsx create mode 100644 fe0/src/components/ui/avatar.tsx create mode 100644 fe0/src/components/ui/badge.tsx create mode 100644 fe0/src/components/ui/breadcrumb.tsx create mode 100644 fe0/src/components/ui/button.tsx create mode 100644 fe0/src/components/ui/calendar.tsx create mode 100644 fe0/src/components/ui/card.tsx create mode 100644 fe0/src/components/ui/carousel.tsx create mode 100644 fe0/src/components/ui/chart.tsx create mode 100644 fe0/src/components/ui/checkbox.tsx create mode 100644 fe0/src/components/ui/collapsible.tsx create mode 100644 fe0/src/components/ui/command.tsx create mode 100644 fe0/src/components/ui/context-menu.tsx create mode 100644 fe0/src/components/ui/dialog.tsx create mode 100644 fe0/src/components/ui/drawer.tsx create mode 100644 fe0/src/components/ui/dropdown-menu.tsx create mode 100644 fe0/src/components/ui/form.tsx create mode 100644 fe0/src/components/ui/hover-card.tsx create mode 100644 fe0/src/components/ui/input-otp.tsx create mode 100644 fe0/src/components/ui/input.tsx create mode 100644 fe0/src/components/ui/label.tsx create mode 100644 fe0/src/components/ui/menubar.tsx create mode 100644 fe0/src/components/ui/navigation-menu.tsx create mode 100644 fe0/src/components/ui/pagination.tsx create mode 100644 fe0/src/components/ui/popover.tsx create mode 100644 fe0/src/components/ui/progress.tsx create mode 100644 fe0/src/components/ui/radio-group.tsx create mode 100644 fe0/src/components/ui/resizable.tsx create mode 100644 fe0/src/components/ui/scroll-area.tsx create mode 100644 fe0/src/components/ui/select.tsx create mode 100644 fe0/src/components/ui/separator.tsx create mode 100644 fe0/src/components/ui/sheet.tsx create mode 100644 fe0/src/components/ui/sidebar.tsx create mode 100644 fe0/src/components/ui/skeleton.tsx create mode 100644 fe0/src/components/ui/slider.tsx create mode 100644 fe0/src/components/ui/sonner.tsx create mode 100644 fe0/src/components/ui/switch.tsx create mode 100644 fe0/src/components/ui/table.tsx create mode 100644 fe0/src/components/ui/tabs.tsx create mode 100644 fe0/src/components/ui/textarea.tsx create mode 100644 fe0/src/components/ui/toast.tsx create mode 100644 fe0/src/components/ui/toaster.tsx create mode 100644 fe0/src/components/ui/toggle-group.tsx create mode 100644 fe0/src/components/ui/toggle.tsx create mode 100644 fe0/src/components/ui/tooltip.tsx create mode 100644 fe0/src/components/ui/use-toast.ts create mode 100644 fe0/src/contexts/AuthContext.tsx create mode 100644 fe0/src/data/articles.ts create mode 100644 fe0/src/data/departmentOptions.ts create mode 100644 fe0/src/data/mockApplications.ts create mode 100644 fe0/src/features/chat/hooks/useChat.ts create mode 100644 fe0/src/features/chat/services/chatService.ts create mode 100644 fe0/src/features/chat/types/chat.types.ts create mode 100644 fe0/src/features/workflows/hooks/useWorkflows.ts create mode 100644 fe0/src/features/workflows/services/workflowService.ts create mode 100644 fe0/src/features/workflows/types/workflow.types.ts create mode 100644 fe0/src/hooks/use-mobile.tsx create mode 100644 fe0/src/hooks/use-toast.ts create mode 100644 fe0/src/hooks/useApplicantRegistrationState.test.tsx create mode 100644 fe0/src/hooks/useApplicantRegistrationState.ts create mode 100644 fe0/src/hooks/useApplicationReviewDetail.ts create mode 100644 fe0/src/hooks/useEvidenceStaffReviewAck.ts create mode 100644 fe0/src/hooks/useVisibilityAwareRefetchInterval.ts create mode 100644 fe0/src/index.css create mode 100644 fe0/src/layouts/DashboardLayout.tsx create mode 100644 fe0/src/lib/applicantApplicationsApi.ts create mode 100644 fe0/src/lib/applicantHonestyPrerequisites.ts create mode 100644 fe0/src/lib/applicantRegistrationSession.test.ts create mode 100644 fe0/src/lib/applicantRegistrationSession.ts create mode 100644 fe0/src/lib/applicantSubmissionRecord.ts create mode 100644 fe0/src/lib/applicationAdminResultApi.ts create mode 100644 fe0/src/lib/applicationBackupDownload.ts create mode 100644 fe0/src/lib/applicationEvidenceApi.ts create mode 100644 fe0/src/lib/applicationFormDocxApi.ts create mode 100644 fe0/src/lib/applicationReviewApi.ts create mode 100644 fe0/src/lib/applicationReviewNavigation.ts create mode 100644 fe0/src/lib/auth-service.register.contract.test.ts create mode 100644 fe0/src/lib/auth-service.ts create mode 100644 fe0/src/lib/dashboardNavigation.ts create mode 100644 fe0/src/lib/docxJustifyMitigationCss.ts create mode 100644 fe0/src/lib/docxPreviewClient.ts create mode 100644 fe0/src/lib/docxTableReflow.ts create mode 100644 fe0/src/lib/evidenceFileKind.ts create mode 100644 fe0/src/lib/evidencePreviewUtils.ts create mode 100644 fe0/src/lib/evidenceUploadLimits.ts create mode 100644 fe0/src/lib/initiativeSubmitWorkflow.ts create mode 100644 fe0/src/lib/networkTimeout.ts create mode 100644 fe0/src/lib/officialFormLayoutApi.ts create mode 100644 fe0/src/lib/officialFormPdfFileName.ts create mode 100644 fe0/src/lib/permissions.ts create mode 100644 fe0/src/lib/pizzipClient.ts create mode 100644 fe0/src/lib/resolveApplicationAssetUrl.ts create mode 100644 fe0/src/lib/resolveApplicationDraftCaseId.test.ts create mode 100644 fe0/src/lib/resolveApplicationDraftCaseId.ts create mode 100644 fe0/src/lib/reviewDocumentApi.ts create mode 100644 fe0/src/lib/sharedDocxOfficialFormRenderOptions.ts create mode 100644 fe0/src/lib/user-profile-service.ts create mode 100644 fe0/src/lib/userNotificationsApi.ts create mode 100644 fe0/src/lib/utils.ts create mode 100644 fe0/src/lib/vnDateFormat.ts create mode 100644 fe0/src/main.tsx create mode 100644 fe0/src/pages/About.tsx create mode 100644 fe0/src/pages/AdminApplicationReviewPage.test.tsx create mode 100644 fe0/src/pages/AdminApplicationReviewPage.tsx create mode 100644 fe0/src/pages/AdminPanel.tsx create mode 100644 fe0/src/pages/AdminProjectsPage.tsx create mode 100644 fe0/src/pages/AdminUserProfilesPage.tsx create mode 100644 fe0/src/pages/ApplicantProfilePage.tsx create mode 100644 fe0/src/pages/ApplicationReviewPage.tsx create mode 100644 fe0/src/pages/Article.tsx create mode 100644 fe0/src/pages/Authors.tsx create mode 100644 fe0/src/pages/Contact.tsx create mode 100644 fe0/src/pages/CouncilApplicationReviewPage.tsx create mode 100644 fe0/src/pages/Dashboard.tsx create mode 100644 fe0/src/pages/ForgotPasswordPage.tsx create mode 100644 fe0/src/pages/Growth.tsx create mode 100644 fe0/src/pages/Index.tsx create mode 100644 fe0/src/pages/Login.tsx create mode 100644 fe0/src/pages/NotFound.tsx create mode 100644 fe0/src/pages/NotificationsPage.tsx create mode 100644 fe0/src/pages/Privacy.tsx create mode 100644 fe0/src/pages/RenewFormPanel.tsx create mode 100644 fe0/src/pages/ResetPasswordPage.tsx create mode 100644 fe0/src/pages/StyleGuide.tsx create mode 100644 fe0/src/pages/Terms.tsx create mode 100644 fe0/src/pages/Travel.tsx create mode 100644 fe0/src/pages/Unauthorized.tsx create mode 100644 fe0/src/pages/VerifyEmailPage.tsx create mode 100644 fe0/src/pages/applicantWorkflow.integration.test.tsx create mode 100644 fe0/src/shared/api/client.queryRetry.test.ts create mode 100644 fe0/src/shared/api/client.ts create mode 100644 fe0/src/shared/components/feedback/LoadingSpinner.tsx create mode 100644 fe0/src/shared/config/env.test.ts create mode 100644 fe0/src/shared/config/env.ts create mode 100644 fe0/src/shared/config/polling.ts create mode 100644 fe0/src/shared/types/api.types.ts create mode 100644 fe0/src/shared/utils/testConnection.ts create mode 100644 fe0/src/shared/utils/token.ts create mode 100644 fe0/src/test-setup.ts create mode 100644 fe0/src/test/ApplicantFormAutofillButton.tsx create mode 100644 fe0/src/test/applicantFormTestFixtures.ts create mode 100644 fe0/src/test/applicantFormTestFlags.ts create mode 100644 fe0/src/types/applicantPrefill.ts create mode 100644 fe0/src/vite-env.d.ts create mode 100644 fe0/tailwind.config.ts create mode 100644 fe0/test-results/.last-run.json create mode 100644 fe0/tsconfig.app.json create mode 100644 fe0/tsconfig.json create mode 100644 fe0/tsconfig.node.json create mode 100644 fe0/vite create mode 100644 fe0/vite.config.ts create mode 100644 fe0/vite_react_shadcn_ts@0.0.0 create mode 100644 fe0/vitest.config.ts create mode 100644 frontend_admin/Dockerfile create mode 100644 frontend_admin/Dockerfile.prod create mode 100644 frontend_admin/index.html create mode 100644 frontend_admin/nginx/default.conf create mode 100644 frontend_admin/package.json create mode 100644 frontend_admin/postcss.config.js create mode 100644 frontend_admin/public/logo.png create mode 100644 frontend_admin/src/App.tsx create mode 100644 frontend_admin/src/admin/audit/AuditEventDetailSheet.tsx create mode 100644 frontend_admin/src/admin/audit/AuditLogFilters.tsx create mode 100644 frontend_admin/src/admin/audit/AuditLogManagerPage.tsx create mode 100644 frontend_admin/src/admin/audit/AuditLogTable.tsx create mode 100644 frontend_admin/src/applicant/audit/actionLabels.ts create mode 100644 frontend_admin/src/audit/adminAuditApi.ts create mode 100644 frontend_admin/src/audit/formatAuditTime.ts create mode 100644 frontend_admin/src/audit/jsonMicrodiffLines.ts create mode 100644 frontend_admin/src/audit/types.ts create mode 100644 frontend_admin/src/index.css create mode 100644 frontend_admin/src/layouts/AdminLayout.tsx create mode 100644 frontend_admin/src/lib/adminNav.ts create mode 100644 frontend_admin/src/main.tsx create mode 100644 frontend_admin/src/pages/ComingSoonPage.tsx create mode 100644 frontend_admin/src/pages/DashboardPage.tsx create mode 100644 frontend_admin/src/pages/DatasetsPage.tsx create mode 100644 frontend_admin/src/pages/LoginPage.tsx create mode 100644 frontend_admin/src/pages/ResearchReviewPage.tsx create mode 100644 frontend_admin/src/pages/TemplatesPage.tsx create mode 100644 frontend_admin/tailwind.config.ts create mode 100644 frontend_admin/tsconfig.json create mode 100644 frontend_admin/vite.config.ts create mode 100644 frontend_investigator/Dockerfile create mode 100644 frontend_investigator/Dockerfile.prod create mode 100644 frontend_investigator/index.html create mode 100644 frontend_investigator/nginx/default.conf create mode 100644 frontend_investigator/package.json create mode 100644 frontend_investigator/postcss.config.js create mode 100644 frontend_investigator/public/logo.png create mode 100644 frontend_investigator/src/App.tsx create mode 100644 frontend_investigator/src/components/DatasetFileViewerDialog.tsx create mode 100644 frontend_investigator/src/components/SegmentationUploadDialog.tsx create mode 100644 frontend_investigator/src/components/applicant/ApplicantSidebarMenuItem.tsx create mode 100644 frontend_investigator/src/components/applicant/DashboardSidebar.tsx create mode 100644 frontend_investigator/src/components/cockpit/CockpitDetail.tsx create mode 100644 frontend_investigator/src/components/cockpit/CockpitSidebar.tsx create mode 100644 frontend_investigator/src/components/cockpit/CockpitWidgets.tsx create mode 100644 frontend_investigator/src/components/cockpit/DetailPrimitives.tsx create mode 100644 frontend_investigator/src/components/cockpit/TeamManagementView.tsx create mode 100644 frontend_investigator/src/components/cockpit/cockpitConfig.ts create mode 100644 frontend_investigator/src/components/cockpit/detailConfig.test.ts create mode 100644 frontend_investigator/src/components/cockpit/detailConfig.ts create mode 100644 frontend_investigator/src/components/proposal/ProposalFormFields.tsx create mode 100644 frontend_investigator/src/components/proposal/proposalSchema.ts create mode 100644 frontend_investigator/src/components/totalSegmentatorLabels.ts create mode 100644 frontend_investigator/src/components/ui/sidebar.tsx create mode 100644 frontend_investigator/src/features/data-import/application/ports.ts create mode 100644 frontend_investigator/src/features/data-import/application/runDirectUpload.test.ts create mode 100644 frontend_investigator/src/features/data-import/application/runDirectUpload.ts create mode 100644 frontend_investigator/src/features/data-import/domain/bucketPath.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/bucketPath.ts create mode 100644 frontend_investigator/src/features/data-import/domain/cloudImport/reducer.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/cloudImport/reducer.ts create mode 100644 frontend_investigator/src/features/data-import/domain/cloudImport/states.ts create mode 100644 frontend_investigator/src/features/data-import/domain/dataTypes.ts create mode 100644 frontend_investigator/src/features/data-import/domain/directUpload/guards.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/directUpload/guards.ts create mode 100644 frontend_investigator/src/features/data-import/domain/directUpload/reducer.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/directUpload/reducer.ts create mode 100644 frontend_investigator/src/features/data-import/domain/directUpload/states.ts create mode 100644 frontend_investigator/src/features/data-import/domain/formats.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/formats.ts create mode 100644 frontend_investigator/src/features/data-import/domain/index.ts create mode 100644 frontend_investigator/src/features/data-import/domain/itemsList/parse.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/itemsList/parse.ts create mode 100644 frontend_investigator/src/features/data-import/domain/mapping/fileToTask.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/mapping/fileToTask.ts create mode 100644 frontend_investigator/src/features/data-import/domain/mapping/mhdCompanions.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/mapping/mhdCompanions.ts create mode 100644 frontend_investigator/src/features/data-import/domain/mapping/paths.ts create mode 100644 frontend_investigator/src/features/data-import/domain/nnunet/core.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/nnunet/core.ts create mode 100644 frontend_investigator/src/features/data-import/domain/nnunet/fromItemsList.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/nnunet/fromItemsList.ts create mode 100644 frontend_investigator/src/features/data-import/domain/nnunet/wizardState.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/nnunet/wizardState.ts create mode 100644 frontend_investigator/src/features/data-import/domain/samplePath/reducer.test.ts create mode 100644 frontend_investigator/src/features/data-import/domain/samplePath/reducer.ts create mode 100644 frontend_investigator/src/features/data-import/domain/samplePath/states.ts create mode 100644 frontend_investigator/src/features/data-import/domain/types.ts create mode 100644 frontend_investigator/src/features/data-import/infrastructure/httpDirectUploadGateway.ts create mode 100644 frontend_investigator/src/features/data-import/presentation/components/PrivacyNotice.tsx create mode 100644 frontend_investigator/src/features/data-import/presentation/components/UploadDataDialog.tsx create mode 100644 frontend_investigator/src/features/data-import/presentation/hooks/useDirectUpload.ts create mode 100644 frontend_investigator/src/features/data-import/presentation/nnunet/NnunetOrganizerPage.tsx create mode 100644 frontend_investigator/src/features/data-import/presentation/nnunet/OutputPane.tsx create mode 100644 frontend_investigator/src/features/data-import/presentation/nnunet/dataSteps.tsx create mode 100644 frontend_investigator/src/features/data-import/presentation/nnunet/steps.tsx create mode 100644 frontend_investigator/src/features/dataset-workspace/domain/folderTree.test.ts create mode 100644 frontend_investigator/src/features/dataset-workspace/domain/folderTree.ts create mode 100644 frontend_investigator/src/features/dataset-workspace/domain/workspaceModel.test.ts create mode 100644 frontend_investigator/src/features/dataset-workspace/domain/workspaceModel.ts create mode 100644 frontend_investigator/src/features/dataset-workspace/presentation/DatasetWorkspaceView.tsx create mode 100644 frontend_investigator/src/features/dataset-workspace/presentation/FolderTreeView.tsx create mode 100644 frontend_investigator/src/features/dataset-workspace/presentation/FolderUploadButtons.tsx create mode 100644 frontend_investigator/src/features/dataset-workspace/presentation/WorkspacePanel.tsx create mode 100644 frontend_investigator/src/features/project-workflow/domain/taskView.test.ts create mode 100644 frontend_investigator/src/features/project-workflow/domain/taskView.ts create mode 100644 frontend_investigator/src/features/project-workflow/presentation/AnnotationTool.tsx create mode 100644 frontend_investigator/src/features/project-workflow/presentation/DataPage.tsx create mode 100644 frontend_investigator/src/features/project-workflow/presentation/InitialsAvatar.tsx create mode 100644 frontend_investigator/src/features/project-workflow/presentation/ProductivitySidebar.tsx create mode 100644 frontend_investigator/src/features/project-workflow/presentation/TaskRow.tsx create mode 100644 frontend_investigator/src/index.css create mode 100644 frontend_investigator/src/layouts/DashboardLayout.tsx create mode 100644 frontend_investigator/src/main.tsx create mode 100644 frontend_investigator/src/pages/CockpitPage.tsx create mode 100644 frontend_investigator/src/pages/ComingSoonPage.tsx create mode 100644 frontend_investigator/src/pages/DatasetCreatePage.tsx create mode 100644 frontend_investigator/src/pages/DatasetDetailPage.tsx create mode 100644 frontend_investigator/src/pages/DatasetSettingsPage.tsx create mode 100644 frontend_investigator/src/pages/DatasetsListPage.tsx create mode 100644 frontend_investigator/src/pages/ImageSequenceDemoPage.tsx create mode 100644 frontend_investigator/src/pages/LoginPage.tsx create mode 100644 frontend_investigator/src/pages/ProjectsListPage.tsx create mode 100644 frontend_investigator/src/pages/ProposalFormPage.tsx create mode 100644 frontend_investigator/src/pages/VideoViewerDemoPage.tsx create mode 100644 frontend_investigator/tailwind.config.ts create mode 100644 frontend_investigator/tsconfig.json create mode 100644 frontend_investigator/vite.config.ts create mode 100644 frontend_investigator/vitest.config.ts create mode 100644 frontend_publisher/Dockerfile create mode 100644 frontend_publisher/Dockerfile.prod create mode 100644 frontend_publisher/index.html create mode 100644 frontend_publisher/nginx/default.conf create mode 100644 frontend_publisher/package.json create mode 100644 frontend_publisher/postcss.config.js create mode 100644 frontend_publisher/public/fonts/roboto-bold.ttf create mode 100644 frontend_publisher/public/fonts/roboto-regular.ttf create mode 100644 frontend_publisher/public/logo.png create mode 100644 frontend_publisher/src/App.tsx create mode 100644 frontend_publisher/src/components/applicant/ApplicantSidebarMenuItem.tsx create mode 100644 frontend_publisher/src/components/applicant/DashboardSidebar.tsx create mode 100644 frontend_publisher/src/components/ui/sidebar.tsx create mode 100644 frontend_publisher/src/features/papers/api/papersApi.ts create mode 100644 frontend_publisher/src/features/papers/domain/governance.test.ts create mode 100644 frontend_publisher/src/features/papers/domain/governance.ts create mode 100644 frontend_publisher/src/features/papers/domain/paperModel.test.ts create mode 100644 frontend_publisher/src/features/papers/domain/paperModel.ts create mode 100644 frontend_publisher/src/features/papers/presentation/PaperPdfDocument.tsx create mode 100644 frontend_publisher/src/features/papers/presentation/ProvenanceIntegrityPanel.tsx create mode 100644 frontend_publisher/src/features/papers/presentation/PublishGateDialog.tsx create mode 100644 frontend_publisher/src/index.css create mode 100644 frontend_publisher/src/layouts/DashboardLayout.tsx create mode 100644 frontend_publisher/src/main.tsx create mode 100644 frontend_publisher/src/pages/ComingSoonPage.tsx create mode 100644 frontend_publisher/src/pages/GovernancePage.tsx create mode 100644 frontend_publisher/src/pages/LoginPage.tsx create mode 100644 frontend_publisher/src/pages/PaperComposerPage.tsx create mode 100644 frontend_publisher/src/pages/PaperPreviewPage.tsx create mode 100644 frontend_publisher/src/pages/PublishablePapersList.tsx create mode 100644 frontend_publisher/tailwind.config.ts create mode 100644 frontend_publisher/tsconfig.json create mode 100644 frontend_publisher/vite.config.ts create mode 100644 frontend_publisher/vitest.config.ts create mode 100644 frontend_user/Dockerfile create mode 100644 frontend_user/Dockerfile.prod create mode 100644 frontend_user/index.html create mode 100644 frontend_user/nginx/default.conf create mode 100644 frontend_user/package.json create mode 100644 frontend_user/postcss.config.js create mode 100644 frontend_user/public/logo.png create mode 100644 frontend_user/src/App.tsx create mode 100644 frontend_user/src/components/ContributionConfirmationForm.tsx create mode 100644 frontend_user/src/components/InitiativeApplicationForm.tsx create mode 100644 frontend_user/src/components/InitiativeEvaluationForm.tsx create mode 100644 frontend_user/src/components/InitiativeReportForm.tsx create mode 100644 frontend_user/src/components/admin/SelectOptions.ts create mode 100644 frontend_user/src/components/applicant/ApplicantSidebarMenuItem.tsx create mode 100644 frontend_user/src/components/applicant/AuthorArticleCommitmentForm.tsx create mode 100644 frontend_user/src/components/applicant/DashboardSidebar.tsx create mode 100644 frontend_user/src/components/applicant/DomesticJournalHonestyForm.tsx create mode 100644 frontend_user/src/components/applicant/ReferenceMaterialHonestyForm.tsx create mode 100644 frontend_user/src/components/applicant/WorkplaceSelect.tsx create mode 100644 frontend_user/src/components/applicant/dashboard/ApplicantRegistrationDashboard.tsx create mode 100644 frontend_user/src/components/applicant/history/ApplicantHistoryCrudDialog.tsx create mode 100644 frontend_user/src/components/applicant/history/ApplicantHistoryPanel.tsx create mode 100644 frontend_user/src/components/applicant/history/ApplicantHistoryTable.tsx create mode 100644 frontend_user/src/components/applicant/initiative-draft/ApplicationFormDocxPreview.tsx create mode 100644 frontend_user/src/components/applicant/initiative-draft/ReviewPanel.tsx create mode 100644 frontend_user/src/components/applicant/initiative-draft/ReviewSnapshotSections.tsx create mode 100644 frontend_user/src/components/applicant/initiative-draft/pdfExport.ts create mode 100644 frontend_user/src/components/applicant/initiative-workspace/WorkspaceTabPlaceholder.tsx create mode 100644 frontend_user/src/components/applicant/pdfExport.tsx create mode 100644 frontend_user/src/components/applicant/submitInitiativePdf.ts create mode 100644 frontend_user/src/components/application-review/ReviewFormsPanel.tsx create mode 100644 frontend_user/src/components/evidence/EvidenceFileTypeIcon.tsx create mode 100644 frontend_user/src/components/evidence/EvidenceFilesTableCell.tsx create mode 100644 frontend_user/src/components/ui/sidebar.tsx create mode 100644 frontend_user/src/data/mockApplications.ts create mode 100644 frontend_user/src/hooks/useApplicantRegistrationState.ts create mode 100644 frontend_user/src/hooks/useApplicationReviewDetail.ts create mode 100644 frontend_user/src/index.css create mode 100644 frontend_user/src/layouts/DashboardLayout.tsx create mode 100644 frontend_user/src/lib/applicantApplicationsApi.ts create mode 100644 frontend_user/src/lib/applicantSubmissionRecord.ts create mode 100644 frontend_user/src/lib/applicationReviewApi.ts create mode 100644 frontend_user/src/lib/applicationReviewNavigation.ts create mode 100644 frontend_user/src/lib/evidenceFileKind.ts create mode 100644 frontend_user/src/lib/initiativeSubmitWorkflow.ts create mode 100644 frontend_user/src/lib/resolveApplicationAssetUrl.ts create mode 100644 frontend_user/src/lib/resolveApplicationDraftCaseId.ts create mode 100644 frontend_user/src/main.tsx create mode 100644 frontend_user/src/pages/ApplicationReviewPage.tsx create mode 100644 frontend_user/src/pages/ComingSoonPage.tsx create mode 100644 frontend_user/src/pages/DashboardPage.tsx create mode 100644 frontend_user/src/pages/LoginPage.tsx create mode 100644 frontend_user/src/pages/TemplatesFillPage.tsx create mode 100644 frontend_user/tailwind.config.ts create mode 100644 frontend_user/tsconfig.json create mode 100644 frontend_user/vite.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/apply-migration-007-postgres.sh create mode 100644 scripts/build-all.cmd create mode 100644 scripts/build-all.ps1 create mode 100644 scripts/build-word-template.py create mode 100644 scripts/check-prod-stack.sh create mode 100755 scripts/deploy-prod.sh create mode 100644 scripts/deployment/.env.deploy.example create mode 100644 scripts/deployment/00-enable-ssh-ONELINER.txt create mode 100644 scripts/deployment/00-enable-ssh-server.ps1 create mode 100644 scripts/deployment/01-install-prerequisites.ps1 create mode 100644 scripts/deployment/03-setup-iis-sites.ps1 create mode 100644 scripts/deployment/04-install-gitea.ps1 create mode 100644 scripts/deployment/05-install-act-runner.ps1 create mode 100644 scripts/deployment/06-setup-ssl.ps1 create mode 100644 scripts/deployment/ENABLE-SSH-INSTRUCTIONS.md create mode 100644 scripts/deployment/iis-web-configs/api.web.config create mode 100644 scripts/deployment/iis-web-configs/spa.web.config create mode 100644 scripts/deployment/inventory.ps1 create mode 100644 scripts/deployment/sql/01-create-database.sql create mode 100644 scripts/deployment/sql/02-migrate.sql create mode 100644 scripts/deployment/sql/03-add-reports-docs.sql create mode 100644 scripts/health-check.cmd create mode 100644 scripts/health-check.ps1 create mode 100644 scripts/seed-sample-initiatives.py create mode 100755 scripts/setup-gitea-runner.sh create mode 100644 scripts/split-word-template.py create mode 100644 scripts/start-frontends.cmd create mode 100644 scripts/start-frontends.ps1 create mode 100755 scripts/sync-postgres-app-password.sh create mode 100755 scripts/test-verify-prod-env.sh create mode 100755 scripts/verify-prod-env.sh create mode 100644 shared/package.json create mode 100644 shared/src/api/client.ts create mode 100644 shared/src/auth/AuthProvider.tsx create mode 100644 shared/src/auth/ForgotPasswordPage.tsx create mode 100644 shared/src/auth/LoginRegisterCard.tsx create mode 100644 shared/src/auth/RegistrationWithOtp.tsx create mode 100644 shared/src/auth/ResetPasswordPage.tsx create mode 100644 shared/src/auth/authOperations.ts create mode 100644 shared/src/auth/institutionalEmail.ts create mode 100644 shared/src/auth/registration/OtpSixInputs.tsx create mode 100644 shared/src/auth/registration/constants.ts create mode 100644 shared/src/auth/registration/passwordPolicy.ts create mode 100644 shared/src/components/ui/alert-dialog.tsx create mode 100644 shared/src/components/ui/alert.tsx create mode 100644 shared/src/components/ui/badge.tsx create mode 100644 shared/src/components/ui/button.tsx create mode 100644 shared/src/components/ui/calendar.tsx create mode 100644 shared/src/components/ui/card.tsx create mode 100644 shared/src/components/ui/checkbox.tsx create mode 100644 shared/src/components/ui/command.tsx create mode 100644 shared/src/components/ui/dialog.tsx create mode 100644 shared/src/components/ui/dropdown-menu.tsx create mode 100644 shared/src/components/ui/input.tsx create mode 100644 shared/src/components/ui/label.tsx create mode 100644 shared/src/components/ui/popover.tsx create mode 100644 shared/src/components/ui/radio-group.tsx create mode 100644 shared/src/components/ui/resizable.tsx create mode 100644 shared/src/components/ui/scroll-area.tsx create mode 100644 shared/src/components/ui/select.tsx create mode 100644 shared/src/components/ui/separator.tsx create mode 100644 shared/src/components/ui/sheet.tsx create mode 100644 shared/src/components/ui/skeleton.tsx create mode 100644 shared/src/components/ui/sonner.tsx create mode 100644 shared/src/components/ui/switch.tsx create mode 100644 shared/src/components/ui/table.tsx create mode 100644 shared/src/components/ui/tabs.tsx create mode 100644 shared/src/components/ui/textarea.tsx create mode 100644 shared/src/components/ui/tooltip.tsx create mode 100644 shared/src/components/video-viewer/ImageSequenceViewer.tsx create mode 100644 shared/src/components/video-viewer/VideoQuad.tsx create mode 100644 shared/src/components/video-viewer/VideoQuadViewer.tsx create mode 100644 shared/src/components/video-viewer/imageSequenceModel.test.ts create mode 100644 shared/src/components/video-viewer/imageSequenceModel.ts create mode 100644 shared/src/components/video-viewer/index.ts create mode 100644 shared/src/components/video-viewer/playbackModel.test.ts create mode 100644 shared/src/components/video-viewer/playbackModel.ts create mode 100644 shared/src/components/video-viewer/types.ts create mode 100644 shared/src/components/video-viewer/usePlaybackSync.ts create mode 100644 shared/src/components/viewer/AnnotationOverlay.tsx create mode 100644 shared/src/components/viewer/NiftiQuadViewRenderer.tsx create mode 100644 shared/src/components/viewer/QuadViewRenderer.tsx create mode 100644 shared/src/components/viewer/UnifiedQuadViewRenderer.tsx create mode 100644 shared/src/components/viewer/ViewRotationControls.tsx create mode 100644 shared/src/components/viewer/index.ts create mode 100644 shared/src/components/viewer/labelMask.test.ts create mode 100644 shared/src/components/viewer/labelMask.ts create mode 100644 shared/src/components/viewer/niftiLoader.test.ts create mode 100644 shared/src/components/viewer/niftiLoader.ts create mode 100644 shared/src/components/viewer/types.ts create mode 100644 shared/src/components/viewer/useDicomData.ts create mode 100644 shared/src/components/viewer/useNiftiData.ts create mode 100644 shared/src/config/env.ts create mode 100644 shared/src/data/departmentOptions.ts create mode 100644 shared/src/index.ts create mode 100644 shared/src/initiative/ApplicationFormOfficialPdfActions.tsx create mode 100644 shared/src/initiative/DocxToPdfViewer.tsx create mode 100644 shared/src/initiative/DraftContext.tsx create mode 100644 shared/src/initiative/OfficialFormPdfPreviewDialog.tsx create mode 100644 shared/src/initiative/PdfTextLayoutEditorDialog.tsx create mode 100644 shared/src/initiative/applicantDraftSessionUtils.ts create mode 100644 shared/src/initiative/applicantPrefill.ts create mode 100644 shared/src/initiative/applicationAuthorsContributionTable.ts create mode 100644 shared/src/initiative/applicationDrafts.ts create mode 100644 shared/src/initiative/applicationEvidenceApi.ts create mode 100644 shared/src/initiative/applicationFormOfficialPdf.ts create mode 100644 shared/src/initiative/bieuMauTemplate.ts create mode 100644 shared/src/initiative/bieu_mau_sang_kien_template.json create mode 100644 shared/src/initiative/buildDocxTemplateExportBundle.ts create mode 100644 shared/src/initiative/buildInitiativeDraftFromReviewTabs.ts create mode 100644 shared/src/initiative/contributionDraftTypes.ts create mode 100644 shared/src/initiative/contributionRepresentativeAuthorSync.ts create mode 100644 shared/src/initiative/convertDocxToPdfBlob.ts create mode 100644 shared/src/initiative/draftJsonFullShape.ts create mode 100644 shared/src/initiative/draftStorage.ts create mode 100644 shared/src/initiative/draftToTemplateData.ts create mode 100644 shared/src/initiative/initiativeFormTypes.ts create mode 100644 shared/src/initiative/mapBanCamKetToOfficialBieuMau.ts create mode 100644 shared/src/initiative/mapDraftToOfficialBieuMau.ts create mode 100644 shared/src/initiative/mapReferenceMaterialHonestyToOfficialBieuMau.ts create mode 100644 shared/src/initiative/mapResearchDomesticHonestyToOfficialBieuMau.ts create mode 100644 shared/src/initiative/mergeContributionWorkUnits.ts create mode 100644 shared/src/initiative/officialBieuMauToTemplateDataRecord.ts create mode 100644 shared/src/initiative/officialFormPreviewFieldEdits.ts create mode 100644 shared/src/initiative/pdfLayoutEditor.ts create mode 100644 shared/src/initiative/reportApplicationFieldMirror.ts create mode 100644 shared/src/initiative/reportAuthorEvaluationMerge.ts create mode 100644 shared/src/initiative/reportContentSummaryMerge.ts create mode 100644 shared/src/initiative/reviewTabs.ts create mode 100644 shared/src/initiative/serializers.ts create mode 100644 shared/src/initiative/templateTypes.ts create mode 100644 shared/src/initiative/types.ts create mode 100644 shared/src/lib/applicantHonestyPrerequisites.ts create mode 100644 shared/src/lib/applicantRegistrationSession.ts create mode 100644 shared/src/lib/applicationFormDocxApi.ts create mode 100644 shared/src/lib/applicationReviewApi.ts create mode 100644 shared/src/lib/docxJustifyMitigationCss.ts create mode 100644 shared/src/lib/docxTableReflow.ts create mode 100644 shared/src/lib/evidenceUploadLimits.ts create mode 100644 shared/src/lib/imagehubApi.ts create mode 100644 shared/src/lib/imagehubViewer.test.ts create mode 100644 shared/src/lib/imagehubViewer.ts create mode 100644 shared/src/lib/networkTimeout.ts create mode 100644 shared/src/lib/officialFormLayoutApi.ts create mode 100644 shared/src/lib/officialFormPdfFileName.ts create mode 100644 shared/src/lib/permissions.ts create mode 100644 shared/src/lib/researchApi.ts create mode 100644 shared/src/lib/reviewDocumentApi.ts create mode 100644 shared/src/lib/sharedDocxOfficialFormRenderOptions.ts create mode 100644 shared/src/lib/templateApi.ts create mode 100644 shared/src/lib/utils.ts create mode 100644 shared/src/lib/vnDateFormat.ts create mode 100644 shared/src/profile/ProfileVerificationBadge.tsx create mode 100644 shared/src/profile/StaffProfileFormFields.tsx create mode 100644 shared/src/profile/academicTitleOptions.ts create mode 100644 shared/src/profile/index.ts create mode 100644 shared/src/profile/staffProfileValidation.ts create mode 100644 shared/src/profile/types.ts create mode 100644 shared/src/utils/token.ts create mode 100644 shared/tsconfig.json create mode 100644 shared/vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..204678c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# Build context for the frontend_user / frontend_admin images is the repo ROOT (the +# npm workspace). Keep only root manifests + shared/ + the two app dirs; exclude the +# rest so the context stays small. (be0 builds from ./be0 and is unaffected by this file.) +**/node_modules +**/dist +**/dist-ssr +.git +.gitignore +.dockerignore +.claude +docs +be0 +fe0 +assets +database +Posgresdb +scripts +deploy +*.md +.env +.env.* +.DS_Store +**/__pycache__ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..59c04d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,105 @@ +# ============================================================ +# Production / docker-compose.prod.yml +# ----------------------------------------------------------- +# 1. Copy: cp .env.example .env +# 2. Fill every value below (never commit .env — it is gitignored). +# 3. Prefer strong random secrets: +# openssl rand -base64 32 +# +# Before deploy: ./scripts/verify-prod-env.sh +# Full deploy: ./scripts/deploy-prod.sh +# Stack map (FE→BE→DB→MinIO): docs/deploy-stack-overview.md +# Postgres / volume quirks: docs/deploy-production-docker.md +# +# If .env was ever committed to git, rotate ALL secrets below. +# ============================================================ + +# Public hostname or IP that browsers use to reach this machine. +PUBLIC_HOST=your-public-hostname-or-ip.example.com + +FE_PORT=8081 + +# Optional: admin/council SPA port. Bound to 127.0.0.1 only in docker-compose.prod.yml +# (reach it via SSH tunnel or an authenticated reverse-proxy vhost). Defaults to 8082. +# FE_ADMIN_PORT=8082 + +# Optional: principal-investigator SPA port (research proposals + project cockpit). Defaults to 8083. +# FE_INV_PORT=8083 + +# Optional: publisher SPA port (research-result publication). Defaults to 8084. +# FE_PUB_PORT=8084 + +# Optional: extra CORS Allowed-Origins for be0 (comma-separated, no spaces). Production compose sets +# CORS_ORIGINS to http://${PUBLIC_HOST}:${FE_PORT} plus these extras automatically. +# CORS_ORIGINS_EXTRA=https://app.example.com,http://internal:8081 + +MINIO_API_PORT=19000 +MINIO_CONSOLE_PORT=19001 + +MINIO_ROOT_USER=minio_root_change_me +MINIO_ROOT_PASSWORD=replace_with_long_random_secret + +# --- HTTPS for MinIO presigned URLs (required if the SPA is https://…) ------------ +# Mixed content blocks http://PUBLIC_HOST:19000 embedded from an HTTPS UI. Options: +# A) Proxied viewer only (already in-app) — no change needed for preview. +# B) HTTPS for direct MinIO links (iframe / “open presigned URL”) — put TLS in front +# of the S3 API port and align these with that public URL. See docs/minio-behind-https.md . +# Example subdomain (recommended): +# S3_PUBLIC_ENDPOINT_URL=https://minio-api.your-domain.com +# MINIO_SERVER_URL=https://minio-api.your-domain.com +# Optionally point the console at HTTPS too: +# MINIO_BROWSER_REDIRECT_URL=https://minio-console.your-domain.com +# If omitted, Compose keeps using http://${PUBLIC_HOST}:${MINIO_API_PORT} for both. + +# Username + password are fixed the first time the Postgres volume is created (see comment below). + +# Identifier only (letters, digits, underscore) — avoids URL / healthcheck pitfalls. +POSTGRES_USER=postgres_app_user +POSTGRES_PASSWORD=replace_with_long_random_secret + +# Optional: only for scripts/sync-postgres-app-password.sh when the app role is not superuser +# or you must connect as a different DB superuser (e.g. postgres) to run ALTER ROLE. +# POSTGRES_SUPERUSER=postgres + +# Database name created on first init (normally keep "initiatives"). +POSTGRES_DB=initiatives + +# --- Auth (required for production) ------------------------------------------------ +# Generate: openssl rand -base64 48 +JWT_SECRET=replace_with_openssl_rand_base64_48 + +# MinIO browser CORS — your public SPA origin (scheme + host, no trailing slash). +MINIO_API_CORS_ALLOW_ORIGIN=https://www.example.com + +# Postgres + password caveat: +# Changing POSTGRES_USER/POSTGRES_PASSWORD here later does NOT change an existing Docker volume — +# Postgres only reads them when /var/lib/postgresql/data is empty. If login fails after editing .env: +# • Use the same password as first boot (e.g. dev stack used initiative / initiative_secret), or +# • With docker-compose.prod.yml stopped: docker volume rm …_initiative_pg_data then up again (drops DB), or +# • Run ./scripts/sync-postgres-app-password.sh to set the DB role password from this file (no wipe), or + +# --------------------------------------------------------------------------- +# SMTP — outbound mail from be0 (registration OTP, password reset) +# --------------------------------------------------------------------------- +# docker-compose / docker-compose.prod passes these into the be0 container. +# Compose substitutes ${SMTP_*} from THIS file (repo-root `.env`), not from be0/.env alone. +# Omit AUTH_MAIL_LOG_ONLY (or set 0/false) when using real SMTP. +# +# SMTP_HOST=smtp.your-mail-provider.com +# SMTP_PORT=587 +# SMTP_USER=your_smtp_username +# SMTP_PASSWORD=your_smtp_password +# AUTH_MAIL_FROM=noreply@your-institution.edu.vn +# SMTP_USE_TLS=1 +# +# Public URL of the web app (password-reset / verify links in email). Production example: +# AUTH_PUBLIC_WEB_ORIGIN=https://your-app.example.com +# +# Dev-only: print OTP in be0 logs instead of sending mail +# AUTH_MAIL_LOG_ONLY=1 +# +# Microsoft 365 / Outlook (smtp.office365.com), log shows 535 Authentication unsuccessful: +# • SMTP_USER = full mailbox address; SMTP_PASSWORD = correct app password if MFA is enabled +# (not your normal web-login password unless basic auth is allowed — many tenants require app passwords). +# • Exchange admin: enable "Authenticated SMTP" for the mailbox; security defaults may block SMTP AUTH. +# • After editing .env: docker compose up -d be0 (so the container reloads env). diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml new file mode 100644 index 0000000..106641a --- /dev/null +++ b/.gitea/workflows/ci-cd.yml @@ -0,0 +1,99 @@ +name: CI/CD + +# Gitea Actions pipeline for the UMP / ImageHub monorepo. +# backend — be0 (FastAPI, Python 3.11) pytest against a throwaway Postgres +# frontend — npm workspaces (shared + 4 Vite/React SPAs): typecheck, build, unit tests +# deploy — on push to main only: build + `docker compose up -d` on the host runner +# +# Runner labels expected (act_runner registered on 103.149.170.102): +# ci -> docker mode (clean, ephemeral) used by backend + frontend +# deploy -> host mode (drives host docker) used by deploy +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend: + runs-on: ci + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: initiative + POSTGRES_PASSWORD: initiative_secret + POSTGRES_DB: initiatives + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U initiative -d initiatives" + --health-interval 5s --health-timeout 5s --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install backend deps (+ test deps) + working-directory: be0 + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Unit tests — pytest PER FILE (isolates asyncpg event loop per module) + working-directory: be0 + env: + INITIATIVE_DATABASE_URL: postgresql+asyncpg://initiative:initiative_secret@postgres:5432/initiatives + run: | + set -e + fail=0 + for f in tests/test_*.py; do + echo "::group::$f" + python -m pytest "$f" -q || fail=1 + echo "::endgroup::" + done + exit $fail + + frontend: + runs-on: ci + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Install (workspaces) + run: npm ci + - name: Typecheck (all workspaces) + run: npm run typecheck + - name: Build (all workspaces) + run: npm run build + - name: Unit tests (workspaces w/ vitest — shared, investigator, publisher) + run: npm test --workspaces --if-present + + # Deploy runs in HOST mode from a PERSISTENT dir (NOT the ephemeral runner + # workspace): docker-compose.prod.yml bind-mounts ./assets/minio-data and + # ./be0, so MinIO data + submitted files must live on a stable host path or + # they would be wiped on every deploy. + deploy: + needs: [backend, frontend] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: deploy + steps: + - name: Sync code to persistent deploy dir + run: | + set -euo pipefail + DEPLOY_DIR=/srv/sciagent + if [ ! -d "$DEPLOY_DIR/.git" ]; then + git clone http://localhost:3000/tlam89/sciagent.git "$DEPLOY_DIR" + fi + cd "$DEPLOY_DIR" + git fetch origin main + git reset --hard origin/main + - name: Materialize prod .env from secret + run: | + set -euo pipefail + printf '%s' "${{ secrets.PROD_ENV }}" > /srv/sciagent/.env + chmod 600 /srv/sciagent/.env + - name: Deploy stack (build locally, no registry pull) + run: cd /srv/sciagent && bash scripts/deploy-prod.sh --no-pull + - name: Stack health check + run: cd /srv/sciagent && bash scripts/check-prod-stack.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dee745a --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Secrets — commit only `.env.example`, never `.env`. +.env + +.env.local +.env.*.local + +# Keep the example/template +!.env.example + +assets/minio-data/* + +be0/.venv/ + +# HMW-mode marker — session-local toggle (/ultra-on … /ultra-off). Never commit; +# committing it would leave a fresh `git clone` stuck in token-burn mode. +.claude/hmw-mode.on \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b09cd78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +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 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 + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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/Posgresdb/crud_examples.sql b/Posgresdb/crud_examples.sql new file mode 100644 index 0000000..ed75882 --- /dev/null +++ b/Posgresdb/crud_examples.sql @@ -0,0 +1,137 @@ +-- ============================================================================= +-- CRUD PATTERNS — Sáng kiến application system +-- ============================================================================= + +-- ============================================================================= +-- CREATE: Submit a new application with multiple authors (atomic) +-- ============================================================================= +BEGIN; + -- Set audit context + SELECT set_config('my.user_id', '42', true); + + -- 1. Main record + INSERT INTO applications(code, title, registration_year, status, purpose, + is_technical_solution, primary_unit_id, created_by) + VALUES ('SK-2025-007', + 'Hệ thống tự động điền hồ sơ sáng kiến', + 2025, 'DRAFT', + 'Tự động hoá việc điền các mẫu số 01–04', + TRUE, 2, 42) + RETURNING application_id \gset + + -- 2. Authors (defer contribution-sum check until COMMIT) + SET CONSTRAINTS trg_contribution_total DEFERRED; + INSERT INTO application_authors(application_id, user_id, contribution_pct, role, display_order) VALUES + (:application_id, 42, 60.00, 'PRIMARY', 1), + (:application_id, 13, 25.00, 'CO_AUTHOR', 2), + (:application_id, 27, 15.00, 'CO_AUTHOR', 3); + + -- 3. Orgs that tested it + INSERT INTO application_adopters(application_id, org_name, address, field) VALUES + (:application_id, 'Phòng KHCN', '217 Hồng Bàng, Q.5', 'Cải cách hành chính'); +COMMIT; + + +-- ============================================================================= +-- READ: Dashboard — paginated list with filters +-- ============================================================================= +SELECT * FROM v_application_summary + WHERE registration_year = 2025 + AND status = ANY(ARRAY['UNDER_REVIEW','EVALUATED']::text[]) + AND title ILIKE '%động vật%' -- uses trigram index + ORDER BY avg_score DESC NULLS LAST, submitted_at DESC + LIMIT 20 OFFSET 0; + +-- Read: full application with nested data (app layer usually does this as N queries +-- or one JSON aggregate — here's the aggregate version) +SELECT jsonb_build_object( + 'application', to_jsonb(a.*), + 'authors', (SELECT jsonb_agg(jsonb_build_object( + 'user_id', u.user_id, + 'name', u.full_name, + 'pct', aa.contribution_pct, + 'role', aa.role + ) ORDER BY aa.display_order) + FROM application_authors aa + JOIN users u USING (user_id) + WHERE aa.application_id = a.application_id), + 'evaluations',(SELECT jsonb_agg(to_jsonb(e.*)) + FROM evaluations e WHERE e.application_id = a.application_id), + 'attachments',(SELECT jsonb_agg(to_jsonb(att.*)) + FROM attachments att WHERE att.application_id = a.application_id) +) AS document +FROM applications a +WHERE a.application_id = 1 AND a.deleted_at IS NULL; + +-- Full-text search (Vietnamese-friendly; combine with unaccent for better recall) +SELECT application_id, code, title + FROM applications + WHERE to_tsvector('simple', title || ' ' || coalesce(introduction,'')) + @@ plainto_tsquery('simple', 'đạo đức động vật') + ORDER BY registration_year DESC + LIMIT 10; + + +-- ============================================================================= +-- UPDATE: Progress an application through the workflow +-- ============================================================================= +-- Submit (DRAFT → SUBMITTED). Triggers populate submitted_at automatically. +UPDATE applications SET status = 'SUBMITTED' WHERE application_id = 7; + +-- Assign to review panel +UPDATE applications SET status = 'UNDER_REVIEW' WHERE application_id = 7; + +-- Upsert an evaluation (same evaluator re-scores) +INSERT INTO evaluations (application_id, evaluator_id, novelty_score, effectiveness_score, conclusion) +VALUES (7, 99, 32, 48, 'Đề nghị công nhận') +ON CONFLICT (application_id, evaluator_id) +DO UPDATE SET + novelty_score = EXCLUDED.novelty_score, + effectiveness_score = EXCLUDED.effectiveness_score, + conclusion = EXCLUDED.conclusion, + evaluated_at = NOW(); + +-- Update JSONB field: patch a single effectiveness sub-field +UPDATE applications + SET effectiveness = effectiveness || jsonb_build_object( + 'economic', + 'Tiết kiệm ~30% thời gian xét duyệt' + ) + WHERE application_id = 7; + +-- Partial update (PATCH-style) — only update provided fields. The app layer +-- generates SET clauses from the non-null fields in the request body. +UPDATE applications + SET title = COALESCE($1, title), + purpose = COALESCE($2, purpose), + updated_at = NOW() + WHERE application_id = $3 AND deleted_at IS NULL +RETURNING *; + + +-- ============================================================================= +-- DELETE: Soft delete + restore +-- ============================================================================= +-- Soft delete +UPDATE applications SET deleted_at = NOW() WHERE application_id = 7; + +-- Restore +UPDATE applications SET deleted_at = NULL WHERE application_id = 7; + +-- Hard delete (only for drafts, cascades to authors/evaluations/etc.) +DELETE FROM applications + WHERE application_id = 7 + AND status = 'DRAFT'; + + +-- ============================================================================= +-- ANALYTICS: Materialized-view refresh (run nightly via cron/pgAgent) +-- ============================================================================= +REFRESH MATERIALIZED VIEW CONCURRENTLY mv_annual_stats; + +-- Leaderboard: top-scoring approved innovations +SELECT code, title, avg_score + FROM v_application_summary + WHERE status = 'APPROVED' + ORDER BY avg_score DESC + LIMIT 10; diff --git a/Posgresdb/schema.sql b/Posgresdb/schema.sql new file mode 100644 index 0000000..f015125 --- /dev/null +++ b/Posgresdb/schema.sql @@ -0,0 +1,422 @@ +-- ============================================================================= +-- SÁNG KIẾN (INNOVATION APPLICATION) DATABASE SCHEMA +-- PostgreSQL 14+ +-- +-- Domain: Manage innovation applications at ĐHYD TP.HCM (Vietnamese medical +-- university). Supports the full lifecycle: draft → submit → evaluate → approve. +-- +-- Design principles: +-- - 3NF for entities, JSONB for semi-structured/optional narrative +-- - Soft delete (deleted_at) — legal/audit requires historical retention +-- - State machine on applications.status enforced by trigger +-- - Full audit_log via trigger on all CUD operations +-- - Contribution % sums to 100 enforced by DEFERRABLE trigger +-- ============================================================================= + +CREATE EXTENSION IF NOT EXISTS pg_trgm; -- fuzzy matching +CREATE EXTENSION IF NOT EXISTS unaccent; -- Vietnamese diacritics in search + +-- Convenience: updated_at auto-maintenance +CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS TRIGGER AS $$ +BEGIN NEW.updated_at := NOW(); RETURN NEW; END; +$$ LANGUAGE plpgsql; + + +-- ============================================================================= +-- REFERENCE: UNITS (departments, faculties, centers) +-- ============================================================================= +CREATE TABLE units ( + unit_id SERIAL PRIMARY KEY, + code VARCHAR(32) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, -- full Vietnamese name + parent_unit_id INT REFERENCES units(unit_id) ON DELETE SET NULL, + type VARCHAR(32) NOT NULL + CHECK (type IN ('TRUONG','KHOA','PHONG','BO_MON','TRUNG_TAM','KHAC')), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER trg_units_touch BEFORE UPDATE ON units + FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + + +-- ============================================================================= +-- USERS (unified: authors, evaluators, admins — a user can wear many hats) +-- ============================================================================= +CREATE TABLE users ( + user_id SERIAL PRIMARY KEY, + full_name VARCHAR(255) NOT NULL, + title VARCHAR(64), -- PGS.TS, TS., GS., CN., ThS. + date_of_birth DATE, + email VARCHAR(255) UNIQUE, + phone VARCHAR(32), + id_number VARCHAR(32) UNIQUE, -- CCCD / hộ chiếu + unit_id INT REFERENCES units(unit_id) ON DELETE SET NULL, + position VARCHAR(255), -- chức danh: Trưởng phòng, GV cao cấp + qualification VARCHAR(64), -- trình độ: Tiến sĩ, Thạc sĩ, Cử nhân + user_type VARCHAR(32) NOT NULL DEFAULT 'AUTHOR' + CHECK (user_type IN ('AUTHOR','COUNCIL','ADMIN','STUDENT','EXTERNAL')), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + deleted_at TIMESTAMPTZ, -- soft delete + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_users_unit ON users(unit_id); +CREATE INDEX idx_users_active ON users(is_active) WHERE deleted_at IS NULL; +CREATE INDEX idx_users_name_trgm ON users USING GIN (full_name gin_trgm_ops); +CREATE TRIGGER trg_users_touch BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + + +-- ============================================================================= +-- APPLICATIONS (sáng kiến) — the core entity +-- ============================================================================= +CREATE TABLE applications ( + application_id SERIAL PRIMARY KEY, + code VARCHAR(32) UNIQUE NOT NULL, -- e.g., 'SK-2025-001' + title TEXT NOT NULL, + title_en TEXT, + registration_year INT NOT NULL CHECK (registration_year BETWEEN 2000 AND 2100), + field_of_application TEXT, -- lĩnh vực áp dụng + + -- Workflow state (enforced by trigger below) + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' + CHECK (status IN ( + 'DRAFT','SUBMITTED','UNDER_REVIEW', + 'EVALUATED','APPROVED','REJECTED','WITHDRAWN' + )), + + -- Mẫu 01 narrative (long text) + introduction TEXT, -- 1. Mở đầu + current_state TEXT, -- 4.1 Tình trạng đã biết + purpose TEXT, -- Mục đích + implementation_steps TEXT, -- Các bước thực hiện + required_conditions TEXT, -- Điều kiện cần thiết + results_achieved TEXT, -- Kết quả thu được + novelty_description TEXT, -- Tính mới + confidential_info TEXT, -- Thông tin cần bảo mật + + -- 10 effectiveness sub-fields (all optional narrative) → JSONB + effectiveness JSONB NOT NULL DEFAULT '{}'::jsonb, + -- Shape: { "economic":"...", "teaching":"...", "productivity":"...", + -- "work_efficiency":"...", "quality":"...", "cost_reduction":"...", + -- "environment":"...", "health":"...", "safety":"...", "awareness":"..." } + + -- Mẫu 02 fields + owner_org VARCHAR(255), -- chủ đầu tư + first_applied_date DATE, -- ngày áp dụng lần đầu + content_summary TEXT, -- nội dung sáng kiến (short) + author_assessment TEXT, -- đánh giá theo tác giả + org_assessment TEXT, -- đánh giá theo tổ chức + + -- Mẫu 02 classification (mutually exclusive in form, but stored as flags) + is_technical_solution BOOLEAN NOT NULL DEFAULT FALSE, + is_from_research_article BOOLEAN NOT NULL DEFAULT FALSE, + is_from_book_material BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT chk_exactly_one_classification CHECK ( + status = 'DRAFT' OR + (is_technical_solution::int + is_from_research_article::int + is_from_book_material::int) = 1 + ), + + -- Workflow timestamps + submitted_at TIMESTAMPTZ, + decided_at TIMESTAMPTZ, + + primary_unit_id INT REFERENCES units(unit_id), + created_by INT REFERENCES users(user_id), + deleted_at TIMESTAMPTZ, -- soft delete + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_apps_status ON applications(status) WHERE deleted_at IS NULL; +CREATE INDEX idx_apps_year ON applications(registration_year); +CREATE INDEX idx_apps_unit ON applications(primary_unit_id); +CREATE INDEX idx_apps_title_trgm ON applications USING GIN (title gin_trgm_ops); +CREATE INDEX idx_apps_fts ON applications USING GIN ( + to_tsvector('simple', + coalesce(title,'') || ' ' || + coalesce(introduction,'') || ' ' || + coalesce(novelty_description,'') + ) +); +CREATE INDEX idx_apps_effectiveness ON applications USING GIN (effectiveness); +CREATE TRIGGER trg_apps_touch BEFORE UPDATE ON applications + FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + + +-- ============================================================================= +-- APPLICATION_AUTHORS (M:N with contribution %) +-- ============================================================================= +CREATE TABLE application_authors ( + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + user_id INT NOT NULL REFERENCES users(user_id), + contribution_pct NUMERIC(5,2) NOT NULL CHECK (contribution_pct > 0 AND contribution_pct <= 100), + role VARCHAR(32) NOT NULL DEFAULT 'CO_AUTHOR' + CHECK (role IN ('PRIMARY','CO_AUTHOR')), + display_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (application_id, user_id) +); +CREATE INDEX idx_app_authors_user ON application_authors(user_id); + +-- At most one PRIMARY author per application +CREATE UNIQUE INDEX uq_primary_per_app + ON application_authors(application_id) WHERE role = 'PRIMARY'; + +-- Deferrable check: contribution % must total 100 per application +CREATE OR REPLACE FUNCTION check_contribution_total() RETURNS TRIGGER AS $$ +DECLARE v_total NUMERIC; v_app INT; +BEGIN + v_app := COALESCE(NEW.application_id, OLD.application_id); + SELECT COALESCE(SUM(contribution_pct),0) INTO v_total + FROM application_authors WHERE application_id = v_app; + -- Only enforce when application has left DRAFT + IF (SELECT status FROM applications WHERE application_id = v_app) <> 'DRAFT' + AND v_total <> 100 THEN + RAISE EXCEPTION 'Contribution % for application % must sum to 100 (got %)', + '%', v_app, v_total; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER trg_contribution_total + AFTER INSERT OR UPDATE OR DELETE ON application_authors + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION check_contribution_total(); + + +-- ============================================================================= +-- ORGS that tested / adopted the innovation (Mẫu 01 inner table) +-- ============================================================================= +CREATE TABLE application_adopters ( + adopter_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + display_order INT NOT NULL DEFAULT 0, + org_name VARCHAR(255) NOT NULL, + address TEXT, + field TEXT +); +CREATE INDEX idx_adopters_app ON application_adopters(application_id); + + +-- ============================================================================= +-- PARTICIPANTS in first application (Mẫu 02 inner table) +-- ============================================================================= +CREATE TABLE application_participants ( + participant_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + user_id INT REFERENCES users(user_id), -- optional link + display_order INT NOT NULL DEFAULT 0, + full_name VARCHAR(255) NOT NULL, + date_of_birth DATE, + work_unit VARCHAR(255), + position VARCHAR(255), + qualification VARCHAR(64), + support_content TEXT +); +CREATE INDEX idx_participants_app ON application_participants(application_id); + + +-- ============================================================================= +-- EVALUATIONS (Mẫu 04) — council members score applications +-- ============================================================================= +CREATE TABLE evaluations ( + evaluation_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + evaluator_id INT NOT NULL REFERENCES users(user_id), + + novelty_comments TEXT, + novelty_score INT NOT NULL DEFAULT 0 + CHECK (novelty_score BETWEEN 0 AND 40), + + effectiveness_comments TEXT, + effectiveness_score INT NOT NULL DEFAULT 0 + CHECK (effectiveness_score BETWEEN 0 AND 60), + + total_score INT GENERATED ALWAYS AS (novelty_score + effectiveness_score) STORED, + conclusion TEXT, + evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id, evaluator_id) +); +CREATE INDEX idx_eval_app ON evaluations(application_id); +CREATE INDEX idx_eval_evaluator ON evaluations(evaluator_id); + + +-- ============================================================================= +-- COMMITMENTS (Bản cam kết) — for paper-based innovations +-- ============================================================================= +CREATE TABLE commitments ( + commitment_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + user_id INT NOT NULL REFERENCES users(user_id), + + paper_title TEXT, + role_type VARCHAR(32) NOT NULL + CHECK (role_type IN ('PRIMARY_AUTHOR','CO_AUTHOR')), + + -- 5 commitment checkboxes + is_legal_owner BOOLEAN NOT NULL DEFAULT FALSE, + is_authorized_by_owner BOOLEAN NOT NULL DEFAULT FALSE, + has_coauthor_consent BOOLEAN NOT NULL DEFAULT FALSE, + not_predatory_journal BOOLEAN NOT NULL DEFAULT FALSE, + complies_with_ip_law BOOLEAN NOT NULL DEFAULT FALSE, + + signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id, user_id) +); +CREATE INDEX idx_commit_app ON commitments(application_id); + + +-- ============================================================================= +-- ATTACHMENTS (uploaded files — figures, flowcharts, annexes) +-- ============================================================================= +CREATE TABLE attachments ( + attachment_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + file_path TEXT NOT NULL, -- S3/MinIO key + file_size BIGINT, + mime_type VARCHAR(128), + kind VARCHAR(32) -- 'LUU_DO', 'PHU_LUC', 'KY_SO', 'KHAC' + CHECK (kind IS NULL OR kind IN ('LUU_DO','PHU_LUC','KY_SO','KHAC')), + uploaded_by INT REFERENCES users(user_id), + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_attach_app ON attachments(application_id); + + +-- ============================================================================= +-- AUDIT LOG — single table, populated by triggers on all CUD operations +-- ============================================================================= +CREATE TABLE audit_log ( + log_id BIGSERIAL PRIMARY KEY, + table_name VARCHAR(64) NOT NULL, + record_id TEXT NOT NULL, + action VARCHAR(16) NOT NULL CHECK (action IN ('INSERT','UPDATE','DELETE')), + changed_by INT, -- set from app via SET LOCAL my.user_id + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + old_data JSONB, + new_data JSONB +); +CREATE INDEX idx_audit_table_record ON audit_log(table_name, record_id); +CREATE INDEX idx_audit_user_time ON audit_log(changed_by, changed_at DESC); + +-- Generic audit trigger function +CREATE OR REPLACE FUNCTION audit_trigger() RETURNS TRIGGER AS $$ +DECLARE + v_user INT; + v_pk TEXT; +BEGIN + -- Get user_id from session var if app sets it; else NULL + BEGIN v_user := current_setting('my.user_id')::INT; + EXCEPTION WHEN OTHERS THEN v_user := NULL; END; + + v_pk := COALESCE( + (row_to_json(NEW)::jsonb->>TG_ARGV[0]), + (row_to_json(OLD)::jsonb->>TG_ARGV[0]) + ); + + INSERT INTO audit_log(table_name, record_id, action, changed_by, old_data, new_data) + VALUES ( + TG_TABLE_NAME, + v_pk, + TG_OP, + v_user, + CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) END, + CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN to_jsonb(NEW) END + ); + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- Attach audit trigger to the important tables (pass PK column name as arg) +CREATE TRIGGER trg_audit_applications AFTER INSERT OR UPDATE OR DELETE ON applications + FOR EACH ROW EXECUTE FUNCTION audit_trigger('application_id'); +CREATE TRIGGER trg_audit_authors AFTER INSERT OR UPDATE OR DELETE ON application_authors + FOR EACH ROW EXECUTE FUNCTION audit_trigger('application_id'); +CREATE TRIGGER trg_audit_evaluations AFTER INSERT OR UPDATE OR DELETE ON evaluations + FOR EACH ROW EXECUTE FUNCTION audit_trigger('evaluation_id'); +CREATE TRIGGER trg_audit_commitments AFTER INSERT OR UPDATE OR DELETE ON commitments + FOR EACH ROW EXECUTE FUNCTION audit_trigger('commitment_id'); + + +-- ============================================================================= +-- WORKFLOW STATE MACHINE ENFORCEMENT +-- ============================================================================= +CREATE OR REPLACE FUNCTION enforce_application_transitions() RETURNS TRIGGER AS $$ +DECLARE + allowed BOOLEAN := FALSE; +BEGIN + IF OLD.status = NEW.status THEN RETURN NEW; END IF; + + -- Allowed transitions + allowed := CASE + WHEN OLD.status = 'DRAFT' AND NEW.status IN ('SUBMITTED','WITHDRAWN') THEN TRUE + WHEN OLD.status = 'SUBMITTED' AND NEW.status IN ('UNDER_REVIEW','WITHDRAWN','DRAFT') THEN TRUE + WHEN OLD.status = 'UNDER_REVIEW' AND NEW.status IN ('EVALUATED','WITHDRAWN') THEN TRUE + WHEN OLD.status = 'EVALUATED' AND NEW.status IN ('APPROVED','REJECTED') THEN TRUE + ELSE FALSE + END; + + IF NOT allowed THEN + RAISE EXCEPTION 'Invalid status transition: % → %', OLD.status, NEW.status; + END IF; + + -- Auto-set timestamps + IF NEW.status = 'SUBMITTED' AND OLD.status = 'DRAFT' THEN + NEW.submitted_at := NOW(); + END IF; + IF NEW.status IN ('APPROVED','REJECTED') THEN + NEW.decided_at := NOW(); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_app_state_machine + BEFORE UPDATE OF status ON applications + FOR EACH ROW EXECUTE FUNCTION enforce_application_transitions(); + + +-- ============================================================================= +-- CONVENIENCE VIEWS +-- ============================================================================= + +-- Dashboard: applications with author names and current evaluation average +CREATE VIEW v_application_summary AS +SELECT + a.application_id, + a.code, + a.title, + a.status, + a.registration_year, + u.name AS primary_unit_name, + (SELECT string_agg(usr.full_name, ', ' ORDER BY aa.display_order) + FROM application_authors aa + JOIN users usr ON usr.user_id = aa.user_id + WHERE aa.application_id = a.application_id) AS author_names, + (SELECT ROUND(AVG(total_score),2) + FROM evaluations WHERE application_id = a.application_id) AS avg_score, + (SELECT COUNT(*) FROM evaluations WHERE application_id = a.application_id) AS num_evaluations, + a.submitted_at, + a.decided_at +FROM applications a +LEFT JOIN units u ON u.unit_id = a.primary_unit_id +WHERE a.deleted_at IS NULL; + +-- Materialized view: annual approval statistics (refresh nightly) +CREATE MATERIALIZED VIEW mv_annual_stats AS +SELECT + registration_year, + COUNT(*) FILTER (WHERE status = 'APPROVED') AS approved, + COUNT(*) FILTER (WHERE status = 'REJECTED') AS rejected, + COUNT(*) FILTER (WHERE status NOT IN ('APPROVED','REJECTED')) AS pending, + COUNT(*) AS total +FROM applications +WHERE deleted_at IS NULL +GROUP BY registration_year; +CREATE UNIQUE INDEX ON mv_annual_stats(registration_year); diff --git a/Posgresdb/test_schema.sql b/Posgresdb/test_schema.sql new file mode 100644 index 0000000..73d7e1f --- /dev/null +++ b/Posgresdb/test_schema.sql @@ -0,0 +1,83 @@ +-- Validation tests: run in a single transaction per block +-- =========================================================== + +-- 1. SEED: units + users +INSERT INTO units(code, name, type) VALUES + ('DHYD', 'Đại học Y Dược TP.HCM', 'TRUONG'), + ('KHCN', 'Phòng Khoa học Công nghệ', 'PHONG'); + +INSERT INTO users(full_name, title, email, id_number, unit_id, qualification, user_type) VALUES + ('Trần Hùng', 'PGS.TS', 'tranhung@ump.edu.vn', '001001', 1, 'Tiến sĩ', 'AUTHOR'), + ('Đỗ Quốc Vũ', 'CN.', 'doquocvu@ump.edu.vn', '001002', 2, 'Cử nhân', 'AUTHOR'), + ('Nguyễn Hội đồng A', 'PGS.TS', 'hdA@ump.edu.vn', '002001', 1, 'Tiến sĩ', 'COUNCIL'); + +-- 2. CREATE an application in DRAFT state +INSERT INTO applications(code, title, registration_year, status, purpose, primary_unit_id, created_by) +VALUES ('SK-2025-001', + 'Quy trình xét duyệt Đạo đức trong nghiên cứu trên động vật', + 2025, 'DRAFT', + 'Chuẩn hoá quy trình xét duyệt hồ sơ', + 2, 2); + +-- 3. ADD authors with DEFERRED constraint (sums to 100 at COMMIT) +BEGIN; +INSERT INTO application_authors(application_id, user_id, contribution_pct, role) VALUES + (1, 1, 50, 'CO_AUTHOR'), + (1, 2, 50, 'PRIMARY'); +-- At this point sum=100, but app is DRAFT so constraint doesn't even care yet +COMMIT; + +-- Verify +SELECT 'Authors inserted:' AS step, count(*) FROM application_authors; + +-- 4. TRY to submit the application (DRAFT → SUBMITTED): needs classification +-- This should FAIL the check constraint because no classification flag is set +\echo 'Test 4: should FAIL (missing classification)' +UPDATE applications SET status='SUBMITTED' WHERE application_id=1; +\echo '' + +-- Fix and retry +UPDATE applications + SET is_technical_solution = TRUE, + status = 'SUBMITTED' + WHERE application_id = 1; +SELECT 'After submit:' AS step, status, submitted_at FROM applications WHERE application_id=1; + +-- 5. TRY invalid transition SUBMITTED → APPROVED (should FAIL) +\echo 'Test 5: should FAIL (illegal transition)' +UPDATE applications SET status='APPROVED' WHERE application_id=1; +\echo '' + +-- Valid transitions +UPDATE applications SET status='UNDER_REVIEW' WHERE application_id=1; + +-- 6. EVALUATOR scores the application +INSERT INTO evaluations(application_id, evaluator_id, novelty_score, effectiveness_score, conclusion) +VALUES (1, 3, 35, 50, 'Đề xuất công nhận'); + +SELECT 'Evaluation:' AS step, novelty_score, effectiveness_score, total_score FROM evaluations; + +-- 7. Move to EVALUATED → APPROVED +UPDATE applications SET status='EVALUATED' WHERE application_id=1; +UPDATE applications SET status='APPROVED' WHERE application_id=1; + +SELECT 'Final status:' AS step, status, decided_at IS NOT NULL AS has_decision_time + FROM applications WHERE application_id=1; + +-- 8. READ: summary view +SELECT code, title, status, author_names, avg_score, num_evaluations + FROM v_application_summary; + +-- 9. AUDIT trail: who changed what? +SELECT table_name, action, changed_at, + (new_data->>'status') AS new_status + FROM audit_log + WHERE table_name = 'applications' + ORDER BY log_id; + +-- 10. Bad contribution sum should fail at COMMIT +\echo 'Test 10: should FAIL (sum != 100 on submitted app)' +BEGIN; + UPDATE application_authors SET contribution_pct = 30 WHERE application_id=1 AND user_id=1; + -- sum is now 30+50=80, but app is APPROVED so trigger will reject at commit +COMMIT; diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2b992a --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +Initiative Management System + +The platform consists of two main services: + +- **Frontend**: React-based web application with TypeScript and Vite +- **Backend**: FastAPI-based REST API with Python 3.11 +- **AI Integration**: Ollama-powered document analysis and compliance checking + +## Project Structure + +``` +poc/ +├── fe0/ # Frontend service +│ ├── src/ # React application source +│ ├── public/ # Static assets +│ ├── package.json # Node.js dependencies +│ └── Dockerfile # Frontend container +├── be0/ # Backend service +│ ├── src/ # Python application source +│ ├── main.py # FastAPI application entry point +│ ├── requirements.txt # Python dependencies +│ └── Dockerfile # Backend container +├── assets/ # Shared resources and data +└── docker-compose.yml # Service orchestration +``` + +## Prerequisites + +- Docker 20.10+ +- Docker Compose 2.0+ +- Git + +## Quick Start + +1. **Clone and setup** + ```bash + git clone + cd poc + ``` + +2. **Start all services** + ```bash + docker-compose up --build + ``` + +3. **Access the application** + - **Frontend**: http://localhost:8081 + - **Backend API**: http://localhost:4402 + - **API Documentation**: http://localhost:4402/docs + +## Development Setup + +### Frontend Development + +```bash +cd fe0 +npm install +npm run dev +``` + +**Available Scripts:** +- `npm run dev` - Start development server +- `npm run build` - Build for production +- `npm run preview` - Preview production build +- `npm run lint` - Run ESLint + +**Technology Stack:** +- React 18 with TypeScript +- Vite for build tooling +- Tailwind CSS for styling +- shadcn/ui component library +- React Router for navigation +- TanStack Query for state management + +### Backend Development + +```bash +cd be0 +pip install -r requirements.txt +uvicorn main:app --host 0.0.0.0 --port 4402 --reload +``` + +**Technology Stack:** +- FastAPI framework +- Python 3.11 +- Pydantic for data validation +- LangChain for AI workflows +- Ollama for local AI models +- PDF processing with PyPDF and Docling + +## API Documentation + +### Core Endpoints + +#### Workflow Management +- `POST /workflows` - Initialize new compliance workflow +- `GET /workflows/{workflow_id}` - Retrieve workflow status +- `PUT /workflows/{workflow_id}/items` - Update workflow items +- `POST /workflows/{workflow_id}/approvals` - Submit approvals +- `GET /workflows/{workflow_id}/report` - Generate status reports +- `POST /workflows/{workflow_id}/advance` - Progress to next phase + +#### Document Processing +- `POST /upload_document` - Upload and parse documents +- `POST /get_page` - Retrieve specific document pages +- `POST /test_ollama` - Test AI model connectivity + +#### System Health +- `GET /health` - Service health check +- `GET /` - API information and available endpoints + +### Request/Response Examples + +**Create Workflow:** +```json +POST /workflows +{ + "project_name": "ISO 27001 Implementation", + "project_description": "Implement ISO 27001 controls", + "records_officer_email": "officer@company.com" +} +``` + +**Update Workflow Item:** +```json +PUT /workflows/{workflow_id}/items +{ + "item_id": 1, + "status": "completed", + "comment": "Implementation completed", + "updated_by": "john.doe@company.com" +} +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `GENERIC_TIMEZONE`` | Application timezone | `UTC` | +| `NVIDIA_VISIBLE_DEVICES` | GPU access for AI models | `all` | +| `NVIDIA_DRIVER_CAPABILITIES` | GPU capabilities | `compute,utility` | + +### Docker Network Configuration + +Services communicate via a custom Docker network (`profyt-net`) with static IP addressing: +- Frontend: `192.168.42.20` +- Backend: `192.168.42.22` + +## Features + +### Compliance Management +- **ISO 27001** compliance tracking and reporting +- **Records Management** integration workflows +- **Risk Assessment** tools and dashboards +- **Document Processing** with AI-powered analysis + +### Workflow Engine +- Multi-phase compliance workflows +- Approval management system +- Progress tracking and reporting +- Integration with external systems + +### AI-Powered Analysis +- Document parsing and content extraction +- Compliance gap analysis +- Automated report generation +- Natural language processing for policy analysis + +## Deployment + +### Production Deployment + +On the **application host** (SSH), from the repository root: + +1. **Secrets & config** + ```bash + cp .env.example .env + # Edit .env: PUBLIC_HOST, ports, MinIO and Postgres credentials (openssl rand -base64 32). + # Never commit `.env`. Postgres user/password apply only on FIRST empty DB volume — see `.env.example`. + ./scripts/verify-prod-env.sh + ``` + +2. **Deploy (pull, build, recreate containers)** + ```bash + ./scripts/deploy-prod.sh + # Air-gapped / no registry pull: + # ./scripts/deploy-prod.sh --no-pull + ``` + + Or manually (must pass `/.env` explicitly if it is not named `.env` next to the compose file): + ```bash + docker compose --env-file .env -f docker-compose.prod.yml pull + docker compose --env-file .env -f docker-compose.prod.yml up -d --build --remove-orphans + ``` + +3. **Smoke checks** (`FE_PORT` and API port come from `.env` / compose; API is `127.0.0.1:4402` in prod compose) + ```bash + # Replace 8081 with the FE_PORT value in .env when different. + curl -sf http://127.0.0.1:8081/ + curl -sf http://127.0.0.1:4402/health + ``` + +### Scaling Considerations + +- **Frontend**: Stateless, horizontally scalable +- **Backend**: Consider database persistence for production +- **AI Models**: GPU requirements for optimal performance +- **Storage**: Implement proper file storage for documents + +## Monitoring and Logging + +### Application Logs +- Frontend logs: Available via Docker logs +- Backend logs: Stored in `be0/logs/` directory +- System logs: `docker-compose logs [service-name]` + +### Health Monitoring +- Health check endpoints available +- Docker health checks configured +- Log aggregation recommended for production + +## Security Considerations + +### Current Implementation +- CORS enabled for cross-origin requests +- Input validation via Pydantic models +- File upload restrictions + +### Production Recommendations +- Implement authentication/authorization +- Add rate limiting +- Enable HTTPS/TLS +- Implement proper secret management +- Add audit logging + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +### Development Guidelines +- Follow TypeScript best practices +- Write comprehensive tests +- Update documentation for new features +- Follow conventional commit messages + +## License + +This project is licensed under the terms specified in the LICENSE file. diff --git a/be0/.env.example b/be0/.env.example new file mode 100644 index 0000000..d7902a3 --- /dev/null +++ b/be0/.env.example @@ -0,0 +1,29 @@ +# Copy to .env and adjust. docker-compose sets these for the be0 service when using the repo stack. +INITIATIVE_DATABASE_URL=postgresql+asyncpg://initiative:initiative_secret@localhost:15432/initiatives + +# S3 / MinIO — server-to-server (API → object store) +S3_ENDPOINT_URL=http://localhost:19000 +S3_ACCESS_KEY=minio_user +S3_SECRET_KEY=minio_password +S3_BUCKET_ATTACHMENTS=initiative-attachments +S3_BUCKET_EXPORTS=initiative-exports +S3_BUCKET_QUARANTINE=initiative-quarantine + +# Optional: HTTPS base for presigned URLs (must match public MinIO TLS host; see docs/minio-behind-https.md) +# S3_PUBLIC_ENDPOINT_URL=https://minio-api.example.com + +# Optional: comma-separated extra browser origins for CORS (merged with localhost defaults in main.py). +# In Docker dev stack, docker-compose.yml can set this; production compose adds your public UI URL automatically. +# CORS_ORIGINS=http://YOUR_LAN_IP:8081 + +# Local Python runs may load this file; Docker Compose uses the repo-root `.env` for ${SMTP_*} → be0. +# Password reset email (same SMTP block as `.env.example` beside docker-compose for dev stack.) +# OTP + reset use src/auth_mail.py: set SMTP_* for Option A or AUTH_MAIL_LOG_ONLY=1 locally. +# AUTH_MAIL_LOG_ONLY=1 +# AUTH_PUBLIC_WEB_ORIGIN=http://localhost:8081 +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER= +# SMTP_PASSWORD= +# AUTH_MAIL_FROM=noreply@example.com +# SMTP_USE_TLS=1 diff --git a/be0/CHAT_ASSISTANT_README.md b/be0/CHAT_ASSISTANT_README.md new file mode 100644 index 0000000..36280c4 --- /dev/null +++ b/be0/CHAT_ASSISTANT_README.md @@ -0,0 +1,223 @@ +# Chat Assistant Module + +## Overview + +The Chat Assistant module provides a conversational AI interface for answering policy and compliance questions using Ollama. + +## Architecture + +### Backend (`be0/src/chat_assistant.py`) + +The `ChatAssistant` class provides: +- **Chat functionality**: Conversational AI for policy questions +- **Content verification**: Verify content against compliance requirements +- **Policy Q&A**: Answer questions about policies and compliance + +### Frontend (`fe0/src/features/chat/`) + +The frontend chat feature includes: +- **Service layer**: API communication with backend +- **React hooks**: Easy-to-use hooks for chat functionality +- **Type definitions**: TypeScript types for type safety + +## API Endpoints + +### 1. Chat Endpoint +``` +POST /api/v1/chat +``` + +**Request Body:** +```json +{ + "message": "What are ISO 27001 requirements?", + "conversation_history": [ + { + "role": "user", + "content": "Previous message" + }, + { + "role": "assistant", + "content": "Previous response" + } + ], + "context": "Optional context about policies" +} +``` + +**Response:** +```json +{ + "message": "ISO 27001 is an information security management system...", + "model": "gemma3:27b", + "tokens_used": 150 +} +``` + +### 2. Verify Content Endpoint +``` +POST /api/v1/chat/verify +``` + +**Form Data:** +- `field_name`: Name of the field being verified +- `content`: Content to verify +- `verification_criteria`: (Optional) Specific criteria to check + +**Response:** +```json +{ + "message": "The content meets compliance requirements...", + "model": "gemma3:27b", + "tokens_used": 200 +} +``` + +### 3. Policy Question Endpoint +``` +POST /api/v1/chat/question +``` + +**Form Data:** +- `question`: The user's question +- `policy_context`: (Optional) Context about specific policies + +**Response:** +```json +{ + "message": "Answer to the policy question...", + "model": "gemma3:27b", + "tokens_used": 180 +} +``` + +## Features + +### 1. Conversational Context +- Maintains conversation history for context-aware responses +- Keeps last 10 messages for context +- System prompt guides the assistant's behavior + +### 2. Policy Expertise +- Specialized in IT governance and compliance +- Knowledgeable about ISO 27001, NIST, GDPR, etc. +- Provides accurate, actionable advice + +### 3. Content Verification +- Analyzes content against compliance requirements +- Provides detailed feedback +- Suggests improvements + +## Usage + +### Backend + +```python +from src.chat_assistant import get_chat_assistant + +# Get chat assistant instance +assistant = get_chat_assistant() + +# Chat +request = ChatRequest( + message="What is ISO 27001?", + context="IT governance" +) +response = await assistant.chat(request) + +# Verify content +response = await assistant.verify_content( + field_name="Project Description", + content="Our project implements security controls..." +) +``` + +### Frontend + +```typescript +import { useChat } from '@/features/chat/hooks/useChat'; + +const { sendMessage, verifyContent, isLoading } = useChat(); + +// Send a message +const response = await sendMessage( + "What are compliance requirements?", + conversationHistory, // Optional + "ISO 27001 context" // Optional +); + +// Verify content +const verification = await verifyContent( + "Project Name", + "Project content to verify" +); +``` + +## Configuration + +### Model Selection + +The default model is `gemma3:27b`. To change it: + +```python +# In chat_assistant.py +assistant = ChatAssistant(model_name="your-model-name") +``` + +### System Prompt + +The system prompt can be customized in the `ChatAssistant.__init__` method to change the assistant's behavior and expertise. + +## Logging + +All chat interactions are logged to: +- `be0/logs/ChatAssistant.log` + +This helps with debugging and monitoring. + +## Error Handling + +The module includes comprehensive error handling: +- Catches and logs all exceptions +- Returns user-friendly error messages +- Raises HTTPException for API errors + +## Testing + +To test the chat assistant: + +1. **Start the backend:** + ```bash + cd be0 + docker-compose up be0 + ``` + +2. **Test via API:** + ```bash + curl -X POST http://localhost:4402/api/v1/chat \ + -H "Content-Type: application/json" \ + -d '{"message": "What is ISO 27001?"}' + ``` + +3. **Test via Frontend:** + - Open the Dashboard + - Use the ChatAssistant component + - Ask questions or verify content + +## Integration + +The ChatAssistant is integrated with: +- **ChatAssistant.tsx**: React component in the Dashboard +- **useChat hook**: React hook for chat functionality +- **chatService**: API service layer + +## Future Enhancements + +Potential improvements: +1. Streaming responses for real-time text generation +2. Multi-turn conversation management +3. Document context injection +4. Voice input/output +5. Response rating and feedback +6. Conversation export +7. Custom model fine-tuning diff --git a/be0/Dockerfile b/be0/Dockerfile new file mode 100644 index 0000000..752cf2e --- /dev/null +++ b/be0/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.11 + +# Set the working directory +WORKDIR /app + +# Copy the requirements file +COPY ./requirements.txt /app/ + +# Install dependencies and set up Python environment +RUN apt-get update && apt-get install -y --no-install-recommends \ + zstd \ + curl \ + git \ + build-essential \ + python3-pip \ + libreoffice-writer-nogui \ + && rm -rf /var/lib/apt/lists/* + +# RUN curl -fsSL https://ollama.com/install.sh | sh + + +RUN pip install --upgrade pip + +WORKDIR /app + +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install nltk +# Avoid runtime GitHub downloads (slow/hanging in some networks) before Uvicorn starts. +RUN python3 -m nltk.downloader punkt punkt_tab stopwords averaged_perceptron_tagger_eng wordnet + +COPY . /app/ + +EXPOSE 4402 +ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/be0/GOVERNANCE_LAYER_STATUS.md b/be0/GOVERNANCE_LAYER_STATUS.md new file mode 100644 index 0000000..51f0dd8 --- /dev/null +++ b/be0/GOVERNANCE_LAYER_STATUS.md @@ -0,0 +1,172 @@ +# Governance Layer Status in be0 + +## Current State + +### ✅ What EXISTS (Current Implementation) + +The current `be0` codebase has: + +1. **Basic Workflow System** (`src/domain/entities/workflow.py`, `src/application/services/workflow_service.py`) + - SDLC/RM Integration workflow + - Phase-based progression + - Task/checklist management + - **Location**: `be0/src/domain/entities/workflow.py` + +2. **Compliance Verification** (`src/compliance_verifier.py`) + - Ollama-based compliance checking + - Text generation and similarity analysis + - **Location**: `be0/src/compliance_verifier.py` + +3. **Chat Assistant** (`src/chat_assistant.py`) + - Policy Q&A functionality + - Content verification + - **Location**: `be0/src/chat_assistant.py` + +4. **Architecture Foundation** + - Domain/Application/Infrastructure layers + - Repository pattern + - API routes structure + - **Location**: `be0/src/domain/`, `be0/src/application/`, `be0/src/api/` + +--- + +## ❌ What's MISSING (Governance Layer for Initiatives) + +The **Grassroots Initiative Recognition System** governance layer has **NOT been implemented yet**. + +### Missing Components: + +#### 1. **Initiative Management** +- ❌ Initiative entity (initiative_id, group_type, status, etc.) +- ❌ Author management (contribution percentages, lead author logic) +- ❌ Unit/Appraisal Team entities +- **Should be in**: `be0/src/domain/entities/initiative.py` + +#### 2. **Business Rules Engine** +- ❌ Novelty checker (duplicate detection) +- ❌ Scoring algorithm (Group 01 dual/triple reviewer) +- ❌ Auto-classification (Group 02) +- ❌ Author contribution validator +- **Should be in**: `be0/src/domain/rules/` or `be0/src/application/rules/` + +#### 3. **Workflow State Machine** +- ❌ Initiative state transitions (DRAFT → SUBMITTED → UNIT_REVIEW → etc.) +- ❌ Deadline enforcement +- ❌ SLA tracking +- **Should be in**: `be0/src/application/state_machine.py` or `be0/src/domain/workflows/initiative_workflow.py` + +#### 4. **Review Management** +- ❌ Review assignment logic +- ❌ Blind review enforcement +- ❌ Score conflict detection +- ❌ Reviewer assignment service +- **Should be in**: `be0/src/application/services/review_service.py` + +#### 5. **Document Management** +- ❌ Form templates (Form 01, 03, 05, 06) +- ❌ Document versioning +- ❌ File storage integration +- **Should be in**: `be0/src/infrastructure/storage/` + +#### 6. **API Endpoints** +- ❌ `/api/v1/initiatives` (CRUD) +- ❌ `/api/v1/initiatives/{id}/submit` +- ❌ `/api/v1/initiatives/{id}/reviews` +- ❌ `/api/v1/reviews/{review_id}/score` +- ❌ `/api/v1/initiatives/{id}/appeal` +- **Should be in**: `be0/src/api/routes/initiatives.py` + +--- + +## Recommended Structure for Governance Layer + +``` +be0/src/ +├── domain/ +│ ├── entities/ +│ │ ├── initiative.py # ❌ MISSING +│ │ ├── author.py # ❌ MISSING +│ │ ├── review.py # ❌ MISSING +│ │ ├── unit.py # ❌ MISSING +│ │ └── appraisal_team.py # ❌ MISSING +│ ├── rules/ +│ │ ├── novelty_checker.py # ❌ MISSING +│ │ ├── scoring_engine.py # ❌ MISSING +│ │ ├── duplicate_detector.py # ❌ MISSING +│ │ └── classification_engine.py # ❌ MISSING +│ └── workflows/ +│ └── initiative_workflow.py # ❌ MISSING +├── application/ +│ ├── services/ +│ │ ├── initiative_service.py # ❌ MISSING +│ │ ├── review_service.py # ❌ MISSING +│ │ ├── notification_service.py # ❌ MISSING +│ │ └── deadline_service.py # ❌ MISSING +│ └── state_machine.py # ❌ MISSING +├── infrastructure/ +│ ├── storage/ +│ │ └── file_storage.py # ❌ MISSING +│ └── database/ +│ └── models.py # ❌ MISSING (SQLAlchemy models) +└── api/ + └── routes/ + ├── initiatives.py # ❌ MISSING + ├── reviews.py # ❌ MISSING + └── reports.py # ❌ MISSING +``` + +--- + +## What to Build Next + +Based on the simplified tech stack we discussed, here's the implementation order: + +### Phase 1: Core Entities & Database +1. Create database models (PostgreSQL) +2. Create domain entities (Initiative, Author, Review, etc.) +3. Create repository interfaces + +### Phase 2: Business Rules +1. Novelty checker (using PostgreSQL pg_trgm) +2. Scoring engine +3. Auto-classification logic + +### Phase 3: Workflow +1. State machine implementation +2. Transition rules +3. Deadline tracking + +### Phase 4: API & Services +1. Initiative service +2. Review service +3. API endpoints +4. Document upload + +--- + +## Current vs. Required + +| Component | Current | Required | Status | +|-----------|---------|----------|--------| +| Workflow (SDLC) | ✅ | ✅ | Implemented | +| Initiative Management | ❌ | ✅ | **Missing** | +| Business Rules | ❌ | ✅ | **Missing** | +| Review System | ❌ | ✅ | **Missing** | +| State Machine | ❌ | ✅ | **Missing** | +| Document Storage | ❌ | ✅ | **Missing** | +| Scoring Engine | ❌ | ✅ | **Missing** | + +--- + +## Next Steps + +To implement the governance layer: + +1. **Start with database schema** - Create PostgreSQL tables for initiatives, authors, reviews +2. **Create domain entities** - Python classes for Initiative, Author, Review +3. **Implement business rules** - Novelty checker, scoring engine +4. **Build state machine** - Workflow transitions +5. **Create API endpoints** - RESTful APIs for frontend +6. **Add document storage** - Local filesystem integration + +The foundation (layered architecture, FastAPI, PostgreSQL) is already in place - you just need to build the governance-specific components on top of it. diff --git a/be0/TROUBLESHOOTING_CHAT.md b/be0/TROUBLESHOOTING_CHAT.md new file mode 100644 index 0000000..b510dfc --- /dev/null +++ b/be0/TROUBLESHOOTING_CHAT.md @@ -0,0 +1,150 @@ +# Chat Assistant Troubleshooting Guide + +## Common Errors and Solutions + +### Error: 500 Internal Server Error + +This usually indicates one of the following issues: + +#### 1. Ollama Not Running + +**Symptoms:** +- 500 error on `/api/v1/chat` +- Error message mentions "connection" or "refused" + +**Solution:** +```bash +# Check if Ollama is running in the container +docker exec be0 ps aux | grep ollama + +# If not running, restart the container +docker-compose restart be0 + +# Or start Ollama manually +docker exec be0 ollama serve & +``` + +#### 2. Model Not Available + +**Symptoms:** +- Error mentions "model not found" +- Model name mismatch + +**Solution:** +```bash +# Check available models +docker exec be0 ollama list + +# Pull the required model +docker exec be0 ollama pull gemma3:270M + +# Verify model is available +docker exec be0 ollama list | grep gemma3 +``` + +#### 3. Model Name Mismatch + +**Issue:** Code uses `gemma3:27b` but entrypoint pulls `gemma3:270M` + +**Solution:** +The code has been updated to use `gemma3:270M` to match the entrypoint script. + +#### 4. Network Connectivity + +**Symptoms:** +- Connection refused errors +- Timeout errors + +**Solution:** +```bash +# Check if Ollama is accessible from within the container +docker exec be0 curl http://localhost:11434/api/tags + +# Check Ollama service status +docker exec be0 ollama list +``` + +## Diagnostic Endpoints + +### Health Check +```bash +curl http://localhost:4402/health +``` + +This will show: +- Overall service status +- Ollama connection status +- Available models + +### Test Ollama Directly +```bash +# From inside the container +docker exec be0 ollama run gemma3:270M "Hello" +``` + +## Debugging Steps + +1. **Check Backend Logs:** + ```bash + docker-compose logs be0 | tail -50 + ``` + +2. **Check Chat Assistant Logs:** + ```bash + tail -f be0/logs/ChatAssistant.log + ``` + +3. **Test API Endpoint:** + ```bash + curl -X POST http://localhost:4402/api/v1/chat \ + -H "Content-Type: application/json" \ + -d '{"message": "Hello"}' + ``` + +4. **Verify Ollama Service:** + ```bash + docker exec be0 ollama list + docker exec be0 curl http://localhost:11434/api/tags + ``` + +## Common Fixes + +### Fix 1: Restart Ollama Service +```bash +docker exec be0 pkill ollama +docker exec be0 ollama serve & +sleep 2 +docker exec be0 ollama list +``` + +### Fix 2: Pull Missing Model +```bash +docker exec be0 ollama pull gemma3:270M +``` + +### Fix 3: Restart Container +```bash +docker-compose restart be0 +``` + +### Fix 4: Rebuild Container +```bash +docker-compose down +docker-compose build be0 +docker-compose up be0 +``` + +## Expected Behavior + +When working correctly: +1. Health endpoint shows Ollama as "connected" +2. Available models list includes `gemma3:270M` +3. Chat endpoint returns 200 with a response +4. Logs show successful message processing + +## Still Having Issues? + +1. Check the full error in logs: `docker-compose logs be0` +2. Verify Ollama is running: `docker exec be0 ps aux | grep ollama` +3. Test Ollama directly: `docker exec be0 ollama run gemma3:270M "test"` +4. Check model availability: `docker exec be0 ollama list` diff --git a/be0/__init__.py b/be0/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/__pycache__/__init__.cpython-313.pyc b/be0/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e03dbea3738fa1f66503272e6697981128a298f7 GIT binary patch literal 173 zcmey&%ge<80t?(%jU%l4AX$)ZEMp-TXA&+)CX7po)UjL6#ST7$2D#85xV1fh+*PC@Z-D literal 0 HcmV?d00001 diff --git a/be0/__pycache__/main.cpython-311.pyc b/be0/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ad674b6b0ebef753f782dadcf58875a0990f05b9 GIT binary patch literal 205004 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®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 z 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 literal 0 HcmV?d00001 diff --git a/be0/__pycache__/main.cpython-313.pyc b/be0/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..55aeb1b79138e791190e2cc0b61edbd07b12c477 GIT binary patch literal 185886 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%0ARNnk*`Bq4(7VMF%yfa76rZ1Qxxv<_1ncw|0m+!R z&F!`NuRGc|*e-Hra&2u@2#I6OXdE)uYyh7B`pPT~+C{;{&R;Y)85{KSWNis#ce?1B z8b0-Ef~KrMnmWlrxC?_9y4faCxI2#;GJ!6xY@)%Ywe!l9^28>SPXX$;sJls+cJIjq z;>k@x>$>ZX{Kev>9ZR*cywScl$nBL4bk?lXWn>X-kG9AKl6FY8H*&z1wln@^)SYwB0 z#*VZkF4O~wqld#D9;3(9X6hT3eiL@tI0E_mSzk!12-T)8YMJ$%#EEPkwi4~5n{YfH z=?Vrjv!n7!r#M9L6#)y|(bCGZji2aO(jPpGh-jAc6ino3zl3Cbssy}r)3ag225cL-YO9$xaxw5+@PS?sR zU*Q|WWt*=TR7Lm(2#9iRx6`aN`CG{b!mca3U;ndfWSws?RX|1XxvC}SXV)zi1&cPK z_|ozpSc`ss%Y?XJ`1YXAj~m-J>wj%iyIuFS(sna<(P4o1V#aoaUNW2Dzht+y*BdY8 zXSHuMURrCS@Qo%4si&L)2kZms1n+vBSLu&KPZGm3^n{>CMNbW*aZl7thF}UJ5PGYo zom^lOvss1pBr2eeM&h+0>2$-&S{{e)Q?wsBk8ZQWVnQ-`#+D-*YB=rsB$#-{zmvR! zjW`4(jGP1$#B?eU8_Fb;Qt=BI2|uQhPz?z3NXXlGcLzpt3S#F1$|P>X#`xXzDrF-X zk3`vy0lf5(kx9(tWJo>4wgJaMw<0`&J=8ecx+#h(Zb@*~Oo3TJ(KtdoXK_R$58D|9C+W z4G`8&5~f9sScPG+g7Xis*F3=NZsRmUM6KAMv7)%dFOPjIPy2<{0hCPRgMImECvbr( z{R3qA!SFHE<1Sdigf_#GM3@Jlb&>c&i7TW@6tt6?ians6nAAbZEgH>^QK7yKdY^&q zd^EN}Vt}2G_#M~(_i`)yCYb`*))%n7=Mk4@bKREKrsFVj!v>9N0~x_$Um0~PEYPqj zrvpA=n?E5}nr;L%G141_`5m*Z#n}MT(JQ!j4%_=jPWWK=%*H(%9Oh^)L~v~D!iaMW zI|kTIh>WyiuNrnK&?)_NbD*T#Z9_qz&t!osewCbRa()+1f^Gn{1FA?vKzttYuhT&K z5VEs@gd48XU*Xo>GT2RPuH_b=+wrj-k(>%Fvd&li;l{6R3>EAO=I@T=uU_g1=4}A- z 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�p_%{-z*L@=@wSDjebwteZBV8bKtkN_PjSHK_{? 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⋙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,wNycAp|y!? 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/dev/null 2>&1; then + echo "Starting Ollama server..." + ollama serve & + sleep 1 +else + echo "Ollama not installed in this image; skipping." +fi + +# if ! ollama list | grep -q "qwen2.5:3b"; then +# echo "Model qwen2.5:3b not found. Pulling..." +# ollama pull qwen2.5:3b + +# else +# echo "Model qwen2.5:3b already exists. Skipping pull." +# fi + +# #download embedding model +# if ! ollama list | grep -q "embeddinggemma:300m"; then +# echo "Model embeddinggemma:300m not found. Pulling..." +# ollama pull embeddinggemma:300m + +# else +# echo "Model embeddinggemma:300m already exists. Skipping pull." +# fi + +# NLTK corpora are installed when the image is built (see Dockerfile). +# Bind mount overwrites /app; image site-packages may be stale vs mounted requirements.txt. +if [ -f /app/requirements.txt ]; then + echo "Installing/updating Python deps from mounted /app/requirements.txt..." + pip install --no-cache-dir -r /app/requirements.txt || { + echo "ERROR: pip install -r /app/requirements.txt failed; fix deps and restart be0." + exit 1 + } +fi + +echo "Applying idempotent initiative DB migrations (008–014 incl. registration_otp_codes) if needed..." +python /app/scripts/apply_initiative_migrations.py || echo "WARNING: apply_initiative_migrations exited non-zero — check be0 logs (API may return 503 for evidence/artifacts until DB is fixed)." + +echo "Starting FastAPI..." +if [ "${UVICORN_RELOAD:-0}" = "1" ]; then + exec uvicorn main:app --host 0.0.0.0 --port 4402 --reload +else + exec uvicorn main:app --host 0.0.0.0 --port 4402 +fi diff --git a/be0/main.py b/be0/main.py new file mode 100644 index 0000000..22af1b9 --- /dev/null +++ b/be0/main.py @@ -0,0 +1,3726 @@ +from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, Query +from fastapi.responses import JSONResponse, Response, StreamingResponse +from starlette.staticfiles import StaticFiles +from starlette.requests import Request as StarletteRequest +from pydantic import BaseModel, Field +import unicodedata +from typing import Dict, List, Optional, Any, Literal +from datetime import datetime, timezone +from fastapi import File, UploadFile, Form, Header, Body # type: ignore + +from src.auth_jwt import decode_access_token_user_id, decode_bearer_token + +from src.admin_audit_routes import router as admin_audit_router +from src.admin_user_profile_routes import router as admin_user_profile_router +from src.template_routes import router as template_router +from src.research_routes import router as research_router +from src.imagehub_routes import router as imagehub_router + +from fastapi.middleware.cors import CORSMiddleware # type: ignore + +from src.utils import initialize_a_logger +import ollama +import numpy as np +from src.internal_control.it_governance.document_io import DocumentIO +np.random.seed(42) +from pathlib import Path +import uuid +import json +import hashlib +import asyncio +from enum import Enum +from pydantic import BaseModel, Field, validator +import os +import subprocess +import sys +import yaml + +# Import the workflow (assuming it's in rm_workflow.py) +from langgraph.graph import StateGraph, START, END +from typing import TypedDict, Literal +import numpy as np +from src.compliance_verifier import Compliance_Verifier, ComplianceRequest, PromptRequest +from src.chat_assistant import ChatAssistant, ChatRequest, ChatResponse, get_chat_assistant +from src.auth_api import router as auth_api_router + +# Re-define the state and workflow components for FastAPI +class RMIntegrationState(TypedDict): + current_phase: str + phase_number: int + checklist_items: List[dict] + completed_items: List[int] + pending_approvals: List[str] + records_officer_involved: bool + project_status: str + comments: dict + validation_results: dict + next_phase_ready: bool + +# Pydantic models for API requests/responses +class TaskStatus(str, Enum): + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + BLOCKED = "blocked" + +class WorkflowInitRequest(BaseModel): + project_name: str = Field(..., description="Name of the project") + project_description: Optional[str] = Field(None, description="Project description") + records_officer_email: Optional[str] = Field(None, description="Records officer contact") + +class UpdateItemRequest(BaseModel): + item_id: int = Field(..., description="ID of the checklist item to update") + status: TaskStatus = Field(..., description="New status of the item") + comment: Optional[str] = Field(None, description="Comment about the update") + updated_by: Optional[str] = Field(None, description="Who updated the item") + +class ApprovalRequest(BaseModel): + approval_type: str = Field(..., description="Type of approval") + approved: bool = Field(..., description="Whether approved or rejected") + approver: str = Field(..., description="Who provided the approval") + comment: Optional[str] = Field(None, description="Approval comment") + +class WorkflowResponse(BaseModel): + workflow_id: str + current_phase: str + phase_number: int + project_status: str + completion_percentage: float + pending_approvals: List[str] + next_phase_ready: bool + timestamp: str + +class StatusReport(BaseModel): + workflow_id: str + current_phase: str + phase_number: int + completion_percentage: float + completed_items: int + total_items: int + pending_approvals: List[str] + validation_results: Dict[str, str] + project_status: str + checklist_items: List[dict] + timestamp: str + +# Chat Assistant Request Models +class VerifyContentRequest(BaseModel): + """Request model for content verification.""" + field_name: str + content: str + verification_criteria: Optional[str] = None + +class PolicyQuestionRequest(BaseModel): + """Request model for policy questions.""" + question: str + policy_context: Optional[str] = None + +# In-memory storage for workflows (in production, use a database) +workflows_storage: Dict[str, Dict[str, Any]] = {} + + +# try: +# logger = initialize_a_logger() +# logger.info("Logger initialized successfully") # Test it immediately +# except Exception as e: +# import logging +# logging.basicConfig(level=logging.INFO) +# logger = logging.getLogger() +# logger.error(f"Logger initialization failed: {e}", exc_info=True) + +logger = initialize_a_logger('./logs/main.log') + +# FastAPI app initialization +app = FastAPI( + title="RM Integration SDLC Workflow API", + description="API for managing Records Management integration into System Development Life Cycle", + version="1.0.0" +) + +app.include_router(auth_api_router, prefix="/api/v1") +app.include_router(admin_audit_router, prefix="/api/v1") +app.include_router(admin_user_profile_router, prefix="/api/v1") +app.include_router(template_router, prefix="/api/v1") +app.include_router(research_router, prefix="/api/v1") +app.include_router(imagehub_router, prefix="/api/v1") + +APP_ROOT_DIR = Path(__file__).resolve().parent + + +def _resolved_frontend_public_dir() -> Path: + """`assets` at repo root locally, or `/app/assets` when mounted in Docker.""" + env = os.getenv("APPLICATION_REPORT_EXPORT_DIR") + if env: + return Path(env) + app_assets = APP_ROOT_DIR / "assets" + if app_assets.is_dir(): + return app_assets.resolve() + return (APP_ROOT_DIR.parent / "assets").resolve() + + +def _resolved_application_draft_dir() -> Path: + """ + fe0/public/application-drafts when running from repo; `/app/assets/application-drafts` in Docker + (see docker-compose `./assets:/app/assets`). Using APP_ROOT_DIR.parent/fe0 breaks inside Docker + (parent is `/`, producing `/fe0/...`). + """ + env = os.getenv("APPLICATION_DRAFT_DIR") + if env: + return Path(env) + fe0 = APP_ROOT_DIR.parent / "fe0" + if fe0.is_dir(): + return (fe0 / "public" / "application-drafts").resolve() + return (APP_ROOT_DIR / "assets" / "application-drafts").resolve() + + +FRONTEND_PUBLIC_DIR = _resolved_frontend_public_dir() +APPLICATION_DRAFT_DIR = _resolved_application_draft_dir() + + +def _load_application_draft_yaml(case_id: str) -> Optional[Dict[str, Any]]: + """Load draft from disk; try current path and legacy Docker mis-resolved path.""" + name = f"{case_id}.yml" + candidates = [ + APPLICATION_DRAFT_DIR / name, + APP_ROOT_DIR.parent / "fe0" / "public" / "application-drafts" / name, + Path("/fe0/public/application-drafts") / name, + ] + seen: set[str] = set() + for path in candidates: + try: + key = str(path.resolve(strict=False)) + except (OSError, RuntimeError): + key = str(path) + if key in seen: + continue + seen.add(key) + if path.is_file(): + with open(path, "r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + if isinstance(data, dict): + return data + return None + + +def _empty_applicant_draft_bundle(case_id: str) -> Dict[str, Any]: + """ + Client-generated case IDs can exist in sessionStorage before any POST save. + DB reset / no YAML yet must not 404 — return the same shape as save/load. + """ + return { + "caseId": case_id, + "updatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "tabs": {}, + } + + +class ApplicationDraftSaveRequest(BaseModel): + caseId: Optional[str] = None + tab: str + data: Dict[str, Any] + + +class ReviewDocumentSaveRequest(BaseModel): + caseId: str = Field(..., min_length=1, max_length=128) + officialBieuMau: Dict[str, Any] = Field(default_factory=dict) + templateData: Optional[Dict[str, Any]] = None + fullBundle: Optional[Dict[str, Any]] = None + + +class ReviewDocumentUpdateRequest(BaseModel): + officialBieuMau: Dict[str, Any] = Field(default_factory=dict) + templateData: Optional[Dict[str, Any]] = None + fullBundle: Optional[Dict[str, Any]] = None + + +class PdfLayoutEditPayload(BaseModel): + id: str = Field(default="", max_length=128) + text: str = Field(default="", max_length=20_000) + page: int = Field(default=1, ge=1, le=300) + x: float = 0 + y: float = 0 + fontSize: float = Field(default=12, ge=1, le=256) + lineHeight: float = Field(default=14, ge=1, le=512) + boxWidth: float = Field(default=240, ge=1, le=5_000) + letterSpacing: float = Field(default=0, ge=-50, le=200) + textAlign: Literal["left", "center", "right"] = "left" + fontName: Literal["TimesRoman", "TimesRomanBold", "Helvetica", "HelveticaBold"] = "TimesRoman" + colorHex: str = Field(default="#111827", max_length=16) + + +class UpdateSubmittedApplicationBody(BaseModel): + """Applicant history panel: edit name + submission date (mirrors fe0 ApplicantHistoryCrudDialog).""" + + name: str = Field(..., min_length=1, max_length=500) + submittedDate: str = Field(..., min_length=4, max_length=40) + + +class CreateSubmittedApplicationBody(BaseModel): + """Create a new shell record for applicant and immediately allocate `applicationId`.""" + + name: Optional[str] = Field(default=None, max_length=500) + + +def _cors_allow_origins() -> List[str]: + """Localhost defaults plus optional comma-separated `CORS_ORIGINS` (e.g. http://VM_IP:8081).""" + base = [ + "http://localhost:8081", + "http://localhost:8080", + "http://localhost:3000", + "http://127.0.0.1:8081", + "http://127.0.0.1:8080", + "http://127.0.0.1:3000", + ] + extra = os.getenv("CORS_ORIGINS", "").strip() + if not extra: + return base + merged = list(base) + for part in extra.split(","): + o = part.strip() + if o and o not in merged: + merged.append(o) + return merged + + +CORS_ALLOW_ORIGINS = _cors_allow_origins() +if "*" in CORS_ALLOW_ORIGINS: + raise RuntimeError("CORS_ORIGINS must not include '*' when allow_credentials=True") + +# document_parser = DocumentIO() + +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ALLOW_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["*"], + expose_headers=["*"], +) + + +@app.middleware("http") +async def _credential_version_middleware(request: StarletteRequest, call_next): + from src.auth_credential_middleware import auth_credential_version_middleware + + return await auth_credential_version_middleware(request, call_next) + + +@app.middleware("http") +async def _security_headers_middleware(request: StarletteRequest, call_next): + response = await call_next(request) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + if os.getenv("ENVIRONMENT", "").lower() == "production": + response.headers.setdefault( + "Strict-Transport-Security", "max-age=31536000; includeSubDomains" + ) + return response + + +@app.on_event("startup") +async def _initiative_db_startup(): + from src.initiative_db.engine import init_engine, is_postgres_enabled + + if is_postgres_enabled(): + await init_engine() + logger.info("Initiative PostgreSQL persistence enabled") + mig_script = APP_ROOT_DIR / "scripts" / "apply_initiative_migrations.py" + if mig_script.is_file(): + try: + proc = await asyncio.create_subprocess_exec( + sys.executable, + str(mig_script), + cwd=str(APP_ROOT_DIR), + env=os.environ.copy(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, err = await asyncio.wait_for(proc.communicate(), timeout=120) + if out.strip(): + logger.info( + "apply_initiative_migrations: %s", + out.decode("utf-8", errors="replace").strip(), + ) + if err.strip(): + logger.warning( + "apply_initiative_migrations stderr: %s", + err.decode("utf-8", errors="replace").strip(), + ) + if proc.returncode != 0: + logger.warning( + "apply_initiative_migrations exited code %s (e.g. missing registration_otp_codes)", + proc.returncode, + ) + except Exception as exc: + logger.warning("apply_initiative_migrations could not run: %s", exc) + + try: + from src.minio.storage import S3Storage, settings as _s3s + + await S3Storage(_s3s).ensure_buckets_exist() + logger.info("MinIO/S3 buckets ensured (attachments/exports/quarantine).") + except Exception as exc: + logger.warning("MinIO/S3 bucket init skipped (configure S3_* env to enable evidence uploads): %s", exc) + + +@app.on_event("shutdown") +async def _initiative_db_shutdown(): + from src.initiative_db.engine import dispose_engine, is_postgres_enabled + + if is_postgres_enabled(): + await dispose_engine() + + +logger.info(f"parser start") +Compliance_Verifier = Compliance_Verifier() +Chat_Assistant = get_chat_assistant() + +# Import or redefine the workflow functions from the previous artifact +def phase1_concept_development(state: RMIntegrationState) -> RMIntegrationState: + """Phase 1: Concept Development - Initial records planning""" + + phase1_checklist = [ + { + "id": 1, + "task": "Include Records Officer in system design process", + "status": "pending", + "requires_approval": True, + "approver": "Records Officer" + }, + { + "id": 2, + "task": "Identify records that support the business process", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 3, + "task": "Evaluate current record schedules applicability", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 4, + "task": "Determine if new record schedule is required", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 5, + "task": "Obtain Records Officer signature on Investment Summary Proposal", + "status": "pending", + "requires_approval": True, + "approver": "Records Officer" + } + ] + + state["current_phase"] = "Concept Development" + state["phase_number"] = 1 + state["checklist_items"] = phase1_checklist + state["pending_approvals"] = ["Records Officer - System Design", "Records Officer - Investment Summary"] + + return state + +def create_rm_integration_workflow(): + """Simplified workflow creation for FastAPI""" + # This would include all the phase functions from the previous artifact + # For brevity, I'm showing the structure + workflow = StateGraph(RMIntegrationState) + # Add all nodes and edges as in the previous implementation + return workflow + +@app.get("/") +async def root(): + """Root endpoint with API information""" + return { + "message": "RM Integration SDLC Workflow API", + "version": "1.0.0", + "endpoints": { + "POST /workflows": "Create new workflow", + "GET /workflows/{workflow_id}": "Get workflow status", + "PUT /workflows/{workflow_id}/items": "Update checklist item", + "POST /workflows/{workflow_id}/approvals": "Submit approval", + "GET /workflows/{workflow_id}/report": "Get detailed status report", + "POST /workflows/{workflow_id}/advance": "Advance to next phase", + "GET /workflows": "List all workflows" + } + } + +@app.post("/workflows", response_model=WorkflowResponse) +async def create_workflow(request: WorkflowInitRequest): + """Create a new RM integration workflow""" + + workflow_id = str(uuid.uuid4()) + + # Initialize workflow state + initial_state = { + "current_phase": "", + "phase_number": 0, + "checklist_items": [], + "completed_items": [], + "pending_approvals": [], + "records_officer_involved": False, + "project_status": "Starting RM Integration Process", + "comments": {}, + "validation_results": {}, + "next_phase_ready": False + } + + # Start with Phase 1 + state = phase1_concept_development(initial_state) + + # Store workflow + workflows_storage[workflow_id] = { + "state": state, + "metadata": { + "project_name": request.project_name, + "project_description": request.project_description, + "records_officer_email": request.records_officer_email, + "created_at": datetime.now().isoformat(), + "last_updated": datetime.now().isoformat() + } + } + + completed_count = len([item for item in state["checklist_items"] if item["status"] == "completed"]) + total_count = len(state["checklist_items"]) + completion_percentage = (completed_count / total_count * 100) if total_count > 0 else 0 + + return WorkflowResponse( + workflow_id=workflow_id, + current_phase=state["current_phase"], + phase_number=state["phase_number"], + project_status=state["project_status"], + completion_percentage=completion_percentage, + pending_approvals=state["pending_approvals"], + next_phase_ready=state["next_phase_ready"], + timestamp=datetime.now().isoformat() + ) + +@app.get("/workflows/{workflow_id}", response_model=WorkflowResponse) +async def get_workflow_status(workflow_id: str): + """Get current workflow status""" + + if workflow_id not in workflows_storage: + raise HTTPException(status_code=404, detail="Workflow not found") + + state = workflows_storage[workflow_id]["state"] + + completed_count = len([item for item in state["checklist_items"] if item["status"] == "completed"]) + total_count = len(state["checklist_items"]) + completion_percentage = (completed_count / total_count * 100) if total_count > 0 else 0 + + return WorkflowResponse( + workflow_id=workflow_id, + current_phase=state["current_phase"], + phase_number=state["phase_number"], + project_status=state["project_status"], + completion_percentage=completion_percentage, + pending_approvals=state["pending_approvals"], + next_phase_ready=state["next_phase_ready"], + timestamp=datetime.now().isoformat() + ) + +@app.put("/workflows/{workflow_id}/items") +async def update_checklist_item(workflow_id: str, request: UpdateItemRequest): + """Update a checklist item status""" + + if workflow_id not in workflows_storage: + raise HTTPException(status_code=404, detail="Workflow not found") + + state = workflows_storage[workflow_id]["state"] + + # Find and update the item + item_found = False + for item in state["checklist_items"]: + if item["id"] == request.item_id: + item["status"] = request.status.value + if request.status == TaskStatus.COMPLETED and request.item_id not in state["completed_items"]: + state["completed_items"].append(request.item_id) + item_found = True + break + + if not item_found: + raise HTTPException(status_code=404, detail="Checklist item not found") + + # Add comment if provided + if request.comment: + state["comments"][request.item_id] = { + "comment": request.comment, + "updated_by": request.updated_by, + "timestamp": datetime.now().isoformat() + } + + # Update metadata + workflows_storage[workflow_id]["metadata"]["last_updated"] = datetime.now().isoformat() + + return {"message": f"Item {request.item_id} updated successfully", "status": request.status.value} + +@app.post("/workflows/{workflow_id}/approvals") +async def submit_approval(workflow_id: str, request: ApprovalRequest): + """Submit an approval for the workflow""" + + if workflow_id not in workflows_storage: + raise HTTPException(status_code=404, detail="Workflow not found") + + state = workflows_storage[workflow_id]["state"] + + # Remove approval from pending if approved + if request.approved and request.approval_type in state["pending_approvals"]: + state["pending_approvals"].remove(request.approval_type) + + # Log the approval + approval_key = f"approval_{len(state.get('approval_log', []))}" + if "approval_log" not in state: + state["approval_log"] = [] + + state["approval_log"].append({ + "approval_type": request.approval_type, + "approved": request.approved, + "approver": request.approver, + "comment": request.comment, + "timestamp": datetime.now().isoformat() + }) + + # Update metadata + workflows_storage[workflow_id]["metadata"]["last_updated"] = datetime.now().isoformat() + + return { + "message": f"Approval {'granted' if request.approved else 'rejected'} for {request.approval_type}", + "pending_approvals": state["pending_approvals"] + } + +@app.get("/workflows/{workflow_id}/report", response_model=StatusReport) +async def get_detailed_report(workflow_id: str): + """Get detailed status report for a workflow""" + + if workflow_id not in workflows_storage: + raise HTTPException(status_code=404, detail="Workflow not found") + + state = workflows_storage[workflow_id]["state"] + metadata = workflows_storage[workflow_id]["metadata"] + + completed_count = len([item for item in state["checklist_items"] if item["status"] == "completed"]) + total_count = len(state["checklist_items"]) + completion_percentage = (completed_count / total_count * 100) if total_count > 0 else 0 + + return StatusReport( + workflow_id=workflow_id, + current_phase=state["current_phase"], + phase_number=state["phase_number"], + completion_percentage=completion_percentage, + completed_items=completed_count, + total_items=total_count, + pending_approvals=state["pending_approvals"], + validation_results=state["validation_results"], + project_status=state["project_status"], + checklist_items=state["checklist_items"], + timestamp=datetime.now().isoformat() + ) + +@app.post("/workflows/{workflow_id}/advance") +async def advance_workflow(workflow_id: str): + """Attempt to advance workflow to next phase""" + + if workflow_id not in workflows_storage: + raise HTTPException(status_code=404, detail="Workflow not found") + + state = workflows_storage[workflow_id]["state"] + + # Validate current phase completion + all_completed = True + validation_results = {} + + for item in state["checklist_items"]: + if item["status"] != "completed": + all_completed = False + validation_results[str(item["id"])] = f"Item {item['id']} not completed: {item['task']}" + + if state["pending_approvals"]: + all_completed = False + validation_results["approvals"] = f"Pending approvals: {', '.join(state['pending_approvals'])}" + + if not all_completed: + state["validation_results"] = validation_results + state["next_phase_ready"] = False + return { + "success": False, + "message": "Cannot advance: Phase requirements not met", + "validation_results": validation_results + } + + # Advance to next phase + current_phase = state["phase_number"] + if current_phase >= 8: + return { + "success": False, + "message": "Workflow already at final phase", + "current_phase": state["current_phase"] + } + + # Here you would call the appropriate next phase function + # For now, just incrementing phase number as example + state["phase_number"] += 1 + state["current_phase"] = f"Phase {state['phase_number']}" + state["project_status"] = f"Advanced to {state['current_phase']}" + state["validation_results"] = {} + state["next_phase_ready"] = True + + # Update metadata + workflows_storage[workflow_id]["metadata"]["last_updated"] = datetime.now().isoformat() + + return { + "success": True, + "message": f"Advanced to {state['current_phase']}", + "current_phase": state["current_phase"], + "phase_number": state["phase_number"] + } + +@app.get("/workflows") +async def list_workflows(): + """List all workflows with basic information""" + + workflows = [] + for workflow_id, data in workflows_storage.items(): + state = data["state"] + metadata = data["metadata"] + + completed_count = len([item for item in state["checklist_items"] if item["status"] == "completed"]) + total_count = len(state["checklist_items"]) + completion_percentage = (completed_count / total_count * 100) if total_count > 0 else 0 + + workflows.append({ + "workflow_id": workflow_id, + "project_name": metadata["project_name"], + "current_phase": state["current_phase"], + "phase_number": state["phase_number"], + "completion_percentage": completion_percentage, + "created_at": metadata["created_at"], + "last_updated": metadata["last_updated"] + }) + + return {"workflows": workflows, "total_count": len(workflows)} + +@app.delete("/workflows/{workflow_id}") +async def delete_workflow(workflow_id: str): + """Delete a workflow""" + + if workflow_id not in workflows_storage: + raise HTTPException(status_code=404, detail="Workflow not found") + + del workflows_storage[workflow_id] + + return {"message": f"Workflow {workflow_id} deleted successfully"} + +# Health check endpoint +@app.get("/health") +async def health_check(): + """Health check endpoint""" + # Check Ollama connectivity + ollama_status = "unknown" + try: + import ollama + models = ollama.list() + ollama_status = "connected" + available_models = [m.get("name", "") for m in models.get("models", [])] + except Exception as e: + ollama_status = f"error: {str(e)}" + available_models = [] + + return { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "active_workflows": len(workflows_storage), + "ollama": { + "status": ollama_status, + "available_models": available_models + } + } + +# Test endpoint for connectivity +@app.get("/api/v1/test") +async def test_endpoint(): + """Simple test endpoint to verify connectivity""" + return { + "message": "Backend is reachable", + "timestamp": datetime.now().isoformat(), + "status": "ok" + } + +# Error handlers +@app.exception_handler(ValueError) +async def value_error_handler(request, exc): + return JSONResponse( + status_code=400, + content={"detail": str(exc)} + ) + + +@app.post("/test_ollama") +async def test_ollama(req: PromptRequest, authorization: Optional[str] = Header(None)): + _require_admin_user(authorization) + try: + response = ollama.chat( + model="qwen2.5:3b", + messages=[{'role': 'user', 'content': req.prompt}], + options={"temperature": 0.0}, + ) + return {"oss_json": response["message"]["content"]} + except HTTPException as e: + return {"oss_json": str(e)} + + except HTTPException as e: + return {"oss_json": str(e)} + +@app.post("/test_ollama_1") +async def test_ollama_1(req: PromptRequest, authorization: Optional[str] = Header(None)): + _require_admin_user(authorization) + result = await Compliance_Verifier.generate_text(req) + return result + +@app.post("/test_ollama_similarity") +async def vectorize_requirement(req: PromptRequest, authorization: Optional[str] = Header(None)): + _require_admin_user(authorization) + result = await Compliance_Verifier.vectorize_requirement(req) + return result + +@app.post("/analyze_compliance") +async def semantic_similarity( + data: ComplianceRequest, authorization: Optional[str] = Header(None) +) -> Dict[str, Any]: + _require_authenticated_user(authorization) + result = await Compliance_Verifier.semantic_similarity(data) + return result + +@app.post("/analyze_structure") +async def structure_similarity( + data: ComplianceRequest, authorization: Optional[str] = Header(None) +) -> Dict[str, Any]: + _require_authenticated_user(authorization) + result = await Compliance_Verifier.structural_similarity(data) + return result + +# Chat Assistant Endpoints +@app.options("/api/v1/chat") +async def chat_options(request: StarletteRequest): + """Handle CORS preflight for chat endpoint""" + origin = request.headers.get("origin", "http://localhost:8080") + allow_origin = origin if origin in CORS_ALLOW_ORIGINS else "http://localhost:8080" + + return Response( + status_code=200, + headers={ + "Access-Control-Allow-Origin": allow_origin, + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Credentials": "true", + } + ) + +@app.post("/api/v1/chat", response_model=ChatResponse) +async def chat_endpoint(request: ChatRequest, authorization: Optional[str] = Header(None)): + """ + Chat endpoint for conversational AI assistant. + Handles policy questions and general compliance queries. + """ + _require_authenticated_user(authorization) + try: + logger.info(f"Chat endpoint called with message: {request.message[:50] if request.message else 'Empty'}...") + logger.debug(f"Full request: message={request.message}, has_history={bool(request.conversation_history)}, context={request.context}") + + # Validate request + if not request.message or not request.message.strip(): + raise HTTPException(status_code=400, detail="Message cannot be empty") + + response = await Chat_Assistant.chat(request) + logger.info(f"Chat response generated successfully: {response.message[:50] if response.message else 'Empty'}...") + return response + except HTTPException as he: + logger.error(f"HTTPException in chat endpoint: {he.status_code} - {he.detail}") + raise + except Exception as e: + logger.error(f"Unexpected error in chat endpoint: {e}", exc_info=True) + import traceback + logger.error(f"Full traceback: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + +@app.post("/api/v1/chat/verify", response_model=ChatResponse) +async def verify_content_endpoint( + request: VerifyContentRequest, authorization: Optional[str] = Header(None) +): + """ + Verify content against compliance requirements. + """ + _require_authenticated_user(authorization) + try: + response = await Chat_Assistant.verify_content( + field_name=request.field_name, + content=request.content, + verification_criteria=request.verification_criteria + ) + return response + except HTTPException: + raise + except Exception as e: + logger.error(f"Error in verify endpoint: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/v1/chat/question", response_model=ChatResponse) +async def answer_policy_question( + request: PolicyQuestionRequest, authorization: Optional[str] = Header(None) +): + """ + Answer a policy or compliance question. + """ + _require_authenticated_user(authorization) + try: + response = await Chat_Assistant.answer_policy_question( + question=request.question, + policy_context=request.policy_context + ) + return response + except HTTPException: + raise + except Exception as e: + logger.error(f"Error in question endpoint: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +# Idea Management Endpoints +class IdeaRequest(BaseModel): + title: str = Field(..., description="Title of the idea") + description: str = Field(..., description="Description of the idea") + category: Optional[str] = Field(None, description="Category of the idea") + +class IdeaSearchRequest(BaseModel): + query: str = Field(..., description="Search query text") + limit: Optional[int] = Field(5, description="Maximum number of results") + score_threshold: Optional[float] = Field(0.5, description="Minimum similarity score") + +# Initialize Qdrant collection on first API call (lazy initialization) + +@app.post("/api/v1/ideas") +async def add_idea(request: IdeaRequest, authorization: Optional[str] = Header(None)): + """Add a new idea to the vector database""" + _require_admin_user(authorization) + try: + from src.infrastructure.vector_db.qdrant_service import get_qdrant_service + qdrant_service = get_qdrant_service() + # Ensure collection is initialized + await qdrant_service.initialize_collection() + result = await qdrant_service.add_idea( + title=request.title, + description=request.description, + category=request.category + ) + return result + except Exception as e: + logger.error(f"Error adding idea: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/v1/ideas/search") +async def search_ideas(request: IdeaSearchRequest, authorization: Optional[str] = Header(None)): + """Search for similar ideas using vector similarity""" + _require_authenticated_user(authorization) + try: + from src.infrastructure.vector_db.qdrant_service import get_qdrant_service + qdrant_service = get_qdrant_service() + # Ensure collection is initialized + await qdrant_service.initialize_collection() + results = await qdrant_service.search_similar_ideas( + query_text=request.query, + limit=request.limit or 5, + score_threshold=request.score_threshold or 0.5 + ) + return {"results": results, "count": len(results)} + except Exception as e: + logger.error(f"Error searching ideas: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/v1/ideas") +async def get_all_ideas(limit: int = 100, authorization: Optional[str] = Header(None)): + """Get all ideas from the database""" + _require_admin_user(authorization) + try: + from src.infrastructure.vector_db.qdrant_service import get_qdrant_service + qdrant_service = get_qdrant_service() + ideas = await qdrant_service.get_all_ideas(limit=limit) + return {"ideas": ideas, "count": len(ideas)} + except Exception as e: + logger.error(f"Error getting ideas: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@app.delete("/api/v1/ideas/{idea_id}") +async def delete_idea(idea_id: str, authorization: Optional[str] = Header(None)): + """Delete an idea from the database""" + _require_admin_user(authorization) + try: + from src.infrastructure.vector_db.qdrant_service import get_qdrant_service + qdrant_service = get_qdrant_service() + success = await qdrant_service.delete_idea(idea_id) + if success: + return {"message": "Idea deleted successfully", "id": idea_id} + else: + raise HTTPException(status_code=404, detail="Idea not found") + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting idea: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/v1/ideas/bulk-add") +async def bulk_add_ideas(ideas: List[IdeaRequest], authorization: Optional[str] = Header(None)): + """Add multiple ideas at once""" + _require_admin_user(authorization) + if len(ideas) > 50: + raise HTTPException(status_code=422, detail="Tối đa 50 ý tưởng mỗi lần.") + try: + from src.infrastructure.vector_db.qdrant_service import get_qdrant_service + qdrant_service = get_qdrant_service() + # Ensure collection is initialized + await qdrant_service.initialize_collection() + results = [] + for idea in ideas: + result = await qdrant_service.add_idea( + title=idea.title, + description=idea.description, + category=idea.category + ) + results.append(result) + return {"results": results, "count": len(results)} + except Exception as e: + logger.error(f"Error bulk adding ideas: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/v1/ideas/initialize-ump") +async def initialize_ump_ideas(authorization: Optional[str] = Header(None)): + """Initialize database with the 10 UMP innovation ideas""" + _require_admin_user(authorization) + ump_ideas = [ + IdeaRequest( + title="Nền tảng Trợ lý AI học tập lâm sàng (Clinical AI Tutor)", + description="Ứng dụng AI đóng vai trò trợ giảng cho sinh viên y, hỗ trợ phân tích ca bệnh giả lập, giải thích cận lâm sàng, và gợi ý chẩn đoán theo phác đồ Việt Nam.", + category="Giáo dục - AI" + ), + IdeaRequest( + title="Hệ thống bệnh án điện tử học thuật (Academic EMR Sandbox)", + description="Môi trường EMR mô phỏng cho đào tạo và nghiên cứu, cho phép sinh viên và giảng viên thực hành nhập – phân tích – khai thác dữ liệu y khoa mà không ảnh hưởng dữ liệu bệnh nhân thật.", + category="Giáo dục - Chuyển đổi số" + ), + IdeaRequest( + title="Trung tâm mô phỏng y khoa bằng AR/VR & Digital Twin", + description="Xây dựng phòng lab mô phỏng phẫu thuật, cấp cứu, và quy trình điều trị bằng AR/VR, kết hợp mô hình \"digital twin\" của cơ thể người phục vụ đào tạo nâng cao.", + category="Giáo dục - AR/VR" + ), + IdeaRequest( + title="Chương trình Y tế cộng đồng số cho vùng sâu vùng xa", + description="Kết hợp telehealth, trợ lý ảo y tế (agentic care) và AI sàng lọc sớm bệnh không lây (NCD) cho người dân vùng nông thôn, miền núi và hải đảo.", + category="Tác động xã hội - Telehealth" + ), + IdeaRequest( + title="Nền tảng nghiên cứu AI y sinh dùng chung (UMP AI Research Hub)", + description="Cung cấp hạ tầng GPU, kho dữ liệu y khoa ẩn danh, và công cụ phân tích AI cho giảng viên – nghiên cứu sinh – startup hợp tác nghiên cứu.", + category="Nghiên cứu - AI" + ), + IdeaRequest( + title="Hệ thống theo dõi và dự báo sức khỏe sinh viên & nhân viên y tế", + description="Ứng dụng phân tích dữ liệu và AI để phát hiện sớm stress, burnout, và vấn đề sức khỏe tâm thần trong cộng đồng sinh viên và nhân viên y tế.", + category="Tác động xã hội - Sức khỏe" + ), + IdeaRequest( + title="Vườn ươm khởi nghiệp công nghệ y sinh (MedTech Incubator)", + description="Hỗ trợ sinh viên, bác sĩ và giảng viên phát triển startup MedTech, HealthTech, AI y tế thông qua mentoring, quỹ seed và kết nối bệnh viện – doanh nghiệp.", + category="Khởi nghiệp - MedTech" + ), + IdeaRequest( + title="Hệ thống quản lý chất lượng đào tạo và kiểm định số", + description="Số hóa toàn bộ quy trình đảm bảo chất lượng nội bộ (IQA), đánh giá chương trình đào tạo, và chuẩn hóa theo tiêu chuẩn quốc tế (WFME, AUN-QA).", + category="Giáo dục - Quản lý chất lượng" + ), + IdeaRequest( + title="Nền tảng dữ liệu lớn phòng chống dịch và bệnh không lây", + description="Phân tích dữ liệu dịch tễ, môi trường, và hành vi để dự báo dịch bệnh, hỗ trợ Sở Y tế và Bộ Y tế trong ra quyết định chính sách.", + category="Nghiên cứu - Dịch tễ học" + ), + IdeaRequest( + title="Học viện Y học chính xác & Y học cá thể hóa", + description="Kết hợp dữ liệu gen, hình ảnh y khoa, lối sống và AI để nghiên cứu và ứng dụng điều trị cá thể hóa cho bệnh ung thư, tim mạch và bệnh mạn tính.", + category="Nghiên cứu - Y học chính xác" + ), + ] + + try: + from src.infrastructure.vector_db.qdrant_service import get_qdrant_service + qdrant_service = get_qdrant_service() + # Ensure collection is initialized + await qdrant_service.initialize_collection() + results = [] + for idea in ump_ideas: + result = await qdrant_service.add_idea( + title=idea.title, + description=idea.description, + category=idea.category + ) + results.append(result) + return {"results": results, "count": len(results), "message": f"Successfully added {len(results)} UMP ideas"} + except Exception as e: + logger.error(f"Error initializing UMP ideas: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/v1/application-reports/excel") +async def save_application_report_excel(caseId: str = Form(...), file: UploadFile = File(...)): + """ + Save uploaded application Excel file to shared assets folder so admin can review it. + """ + if not caseId.strip(): + raise HTTPException(status_code=400, detail="caseId is required") + + FRONTEND_PUBLIC_DIR.mkdir(parents=True, exist_ok=True) + safe_case_id = "".join(ch for ch in caseId if ch.isalnum() or ch in ("-", "_")) + if not safe_case_id: + raise HTTPException(status_code=400, detail="Invalid caseId") + + target_name = f"{safe_case_id}.xlsx" + target_path = FRONTEND_PUBLIC_DIR / target_name + content = await file.read() + with open(target_path, "wb") as output: + output.write(content) + + return { + "caseId": safe_case_id, + "fileName": target_name, + "savedPath": str(target_path), + "publicUrl": f"/assets/{target_name}", + } + + +@app.get("/api/v1/application-reports") +async def list_application_reports(): + """ + List saved report files from shared assets folder. + """ + FRONTEND_PUBLIC_DIR.mkdir(parents=True, exist_ok=True) + files = [] + for path in sorted(FRONTEND_PUBLIC_DIR.glob("*.xlsx"), key=lambda p: p.stat().st_mtime, reverse=True): + stat = path.stat() + files.append( + { + "fileName": path.name, + "publicUrl": f"/assets/{path.name}", + "sizeBytes": stat.st_size, + "updatedAt": datetime.fromtimestamp(stat.st_mtime).isoformat(), + } + ) + return {"files": files} + + +def _normalize_case_id(case_id: Optional[str]) -> str: + raw = case_id or f"CASE-{int(datetime.now().timestamp() * 1000)}" + safe = "".join(ch for ch in raw if ch.isalnum() or ch in ("-", "_")) + if not safe: + raise HTTPException(status_code=400, detail="Invalid caseId") + return safe + + +def _draft_path(case_id: str) -> Path: + APPLICATION_DRAFT_DIR.mkdir(parents=True, exist_ok=True) + return APPLICATION_DRAFT_DIR / f"{case_id}.yml" + + +@app.post("/api/v1/application-drafts") +async def save_application_draft( + request: ApplicationDraftSaveRequest, + authorization: Optional[str] = Header(None), +): + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.drafts import save_application_draft_tab + + case_id = _normalize_case_id(request.caseId) + owner_uid = decode_access_token_user_id(authorization) + + if is_postgres_enabled(): + try: + async with get_session() as session: + return await save_application_draft_tab( + session, case_id, request.tab, request.data, owner_id=owner_uid + ) + except HTTPException: + raise + except Exception as e: + logger.exception("application draft save (PostgreSQL) failed") + raise HTTPException(status_code=500, detail="Failed to persist draft") from e + + target = _draft_path(case_id) + + current: Dict[str, Any] = { + "caseId": case_id, + "updatedAt": datetime.now().isoformat(), + "tabs": {}, + } + if target.exists(): + with open(target, "r", encoding="utf-8") as handle: + loaded = yaml.safe_load(handle) or {} + if isinstance(loaded, dict): + current.update(loaded) + current["tabs"] = dict(loaded.get("tabs") or {}) + + current["caseId"] = case_id + current["updatedAt"] = datetime.now().isoformat() + current["tabs"][request.tab] = request.data + + with open(target, "w", encoding="utf-8") as handle: + yaml.safe_dump(current, handle, allow_unicode=True, sort_keys=False) + + return { + "caseId": case_id, + "updatedAt": current["updatedAt"], + "tabs": current["tabs"], + "publicUrl": f"/application-drafts/{case_id}.yml", + } + + +@app.get("/api/v1/application-drafts/{case_id}") +async def get_application_draft(case_id: str): + from sqlalchemy.exc import IntegrityError + + from src.initiative_db.drafts import ( + get_application_draft_document, + insert_initiative_draft_if_missing, + ) + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.submissions import merge_application_draft_document_with_snapshot_if_needed + + safe_case_id = _normalize_case_id(case_id) + if is_postgres_enabled(): + try: + async with get_session() as session: + from src.initiative_db.submissions import resolve_initiative_for_draft_case_key + + ini_res = await resolve_initiative_for_draft_case_key(session, case_id) + if ini_res is not None: + safe_case_id = ini_res.case_code + except Exception: + logger.exception("resolve initiative for application draft GET failed; using path case id") + yaml_fallback = _load_application_draft_yaml(safe_case_id) + + if is_postgres_enabled(): + try: + async with get_session() as session: + try: + doc = await get_application_draft_document(session, safe_case_id) + return await merge_application_draft_document_with_snapshot_if_needed(session, safe_case_id, doc) + except KeyError: + pass + if yaml_fallback is None: + return _empty_applicant_draft_bundle(safe_case_id) + try: + await insert_initiative_draft_if_missing(session, safe_case_id, yaml_fallback) + except IntegrityError: + await session.rollback() + async with get_session() as session: + try: + doc = await get_application_draft_document(session, safe_case_id) + return await merge_application_draft_document_with_snapshot_if_needed(session, safe_case_id, doc) + except KeyError: + return yaml_fallback + except HTTPException: + raise + except Exception as e: + logger.exception("application draft load (PostgreSQL) failed") + raise HTTPException(status_code=500, detail="Failed to load draft") from e + + if yaml_fallback is not None: + return yaml_fallback + return _empty_applicant_draft_bundle(safe_case_id) + + +def _evidence_kind_to_role(kind: object) -> Optional[str]: + """ + Map API kind query/form value → storage role. + + Accepts str or list (duplicate ``kind=`` keys); strips BOM / ZWSP; NFKC-normalizes Unicode + so lookalike Latin cannot bypass the allow-list. + """ + if kind is None: + return None + if isinstance(kind, (list, tuple)): + candidates = [str(x) for x in kind if x is not None and str(x).strip() != ""] + else: + candidates = [str(kind)] + + for c in candidates: + k = unicodedata.normalize("NFKC", (c or "").strip()) + k = k.replace("\ufeff", "").replace("\u200b", "").strip().lower() + if k == "research": + return "research_evidence" + if k == "textbook": + return "textbook_evidence" + if k == "technical": + return "technical_evidence" + return None + + +def _evidence_role_to_api_kind(role: str) -> str: + if role == "research_evidence": + return "research" + if role == "textbook_evidence": + return "textbook" + if role == "technical_evidence": + return "technical" + return "research" + + +def _evidence_row_looks_like_pdf(row, default_name: str) -> bool: + """True when the stored artifact should be shown with an inline PDF presign.""" + mt = (row.mime_type or "").lower() + if "pdf" in mt: + return True + dn = (default_name or "").lower() + return dn.endswith(".pdf") + + +def _jwt_role_strings(authorization: Optional[str]) -> list[str]: + p = decode_bearer_token(authorization) + if not p: + return [] + r = p.get("roles") + if isinstance(r, list): + return [str(x) for x in r] + return [] + + +def _is_staff_reviewer(authorization: Optional[str]) -> bool: + roles = _jwt_role_strings(authorization) + return "admin" in roles or "editor" in roles + + +def _require_admin_user(authorization: Optional[str]) -> uuid.UUID: + """JWT must be valid and include role ``admin``.""" + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.") + roles = _jwt_role_strings(authorization) + if "admin" not in roles: + raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.") + return uid + + +def _require_authenticated_user(authorization: Optional[str]) -> uuid.UUID: + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.") + return uid + + +def _require_staff_reviewer(authorization: Optional[str]) -> uuid.UUID: + uid = _require_authenticated_user(authorization) + if not _is_staff_reviewer(authorization): + raise HTTPException(status_code=403, detail="Không có quyền truy cập.") + return uid + + +async def _assert_initiative_case_access( + session: Any, + case_id: str, + uid: uuid.UUID, + authorization: Optional[str], +) -> None: + """Allow staff or initiative owner for the normalized case code.""" + from sqlalchemy import select + + from src.initiative_db.models import Initiative + + normalized = _normalize_case_id(case_id) + ini = (await session.execute(select(Initiative).where(Initiative.case_code == normalized))).scalar_one_or_none() + if ini is None: + return + if ini.owner_id == uid or _is_staff_reviewer(authorization): + return + raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") + + +async def _assert_review_document_access( + session: Any, + review_document_id: str, + uid: uuid.UUID, + authorization: Optional[str], +) -> None: + from src.initiative_db.models import ApplicationReviewDocument, Initiative + + try: + rid = uuid.UUID(str(review_document_id)) + except ValueError as exc: + raise HTTPException(status_code=404, detail="Không tìm thấy review document") from exc + doc = await session.get(ApplicationReviewDocument, rid) + if doc is None: + raise HTTPException(status_code=404, detail="Không tìm thấy review document") + ini = await session.get(Initiative, doc.initiative_id) + if ini is not None and ini.owner_id != uid and not _is_staff_reviewer(authorization): + raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") + + +class AdminApplicationResultBody(BaseModel): + decision: Literal["approved", "rejected"] + feedback: str = Field(default="", max_length=50_000) + rationale: Optional[str] = Field(default=None, max_length=50_000) + + +def _initiative_allows_owner_evidence_edit(status: str) -> bool: + s = (status or "").strip().lower() + return s not in ("approved", "rejected") + + +def _normalize_pdf_layout_edits(raw: Any) -> list[Dict[str, Any]]: + if not isinstance(raw, list): + raise HTTPException(status_code=422, detail="layoutEdits phải là mảng.") + if len(raw) > 200: + raise HTTPException(status_code=422, detail="Tối đa 200 mục chỉnh bố cục.") + out: list[Dict[str, Any]] = [] + for idx, item in enumerate(raw): + if not isinstance(item, dict): + raise HTTPException(status_code=422, detail=f"layoutEdits[{idx}] không hợp lệ.") + try: + parsed = PdfLayoutEditPayload(**item) + except Exception as exc: + raise HTTPException(status_code=422, detail=f"layoutEdits[{idx}] lỗi định dạng: {exc}") from exc + row = parsed.model_dump() + if not str(row.get("text") or "").strip(): + continue + out.append(row) + return out + + +def _load_minio_for_evidence(): + """Returns (storage, bucket_name, err_msg). On failure, storage is None.""" + try: + from src.minio.storage import S3Storage, settings as s3settings + + return S3Storage(), s3settings.s3_bucket_attachments, None + except Exception as exc: + logger.warning("MinIO / S3 not available for evidence upload: %s", exc) + return None, None, str(exc) + + +def _load_minio_for_exports(): + """Returns (storage, bucket_name, err_msg). On failure, storage is None.""" + try: + from src.minio.storage import S3Storage, settings as s3settings + + return S3Storage(), s3settings.s3_bucket_exports, None + except Exception as exc: + logger.warning("MinIO / S3 not available for export upload: %s", exc) + return None, None, str(exc) + + +@app.post("/api/v1/application-drafts/{case_id}/evidence") +async def upload_application_draft_evidence( + case_id: str, + kind: str = Form(..., description="research | textbook | technical (Minh chứng 2.1 / 2.2 / kỹ thuật)"), + file: UploadFile = File(...), + authorization: Optional[str] = Header(None), +): + """ + Tải minh chứng (PDF, hình, Word, Excel, …) lên MinIO; chủ sở hữu, trừ hồ sơ đã approved/rejected. + """ + from src.initiative_db.application_storage import ( + get_evidence_artifact_row, + upsert_evidence_artifact, + ) + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.minio.storage import ALLOWED_MIME_TYPES, StorageError + + role = _evidence_kind_to_role(kind) + if role is None: + raise HTTPException(status_code=400, detail="kind phải là research, textbook hoặc technical") + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để tải minh chứng.") + + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL để lưu minh chứng trên máy chủ.") + + s3, bucket, _cfg_err = _load_minio_for_evidence() + if s3 is None or bucket is None: + raise HTTPException( + status_code=503, + detail="Lưu tệp MinIO chưa cấu hình. Đặt biến môi trường S3/MinIO hoặc xem tài liệu triển khai.", + ) + + import mimetypes + + filename_l = (file.filename or "").lower() + guessed, _ = mimetypes.guess_type(filename_l) + content_type = (file.content_type or "").split(";")[0].strip() or "application/octet-stream" + if content_type not in ALLOWED_MIME_TYPES and guessed in ALLOWED_MIME_TYPES: + content_type = guessed + if content_type not in ALLOWED_MIME_TYPES: + raise HTTPException(status_code=422, detail=f"Loại tệp không được phép: {content_type}") + + _max_evidence_bytes = 50 * 1024 * 1024 + if file.size is not None and int(file.size) > _max_evidence_bytes: + raise HTTPException( + status_code=413, + detail="Tệp vượt quá 50 MB. Hãy nén hoặc chia nhỏ tệp trước khi tải lên.", + ) + + async with get_session() as session: + from src.initiative_db.submissions import resolve_initiative_for_draft_case_key + + ini = await resolve_initiative_for_draft_case_key(session, case_id) + if ini is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ (hãy lưu bản nháp trước).") + canonical_case = ini.case_code + if ini.owner_id != uid: + raise HTTPException(status_code=403, detail="Chỉ chủ sở hữu mới tải được minh chứng.") + st = str(ini.status or "") + if not _initiative_allows_owner_evidence_edit(st): + raise HTTPException( + status_code=422, + detail="Hồ sơ đã kết thúc duyệt — không cập nhật minh chứng.", + ) + + old_row = await get_evidence_artifact_row(session, initiative_id=ini.id, role=role) + prior_storage_key = ( + (old_row.storage_uri or "").strip() or None if old_row is not None else None + ) + + object_key = s3.build_key_for_initiative(ini.id, file.filename or "evidence.pdf") + try: + result = await s3.upload( + bucket=bucket, + key=object_key, + fileobj=file.file, + mime_type=content_type, + metadata={"uploaded_by": str(uid), "case_code": canonical_case, "role": role}, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except StorageError as exc: + raise HTTPException(status_code=502, detail=f"Không tải được lên kho: {exc}") from exc + + await upsert_evidence_artifact( + session, + initiative_id=ini.id, + role=role, + storage_uri=object_key, + original_name=file.filename, + byte_size=result.get("size"), + sha256_hex=result.get("sha256"), + uploaded_by=uid, + mime_type=content_type, + ) + + from src.auth_jwt import decode_bearer_token + from src.audit import AuditAction, jwt_payload_actor_email, record_audit + + ae, ar = jwt_payload_actor_email(decode_bearer_token(authorization)) + ev_action = AuditAction.update if old_row is not None else AuditAction.create + await record_audit( + session, + actor_user_id=uid, + actor_email=ae, + actor_role=ar, + action=ev_action, + entity_type="application_evidence", + entity_id=f"{canonical_case}:{role}", + before=( + {"storageKey": prior_storage_key} + if prior_storage_key + else None + ), + after={ + "storageKey": object_key, + "originalName": file.filename, + "mimeType": content_type, + }, + metadata={"minioBucket": bucket, "caseId": canonical_case, "evidenceRole": role}, + ) + await session.commit() + + if prior_storage_key and prior_storage_key != object_key: + try: + await s3.delete(bucket, prior_storage_key) + except StorageError as exc: + logger.warning( + "MinIO delete of replaced evidence object failed (DB already points to new key): %s", + exc, + ) + + k_label = _evidence_role_to_api_kind(role) + return { + "ok": True, + "caseId": canonical_case, + "kind": k_label, + "storageKey": object_key, + "originalName": file.filename, + "byteSize": result.get("size"), + "uploadedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + + +@app.get("/api/v1/application-drafts/{case_id}/evidence") +async def get_application_draft_evidence( + case_id: str, + authorization: Optional[str] = Header(None), +): + """Metadata (và link tải có thời hạn) cho minh chứng 2.1 / 2.2. Chủ hồ sơ hoặc admin/hội đồng.""" + from src.initiative_db.application_storage import ( + get_evidence_artifact_row, + ) + from src.initiative_db.engine import get_session, is_postgres_enabled + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xem minh chứng.") + + if not is_postgres_enabled(): + return {"research": None, "textbook": None, "technical": None} + + s3, bucket, _ = _load_minio_for_exports() + + async with get_session() as session: + from src.initiative_db.submissions import resolve_initiative_for_draft_case_key + + ini = await resolve_initiative_for_draft_case_key(session, case_id) + if ini is None: + return {"research": None, "textbook": None, "technical": None} + is_staff = _is_staff_reviewer(authorization) + if ini.owner_id != uid and not is_staff: + raise HTTPException(status_code=403, detail="Không có quyền xem minh chứng hồ sơ này.") + + r_row = await get_evidence_artifact_row(session, initiative_id=ini.id, role="research_evidence") + t_row = await get_evidence_artifact_row(session, initiative_id=ini.id, role="textbook_evidence") + tech_row = await get_evidence_artifact_row(session, initiative_id=ini.id, role="technical_evidence") + + async def pack(row, kind_key: str): + if row is None: + return None + default_name = row.original_name or "evidence" + if not default_name.lower().endswith((".pdf", ".png", ".jpg", ".jpeg", ".docx", ".xlsx")): + mt = (row.mime_type or "").lower() + if "pdf" in mt: + default_name = f"{default_name}.pdf" + elif "word" in mt or "document" in mt: + default_name = f"{default_name}.docx" + out = { + "kind": kind_key, + "originalName": row.original_name, + "byteSize": row.byte_size, + "mimeType": row.mime_type, + "uploadedAt": row.uploaded_at.isoformat() if row.uploaded_at else None, + "storageKey": row.storage_uri, + "reviewStatus": row.review_status, + "reviewedAt": row.reviewed_at.isoformat() if row.reviewed_at else None, + } + if s3 and bucket and row.storage_uri: + try: + from src.minio.storage import settings as s3s + + out["downloadUrl"] = await s3.get_download_url( + bucket, + row.storage_uri, + ttl=s3s.s3_signed_url_ttl, + filename=default_name, + inline=False, + ) + if _evidence_row_looks_like_pdf(row, default_name): + out["viewUrl"] = await s3.get_download_url( + bucket, + row.storage_uri, + ttl=s3s.s3_signed_url_ttl, + filename=default_name, + inline=True, + response_content_type="application/pdf", + ) + else: + out["viewUrl"] = None + except Exception: + out["downloadUrl"] = None + out["viewUrl"] = None + return out + + return { + "research": await pack(r_row, "research"), + "textbook": await pack(t_row, "textbook"), + "technical": await pack(tech_row, "technical"), + } + + +@app.get("/api/v1/application-drafts/{case_id}/evidence/content") +async def stream_application_draft_evidence_content( + case_id: str, + request: Request, + kind: str = Query(..., description="research | textbook | technical"), + attachment: bool = Query( + False, + description="If true, Content-Disposition: attachment (download). Otherwise inline for embedding.", + ), + authorization: Optional[str] = Header(None), +): + """ + Stream evidence bytes through the API so browsers on HTTPS avoid mixed-content iframes + (presigned MinIO URLs are often http://HOST:MINIO_PORT). + Same ACL as GET …/evidence. + """ + from src.initiative_db.application_storage import ( + get_evidence_artifact_row, + ) + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.minio.storage import StorageError, _sanitize_filename as minio_safe_fn + + kinds = request.query_params.getlist("kind") + if len(kinds) > 1: + effective_kind: object = kinds + elif len(kinds) == 1: + effective_kind = kinds[0] + else: + effective_kind = kind + + role = _evidence_kind_to_role(effective_kind) + if role is None: + raise HTTPException(status_code=400, detail="kind phải là research, textbook hoặc technical") + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xem minh chứng.") + + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL để lấy minh chứng.") + + s3, bucket, _ = _load_minio_for_evidence() + if s3 is None or bucket is None: + raise HTTPException( + status_code=503, + detail="Lưu tệp MinIO chưa cấu hình. Đặt biến môi trường S3/MinIO hoặc xem tài liệu triển khai.", + ) + + async with get_session() as session: + from src.initiative_db.submissions import resolve_initiative_for_draft_case_key + + ini = await resolve_initiative_for_draft_case_key(session, case_id) + if ini is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.") + if ini.owner_id != uid and not _is_staff_reviewer(authorization): + raise HTTPException(status_code=403, detail="Không có quyền xem minh chứng hồ sơ này.") + row = await get_evidence_artifact_row(session, initiative_id=ini.id, role=role) + + if row is None or not (row.storage_uri or "").strip(): + raise HTTPException(status_code=404, detail="Không có tệp minh chứng cho loại này.") + + default_name = row.original_name or "evidence" + if not default_name.lower().endswith((".pdf", ".png", ".jpg", ".jpeg", ".docx", ".xlsx")): + mtguess = (row.mime_type or "").lower() + if "pdf" in mtguess: + default_name = f"{default_name}.pdf" + elif "word" in mtguess or "document" in mtguess: + default_name = f"{default_name}.docx" + + safe_fn = minio_safe_fn(default_name) or "file" + disp = "attachment" if attachment else "inline" + media_type = (row.mime_type or "").strip() or "application/octet-stream" + + try: + + async def body(): + async for chunk in s3.download_stream(bucket, row.storage_uri): + yield chunk + + return StreamingResponse( + body(), + media_type=media_type, + headers={ + "Content-Disposition": f'{disp}; filename="{safe_fn}"', + "Cache-Control": "private, no-store", + }, + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Tệp không còn trên kho lưu trữ.") from None + except StorageError as exc: + raise HTTPException(status_code=502, detail=f"Không đọc được tệp từ kho: {exc}") from exc + + +@app.delete("/api/v1/application-drafts/{case_id}/evidence") +async def delete_application_draft_evidence( + case_id: str, + request: Request, + kind: str = Query(..., description="research | textbook | technical"), + authorization: Optional[str] = Header(None), +): + from src.initiative_db.application_storage import ( + delete_evidence_artifact_row, + ) + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.minio.storage import StorageError + + kinds = request.query_params.getlist("kind") + if len(kinds) > 1: + effective_kind: object = kinds + elif len(kinds) == 1: + effective_kind = kinds[0] + else: + effective_kind = kind + role = _evidence_kind_to_role(effective_kind) + if role is None: + raise HTTPException(status_code=400, detail="kind phải là research, textbook hoặc technical") + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xóa minh chứng.") + + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL.") + + s3, bucket, _ = _load_minio_for_evidence() + async with get_session() as session: + from src.initiative_db.submissions import resolve_initiative_for_draft_case_key + + ini = await resolve_initiative_for_draft_case_key(session, case_id) + if ini is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.") + if ini.owner_id != uid: + raise HTTPException(status_code=403, detail="Chỉ chủ sở hữu mới xóa được minh chứng.") + st = str(ini.status or "") + if not _initiative_allows_owner_evidence_edit(st): + raise HTTPException( + status_code=422, + detail="Hồ sơ đã kết thúc duyệt — không xóa minh chứng.", + ) + + old = await delete_evidence_artifact_row(session, initiative_id=ini.id, role=role) + from src.auth_jwt import decode_bearer_token + from src.audit import AuditAction, jwt_payload_actor_email, record_audit + + if old is not None: + ae, ar = jwt_payload_actor_email(decode_bearer_token(authorization)) + await record_audit( + session, + actor_user_id=uid, + actor_email=ae, + actor_role=ar, + action=AuditAction.delete, + entity_type="application_evidence", + entity_id=f"{ini.case_code}:{role}", + before={ + "storageKey": old.storage_uri, + "originalName": old.original_name, + }, + metadata={"caseId": ini.case_code, "minioBucket": bucket}, + ) + if old and old.storage_uri and s3 and bucket: + try: + await s3.delete(bucket, old.storage_uri) + except StorageError as exc: + logger.warning("MinIO delete failed (continuing with DB row removed): %s", exc) + await session.commit() + + return {"ok": True, "kind": _evidence_role_to_api_kind(role)} + + +@app.get("/api/v1/application-drafts/{case_id}/official-form-layout") +async def get_application_draft_official_form_layout( + case_id: str, + authorization: Optional[str] = Header(None), +): + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.drafts import get_official_form_layout_payload + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xem bố cục PDF.") + if not is_postgres_enabled(): + return {"caseId": _normalize_case_id(case_id), "layoutEdits": [], "pdf": None} + + s3, bucket, _ = _load_minio_for_evidence() + safe_case_id = _normalize_case_id(case_id) + async with get_session() as session: + from src.initiative_db.submissions import resolve_initiative_for_draft_case_key + + ini = await resolve_initiative_for_draft_case_key(session, safe_case_id) + if ini is None: + return {"caseId": safe_case_id, "layoutEdits": [], "pdf": None} + if ini.owner_id != uid and not _is_staff_reviewer(authorization): + raise HTTPException(status_code=403, detail="Không có quyền xem bố cục hồ sơ này.") + payload = await get_official_form_layout_payload(session, ini.case_code) + + if not payload: + return {"caseId": safe_case_id, "layoutEdits": [], "pdf": None} + + edits = payload.get("layoutEdits") + if not isinstance(edits, list): + edits = [] + + pdf_meta: Optional[Dict[str, Any]] = None + storage_key = str(payload.get("storageKey") or "").strip() + if storage_key: + pdf_meta = { + "storageKey": storage_key, + "originalName": payload.get("originalName"), + "byteSize": payload.get("byteSize"), + "uploadedAt": payload.get("uploadedAt"), + "downloadUrl": None, + "viewUrl": None, + } + if s3 and bucket: + try: + from src.minio.storage import settings as s3s + + default_name = str(payload.get("originalName") or "official-form-layout.pdf") + pdf_meta["downloadUrl"] = await s3.get_download_url( + bucket, + storage_key, + ttl=s3s.s3_signed_url_ttl, + filename=default_name, + inline=False, + ) + pdf_meta["viewUrl"] = await s3.get_download_url( + bucket, + storage_key, + ttl=s3s.s3_signed_url_ttl, + filename=default_name, + inline=True, + response_content_type="application/pdf", + ) + except Exception: + pdf_meta["downloadUrl"] = None + pdf_meta["viewUrl"] = None + + return { + "caseId": safe_case_id, + "layoutEdits": edits, + "updatedAt": payload.get("updatedAt"), + "pdf": pdf_meta, + } + + +@app.post("/api/v1/application-drafts/{case_id}/official-form-layout") +async def save_application_draft_official_form_layout( + case_id: str, + layout_edits_json: str = Form(..., description="JSON array of PdfTextLayoutEdit"), + file: UploadFile = File(..., description="Edited official-form PDF"), + authorization: Optional[str] = Header(None), +): + from src.initiative_db.drafts import get_official_form_layout_payload, save_official_form_layout_payload + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.minio.storage import StorageError + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để lưu bố cục PDF.") + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL để lưu bố cục PDF.") + s3, bucket, _cfg_err = _load_minio_for_exports() + if s3 is None or bucket is None: + raise HTTPException( + status_code=503, + detail="Lưu tệp MinIO chưa cấu hình. Đặt biến môi trường S3/MinIO hoặc xem tài liệu triển khai.", + ) + + safe_case_id = _normalize_case_id(case_id) + try: + raw_edits = json.loads(layout_edits_json) + except Exception as exc: + raise HTTPException(status_code=422, detail="layoutEdits JSON không hợp lệ.") from exc + normalized_edits = _normalize_pdf_layout_edits(raw_edits) + + content_type = (file.content_type or "").split(";")[0].strip().lower() or "application/octet-stream" + if content_type != "application/pdf": + raise HTTPException(status_code=422, detail=f"Chỉ nhận PDF, nhận được: {content_type}") + + async with get_session() as session: + from src.initiative_db.submissions import resolve_initiative_for_draft_case_key + + ini = await resolve_initiative_for_draft_case_key(session, safe_case_id) + if ini is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ (hãy lưu bản nháp trước).") + if ini.owner_id != uid: + raise HTTPException(status_code=403, detail="Chỉ chủ sở hữu mới lưu bố cục PDF.") + st = str(ini.status or "") + if not _initiative_allows_owner_evidence_edit(st): + raise HTTPException(status_code=422, detail="Hồ sơ đã kết thúc duyệt — không cập nhật bố cục PDF.") + + existing_payload = await get_official_form_layout_payload(session, ini.case_code) + object_key = s3.build_key_for_initiative(ini.id, file.filename or "official-form-layout.pdf") + try: + upload = await s3.upload( + bucket=bucket, + key=object_key, + fileobj=file.file, + mime_type="application/pdf", + metadata={"uploaded_by": str(uid), "case_code": ini.case_code, "role": "official_form_layout_pdf"}, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except StorageError as exc: + raise HTTPException(status_code=502, detail=f"Không tải được PDF bố cục lên kho: {exc}") from exc + + now_iso = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + payload = { + "storageKey": object_key, + "originalName": file.filename or "official-form-layout.pdf", + "mimeType": "application/pdf", + "byteSize": upload.get("size"), + "sha256": upload.get("sha256"), + "uploadedBy": str(uid), + "uploadedAt": now_iso, + "updatedAt": now_iso, + "layoutEdits": normalized_edits, + "layoutEditCount": len(normalized_edits), + } + await save_official_form_layout_payload( + session, + case_id=ini.case_code, + payload=payload, + owner_id=uid, + ) + await session.commit() + + prior_storage_key = str((existing_payload or {}).get("storageKey") or "").strip() + if prior_storage_key and prior_storage_key != object_key: + try: + await s3.delete(bucket, prior_storage_key) + except StorageError as exc: + logger.warning("MinIO delete of replaced official-form layout PDF failed: %s", exc) + + return { + "ok": True, + "caseId": safe_case_id, + "layoutEdits": normalized_edits, + "storageKey": object_key, + "byteSize": upload.get("size"), + "uploadedAt": payload["uploadedAt"], + } + + +class EvidenceReviewBody(BaseModel): + decision: Literal["approved", "rejected"] + + +@app.patch("/api/v1/application-drafts/{case_id}/evidence/review") +async def patch_evidence_review( + case_id: str, + request: Request, + kind: str = Query(..., description="research | textbook | technical"), + body: EvidenceReviewBody = Body(...), + authorization: Optional[str] = Header(None), +): + """ + Duyệt / từ chối minh chứng (chỉ admin hoặc hội đồng / editor). + """ + from src.initiative_db.application_storage import get_evidence_artifact_row, set_evidence_artifact_review + from src.initiative_db.engine import get_session, is_postgres_enabled + + if not _is_staff_reviewer(authorization): + raise HTTPException(status_code=403, detail="Chỉ quản trị hoặc hội đồng mới thẩm định minh chứng.") + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập.") + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL.") + + kinds = request.query_params.getlist("kind") + if len(kinds) > 1: + effective_kind: object = kinds + elif len(kinds) == 1: + effective_kind = kinds[0] + else: + effective_kind = kind + role = _evidence_kind_to_role(effective_kind) + if role is None: + raise HTTPException(status_code=400, detail="kind phải là research, textbook hoặc technical") + + async with get_session() as session: + from src.initiative_db.submissions import resolve_initiative_for_draft_case_key + + ini = await resolve_initiative_for_draft_case_key(session, case_id) + if ini is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.") + row = await get_evidence_artifact_row(session, initiative_id=ini.id, role=role) + if row is None: + raise HTTPException(status_code=404, detail="Chưa có tệp minh chứng cho loại này.") + before_review = { + "reviewStatus": row.review_status, + "reviewedBy": str(row.reviewed_by) if row.reviewed_by else None, + } + await set_evidence_artifact_review( + session, + initiative_id=ini.id, + role=role, + review_status=body.decision, + reviewer_id=uid, + ) + from src.auth_jwt import decode_bearer_token + from src.audit import AuditAction, jwt_payload_actor_email, record_audit + + ae, ar = jwt_payload_actor_email(decode_bearer_token(authorization)) + await record_audit( + session, + actor_user_id=uid, + actor_email=ae, + actor_role=ar, + action=AuditAction.update, + entity_type="application_evidence_review", + entity_id=f"{ini.case_code}:{role}", + before=before_review, + after={"reviewStatus": body.decision, "reviewedBy": str(uid)}, + metadata={"caseId": ini.case_code}, + ) + await session.commit() + + return {"ok": True, "kind": _evidence_role_to_api_kind(role), "decision": body.decision} + + +@app.get("/api/v1/initiatives/by-case/{case_id}/tab-snapshots") +async def list_initiative_tab_snapshots( + case_id: str, + tab: Optional[str] = Query( + None, description="Optional filter: report | application | contribution" + ), + limit: int = Query(20, ge=1, le=200), + authorization: Optional[str] = Header(None), +): + """List versioned tab payloads for an initiative (Postgres + migration 002). Owner-only.""" + from sqlalchemy import select + + from src.initiative_db.application_storage import list_tab_snapshots_for_case + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import Initiative + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xem lịch sử tab.") + + if not is_postgres_enabled(): + return {"data": []} + + safe_case_id = _normalize_case_id(case_id) + try: + async with get_session() as session: + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == safe_case_id)) + ).scalar_one_or_none() + if ini is None: + return {"data": []} + if ini.owner_id != uid: + raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") + data = await list_tab_snapshots_for_case( + session, case_code=safe_case_id, tab=tab, limit=limit + ) + return {"data": data} + except HTTPException: + raise + except Exception: + logger.exception("GET tab-snapshots failed case=%s", safe_case_id) + raise HTTPException(status_code=500, detail="Không tải được lịch sử tab") from None + + +@app.get("/api/v1/initiatives/by-case/{case_id}/submit-snapshots") +async def list_initiative_submit_snapshots( + case_id: str, + limit: int = Query(10, ge=1, le=50), + authorization: Optional[str] = Header(None), +): + """Immutable submit snapshots for an initiative (Postgres + migration 002). Owner-only.""" + from sqlalchemy import select + + from src.initiative_db.application_storage import list_submit_snapshots_for_case + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import Initiative + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xem lịch sử nộp.") + + if not is_postgres_enabled(): + return {"data": []} + + safe_case_id = _normalize_case_id(case_id) + try: + async with get_session() as session: + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == safe_case_id)) + ).scalar_one_or_none() + if ini is None: + return {"data": []} + if ini.owner_id != uid: + raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") + data = await list_submit_snapshots_for_case(session, case_code=safe_case_id, limit=limit) + return {"data": data} + except HTTPException: + raise + except Exception: + logger.exception("GET submit-snapshots failed case=%s", safe_case_id) + raise HTTPException(status_code=500, detail="Không tải được lịch sử nộp") from None + + +@app.post("/api/v1/review-documents") +async def create_review_document( + body: ReviewDocumentSaveRequest, + authorization: Optional[str] = Header(None), +): + """ + Persist ReviewPanel JSON bundle. + Primary payload is `officialBieuMau`; `templateData` / `fullBundle` are optional mirrors. + """ + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.submissions import save_review_document_bundle + + safe_case_id = _normalize_case_id(body.caseId) + req_case_id = _normalize_case_id(body.caseId) + if req_case_id != safe_case_id: + raise HTTPException(status_code=400, detail="caseId in path and body must match.") + owner_uid = _require_authenticated_user(authorization) + + if is_postgres_enabled(): + try: + async with get_session() as session: + await _assert_initiative_case_access(session, safe_case_id, owner_uid, authorization) + saved = await save_review_document_bundle( + session, + case_id=safe_case_id, + official_bieu_mau=body.officialBieuMau, + template_data=body.templateData, + full_bundle=body.fullBundle, + owner_user_id=owner_uid, + ) + return saved + except HTTPException: + raise + except Exception: + logger.exception("review-document save (PostgreSQL) failed case=%s", safe_case_id) + raise HTTPException(status_code=500, detail="Không lưu được JSON ReviewPanel") from None + + # file fallback + target_dir = APP_ROOT_DIR / "assets" / "review-documents" + target_dir.mkdir(parents=True, exist_ok=True) + payload = { + "caseId": safe_case_id, + "officialBieuMau": body.officialBieuMau, + "templateData": body.templateData, + "fullBundle": body.fullBundle, + "savedAt": datetime.utcnow().replace(microsecond=0).isoformat() + "Z", + } + with open(target_dir / f"{safe_case_id}.json", "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + return payload + + +@app.get("/api/v1/review-documents") +async def list_review_documents( + caseId: str, + limit: int = Query(20, ge=1, le=200), + authorization: Optional[str] = Header(None), +): + """List review documents by case id (latest first).""" + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.submissions import list_review_document_bundles + + uid = _require_authenticated_user(authorization) + safe_case_id = _normalize_case_id(caseId) + + if is_postgres_enabled(): + try: + async with get_session() as session: + await _assert_initiative_case_access(session, safe_case_id, uid, authorization) + rows = await list_review_document_bundles(session, case_id=safe_case_id, limit=limit) + return {"data": rows} + except Exception: + logger.exception("review-document load (PostgreSQL) failed case=%s", safe_case_id) + raise HTTPException(status_code=500, detail="Không tải được JSON ReviewPanel") from None + + target_file = APP_ROOT_DIR / "assets" / "review-documents" / f"{safe_case_id}.json" + if not _is_staff_reviewer(authorization): + raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") + if target_file.exists(): + try: + with open(target_file, "r", encoding="utf-8") as handle: + return {"data": [json.load(handle)]} + except Exception: + logger.exception("review-document file fallback load failed case=%s", safe_case_id) + raise HTTPException(status_code=500, detail="Không tải được JSON ReviewPanel") from None + return {"data": []} + + +@app.get("/api/v1/review-documents/{review_document_id}") +async def get_review_document_by_id( + review_document_id: str, authorization: Optional[str] = Header(None) +): + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.submissions import get_review_document_bundle_by_id + + uid = _require_authenticated_user(authorization) + + if is_postgres_enabled(): + try: + async with get_session() as session: + await _assert_review_document_access(session, review_document_id, uid, authorization) + row = await get_review_document_bundle_by_id( + session, review_document_id=review_document_id + ) + if row is None: + raise HTTPException(status_code=404, detail="Không tìm thấy review document") + return row + except HTTPException: + raise + except Exception: + logger.exception("review-document get by id failed id=%s", review_document_id) + raise HTTPException(status_code=500, detail="Không tải được review document") from None + raise HTTPException(status_code=501, detail="ID-based lookup requires PostgreSQL mode") + + +@app.put("/api/v1/review-documents/{review_document_id}") +async def update_review_document_by_id( + review_document_id: str, + body: ReviewDocumentUpdateRequest, + authorization: Optional[str] = Header(None), +): + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.submissions import update_review_document_bundle + + uid = _require_authenticated_user(authorization) + + if is_postgres_enabled(): + try: + async with get_session() as session: + await _assert_review_document_access(session, review_document_id, uid, authorization) + row = await update_review_document_bundle( + session, + review_document_id=review_document_id, + official_bieu_mau=body.officialBieuMau, + template_data=body.templateData, + full_bundle=body.fullBundle, + ) + if row is None: + raise HTTPException(status_code=404, detail="Không tìm thấy review document") + return row + except HTTPException: + raise + except Exception: + logger.exception("review-document update failed id=%s", review_document_id) + raise HTTPException(status_code=500, detail="Không cập nhật được review document") from None + raise HTTPException(status_code=501, detail="ID-based update requires PostgreSQL mode") + + +@app.delete("/api/v1/review-documents/{review_document_id}") +async def delete_review_document_by_id( + review_document_id: str, authorization: Optional[str] = Header(None) +): + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.submissions import delete_review_document_bundle + + uid = _require_authenticated_user(authorization) + + if is_postgres_enabled(): + try: + async with get_session() as session: + await _assert_review_document_access(session, review_document_id, uid, authorization) + ok = await delete_review_document_bundle( + session, review_document_id=review_document_id + ) + if not ok: + raise HTTPException(status_code=404, detail="Không tìm thấy review document") + return {"deleted": True, "id": review_document_id} + except HTTPException: + raise + except Exception: + logger.exception("review-document delete failed id=%s", review_document_id) + raise HTTPException(status_code=500, detail="Không xóa được review document") from None + raise HTTPException(status_code=501, detail="ID-based delete requires PostgreSQL mode") + + +# Backward-compatible endpoints (deprecated) +@app.post("/api/v1/applications/{case_id}/review-document") +async def save_review_document( + case_id: str, + body: ReviewDocumentSaveRequest, + authorization: Optional[str] = Header(None), +): + if _normalize_case_id(case_id) != _normalize_case_id(body.caseId): + raise HTTPException(status_code=400, detail="caseId in path and body must match.") + return await create_review_document(body, authorization) + + +@app.get("/api/v1/applications/{case_id}/review-document") +async def get_review_document(case_id: str): + rows = await list_review_documents(case_id, limit=1) + data = rows.get("data") if isinstance(rows, dict) else None + if isinstance(data, list) and data: + return data[0] + raise HTTPException(status_code=404, detail="Không tìm thấy JSON ReviewPanel") + + +@app.get("/api/v1/applications/{case_id}/review-document/be01-context") +async def get_review_document_be01_context(case_id: str): + """Convert latest official ReviewPanel JSON to be01 `data_blank.json` shape.""" + from src.be01.official_to_data_blank import official_to_data_blank + + row = await get_review_document(case_id) + official = row.get("officialBieuMau") if isinstance(row, dict) else {} + if not isinstance(official, dict) or not official: + raise HTTPException(status_code=404, detail="Không có officialBieuMau để chuyển đổi.") + return { + "caseId": str(row.get("caseId") or case_id), + "be01Context": official_to_data_blank(official), + } + + +class PreviewApplicationFormDocxRequest(BaseModel): + """Paired with `data_blank.json` after `official_to_data_blank(officialBieuMau)`.""" + + officialBieuMau: dict[str, Any] + + +@app.post("/api/v1/docx/preview-application-form") +async def preview_application_form_docx(body: PreviewApplicationFormDocxRequest): + """ + Render `template_application_form.docx` (Jinja2/docxtpl) and return a filled .docx. + Accepts the same `officialBieuMau` object produced on the « Xem lại » tab. + """ + from src.be01.official_to_data_blank import official_to_data_blank + from src.be01.fill_application_form import fill_application_form_docx + + try: + ctx = official_to_data_blank(body.officialBieuMau or {}) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=400, detail="Không chuyển officialBieuMau sang dữ liệu biểu mẫu: " + str(exc) + ) from exc + try: + raw = fill_application_form_docx(ctx) + except ImportError as exc: + raise HTTPException( + status_code=501, detail="Thiếu thư viện docxtpl. Cài: pip install docxtpl" + ) from exc + except FileNotFoundError as exc: + raise HTTPException( + status_code=500, detail="Không tìm thấy file mẫu Word trên server: " + str(exc) + ) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=500, detail="Lỗi khi render mẫu Word: " + str(exc) + ) from exc + return Response( + content=raw, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + headers={"Content-Disposition": 'inline; filename="mau-don-va-bao-cao-xem-truoc.docx"'}, + ) + + +@app.post("/api/v1/docx/preview-application-form-pdf") +async def preview_application_form_pdf(body: PreviewApplicationFormDocxRequest): + """ + Same merge as `preview-application-form` (docxtpl), then LibreOffice → PDF so layout matches DOCX. + """ + from src.be01.docx_to_pdf import convert_docx_bytes_to_pdf + from src.be01.fill_application_form import fill_application_form_docx + from src.be01.official_to_data_blank import official_to_data_blank + + try: + ctx = official_to_data_blank(body.officialBieuMau or {}) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=400, detail="Không chuyển officialBieuMau sang dữ liệu biểu mẫu: " + str(exc) + ) from exc + try: + docx_bytes = fill_application_form_docx(ctx) + except ImportError as exc: + raise HTTPException( + status_code=501, detail="Thiếu thư viện docxtpl. Cài: pip install docxtpl" + ) from exc + except FileNotFoundError as exc: + raise HTTPException( + status_code=500, detail="Không tìm thấy file mẫu Word trên server: " + str(exc) + ) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=500, detail="Lỗi khi render mẫu Word: " + str(exc) + ) from exc + try: + pdf_bytes = convert_docx_bytes_to_pdf( + docx_bytes, + relax_justified_softbreaks=True, + strip_table_row_heights=False, + ) + except FileNotFoundError as exc: + raise HTTPException( + status_code=501, + detail="Chưa cài LibreOffice để xuất PDF. Docker: thêm libreoffice-writer-nogui; " + "hoặc đặt LIBREOFFICE_PATH. Chi tiết: " + str(exc), + ) from exc + except (RuntimeError, ValueError, subprocess.TimeoutExpired) as exc: + raise HTTPException( + status_code=500, detail="Không chuyển DOCX sang PDF: " + str(exc) + ) from exc + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": 'inline; filename="mau-ho-so-sang-kien.pdf"'}, + ) + + +@app.post("/api/v1/docx/convert-pdf") +async def convert_uploaded_docx_to_pdf( + file: UploadFile = File(...), + relax_justified_softbreaks: bool = Form(True), + strip_table_row_heights: bool = Form(False), +): + """ + Convert an uploaded `.docx` file to PDF using LibreOffice for near-Word layout fidelity. + """ + from src.be01.docx_to_pdf import convert_docx_bytes_to_pdf + + filename = (file.filename or "").strip() + if not filename: + raise HTTPException(status_code=400, detail="Thiếu tên file .docx.") + if not filename.lower().endswith(".docx"): + raise HTTPException(status_code=400, detail="Chỉ hỗ trợ file .docx.") + + try: + docx_bytes = await file.read() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail="Không đọc được nội dung file upload.") from exc + if not docx_bytes: + raise HTTPException(status_code=400, detail="File .docx rỗng.") + + try: + pdf_bytes = await asyncio.to_thread( + convert_docx_bytes_to_pdf, + docx_bytes, + relax_justified_softbreaks=relax_justified_softbreaks, + strip_table_row_heights=strip_table_row_heights, + ) + except FileNotFoundError as exc: + raise HTTPException( + status_code=501, + detail="Chưa cài LibreOffice để xuất PDF. Docker: thêm libreoffice-writer-nogui; " + "hoặc đặt LIBREOFFICE_PATH. Chi tiết: " + str(exc), + ) from exc + except (RuntimeError, ValueError, subprocess.TimeoutExpired) as exc: + raise HTTPException( + status_code=500, detail="Không chuyển DOCX sang PDF: " + str(exc) + ) from exc + + safe_stem = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in Path(filename).stem) + out_name = (safe_stem or "document") + ".pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{out_name}"'}, + ) + + +# --- Đơn sáng kiến: nộp PDF (người nộp) + danh sách cho admin « Danh Sách Sáng kiến » --- + +SUBMITTED_INITIATIVES_DIR = Path( + os.getenv( + "SUBMITTED_INITIATIVES_DIR", + str((APP_ROOT_DIR.parent / "fe0" / "public" / "submitted-initiatives").resolve()), + ) +) +SUBMITTED_INDEX_PATH = SUBMITTED_INITIATIVES_DIR / "index.json" +SUBMITTED_INITIATIVES_DIR.mkdir(parents=True, exist_ok=True) +app.mount( + "/submitted-initiatives", + StaticFiles(directory=str(SUBMITTED_INITIATIVES_DIR.resolve())), + name="submitted_initiatives", +) + + +def _load_submitted_items() -> List[Dict[str, Any]]: + SUBMITTED_INITIATIVES_DIR.mkdir(parents=True, exist_ok=True) + if not SUBMITTED_INDEX_PATH.exists(): + return [] + try: + with open(SUBMITTED_INDEX_PATH, "r", encoding="utf-8") as handle: + data = json.load(handle) + items = data.get("items") if isinstance(data, dict) else None + return list(items) if isinstance(items, list) else [] + except Exception: + logger.exception("Failed to load submitted initiatives index") + return [] + + +def _save_submitted_items(items: List[Dict[str, Any]]) -> None: + SUBMITTED_INITIATIVES_DIR.mkdir(parents=True, exist_ok=True) + with open(SUBMITTED_INDEX_PATH, "w", encoding="utf-8") as handle: + json.dump({"items": items}, handle, ensure_ascii=False, indent=2) + + +@app.post("/api/applications/submit") +async def submit_initiative_application( + file: UploadFile = File(...), + metadata: str = Form(""), + authorization: Optional[str] = Header(None), +): + """ + Nhận file PDF hồ sơ đầy đủ từ tab « Xem lại », lưu vào public/submitted-initiatives + và ghi vào index để GET /api/applications trả về cho admin. + """ + if not file.filename or not file.filename.lower().endswith(".pdf"): + raise HTTPException(status_code=400, detail="Yêu cầu file PDF (.pdf)") + + content = await file.read() + if not content or len(content) < 100: + raise HTTPException(status_code=400, detail="File PDF không hợp lệ hoặc quá nhỏ") + + try: + meta = json.loads(metadata) if metadata.strip() else {} + except json.JSONDecodeError: + meta = {} + + new_id = f"sub-{uuid.uuid4().hex[:16]}" + now = datetime.utcnow().replace(microsecond=0).isoformat() + "Z" + safe_name = f"{new_id}.pdf" + SUBMITTED_INITIATIVES_DIR.mkdir(parents=True, exist_ok=True) + pdf_path = SUBMITTED_INITIATIVES_DIR / safe_name + with open(pdf_path, "wb") as handle: + handle.write(content) + + initiative_name = (meta.get("initiativeName") or meta.get("name") or "").strip() or "Hồ sơ sáng kiến" + author_name = (meta.get("authorName") or "").strip() or "—" + author_email = (meta.get("authorEmail") or "").strip() or None + author_phone = (meta.get("authorPhone") or "").strip() or None + case_id = (meta.get("caseId") or "").strip() or None + + item: Dict[str, Any] = { + "id": new_id, + "submittedDate": now, + "name": initiative_name, + "author": { + "id": case_id or new_id, + "name": author_name, + "email": author_email, + "phone": author_phone, + }, + "subjectId": meta.get("subjectId") or "", + "groupId": meta.get("groupId") or "", + "status": "pending", + "reviewStatus": "not_reviewed", + "supervisor": None, + "reviewer": None, + "reviewDeadline": None, + "conference": None, + "topicType": str(meta.get("topicType") or "Hồ sơ PDF (đơn + báo cáo)"), + "files": { + "fullText": {"url": f"/submitted-initiatives/{safe_name}", "type": "pdf"}, + "abstract": None, + "poster": None, + }, + } + + public_url = f"/submitted-initiatives/{safe_name}" + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.submissions import ApplicationSubmitPersistError, save_submitted_application + from src.initiative_db.submission_readiness import ApplicationSubmissionNotReadyError + + owner_uid = decode_access_token_user_id(authorization) + + if is_postgres_enabled(): + try: + pdf_sha256 = hashlib.sha256(content).hexdigest() + pdf_len = len(content) + async with get_session() as session: + saved = await save_submitted_application( + session=session, + metadata=meta if isinstance(meta, dict) else {}, + file_url=public_url, + submission_id=new_id, + owner_user_id=owner_uid, + pdf_byte_size=pdf_len, + pdf_sha256=pdf_sha256, + pdf_original_name=safe_name, + pdf_body=content, + ) + logger.info("Submitted initiative PDF persisted in PostgreSQL path=%s", pdf_path) + return saved + except ApplicationSubmissionNotReadyError as exc: + try: + pdf_path.unlink(missing_ok=True) + except OSError: + pass + raise HTTPException( + status_code=400, + detail={ + "message": "Hồ sơ chưa đủ điều kiện nộp.", + "missing": exc.missing, + }, + ) from exc + except ApplicationSubmitPersistError as exc: + raise HTTPException( + status_code=503, + detail=str(exc), + ) from exc + except Exception: + logger.exception("application submission persist (PostgreSQL) failed; fallback to file index") + + items = _load_submitted_items() + items.insert(0, item) + _save_submitted_items(items) + + logger.info("Submitted initiative PDF id=%s path=%s", new_id, pdf_path) + return { + "id": new_id, + "submittedDate": now, + "publicUrl": public_url, + "name": initiative_name, + } + + +@app.post("/api/applications/new") +async def create_submitted_application( + body: CreateSubmittedApplicationBody, + authorization: Optional[str] = Header(None), +): + """Create a new submitted-application shell row and return generated application id.""" + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import User + from src.initiative_db.submissions import create_submitted_application_shell + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để tạo hồ sơ mới.") + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Tính năng này yêu cầu PostgreSQL.") + + try: + async with get_session() as session: + user = await session.get(User, uid) + row = await create_submitted_application_shell( + session=session, + owner_user_id=uid, + name=(body.name or "").strip() or None, + author_name=(str(user.full_name).strip() if user and user.full_name else None), + author_email=(str(user.email).strip() if user and user.email else None), + author_phone=(str(user.phone).strip() if user and user.phone else None), + ) + return {"id": str(row.get("id") or ""), "application": row} + except HTTPException: + raise + except Exception: + logger.exception("POST /api/applications/new failed") + raise HTTPException(status_code=500, detail="Không thể tạo hồ sơ mới.") from None + + +def _get_application_from_file_index(application_id: str) -> Optional[Dict[str, Any]]: + for row in _load_submitted_items(): + if str(row.get("id")) == application_id: + return row + return None + + +@app.get("/api/applications/mine") +async def list_my_applications(authorization: Optional[str] = Header(None)): + """Submitted applications for the logged-in applicant (Postgres + optional file fallback).""" + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import User + from src.initiative_db.submissions import list_my_submitted_applications + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xem hồ sơ của bạn.") + + if is_postgres_enabled(): + try: + async with get_session() as session: + user = await session.get(User, uid) + email = str(user.email) if user is not None else "" + data = await list_my_submitted_applications(session, uid, email) + return {"data": data} + except HTTPException: + raise + except Exception: + logger.exception("GET /api/applications/mine (PostgreSQL) failed") + raise HTTPException(status_code=500, detail="Không tải được danh sách hồ sơ") from None + + payload = decode_bearer_token(authorization) + token_email = str((payload or {}).get("email") or "").strip().lower() + items = _load_submitted_items() + filtered: List[Dict[str, Any]] = [] + for row in items: + auth_em = str((row.get("author") or {}).get("email") or "").strip().lower() + if token_email and auth_em == token_email: + filtered.append(row) + filtered.sort(key=lambda x: str(x.get("submittedDate") or ""), reverse=True) + for row in filtered: + sd = str(row.get("submittedDate") or "") + if len(sd) >= 4 and sd[:4].isdigit(): + row["calendarYear"] = int(sd[:4]) + return {"data": filtered} + + +async def _enrich_application_detail_full_pdf_presign(session, row: Dict[str, Any]) -> None: + """If full PDF artifact is stored as a MinIO exports key, add files.fullText.viewUrl for admins.""" + from sqlalchemy import select + + from src.initiative_db.models import ApplicationArtifact, Initiative + + case = str(row.get("draft_case_id") or "").strip() + if not case: + return + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == case)) + ).scalar_one_or_none() + if ini is None: + return + art = ( + await session.execute( + select(ApplicationArtifact).where( + ApplicationArtifact.initiative_id == ini.id, + ApplicationArtifact.role == "full_pdf", + ) + ) + ).scalar_one_or_none() + if art is None or not (art.storage_uri or "").strip(): + return + uri = (art.storage_uri or "").strip() + if uri.startswith("/submitted-initiatives") or uri.startswith(("http://", "https://")): + return + try: + from src.minio.storage import S3Storage, settings as s3s + + s3 = S3Storage() + bucket = s3s.s3_bucket_exports + view_url = await s3.get_download_url( + bucket, + uri, + ttl=3600, + filename=(art.original_name or "ho-so.pdf"), + inline=True, + response_content_type="application/pdf", + ) + except Exception: + logger.warning("Presigned URL for submitted full PDF failed (case=%s)", case, exc_info=True) + return + files = row.setdefault("files", {}) + ft = files.get("fullText") + merged = dict(ft) if isinstance(ft, dict) else {} + merged["viewUrl"] = view_url + merged["storageKey"] = uri + files["fullText"] = merged + + +@app.get("/api/applications/export") +async def export_applications_excel( + authorization: Optional[str] = Header(None), + page: int = 1, + pageSize: int = 20, + name: str = "", + authorName: str = "", + reviewerName: str = "", + status: str = "", + reviewStatus: str = "", + dateFrom: str = "", + dateTo: str = "", + sortBy: str = "submittedDate", + sortOrder: str = "desc", + lifecycle: str = "", +): + """ + Xuất Excel danh sách sáng kiến (cùng bộ lọc / sắp xếp với GET /api/applications). + Cột TT & MSSK: «YYYY-n» (n tăng theo từng năm trong bản xuất). Chỉ tài khoản admin. + """ + _require_admin_user(authorization) + from src.be01.export_applications_list_xlsx import build_applications_list_xlsx + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.submissions import submitted_applications_pairs_for_export + + _ = page, pageSize # client gửi cùng query với danh sách; không phân trang khi xuất + + if is_postgres_enabled(): + try: + async with get_session() as session: + pairs = await submitted_applications_pairs_for_export( + session, + name=name, + author_name=authorName, + reviewer_name=reviewerName, + status=status, + review_status=reviewStatus, + date_from=dateFrom, + date_to=dateTo, + sort_by=sortBy, + sort_order=sortOrder, + lifecycle=lifecycle, + ) + except Exception: + logger.exception("GET /api/applications/export (PostgreSQL) failed") + raise HTTPException( + status_code=503, + detail="Không thể xuất Excel từ cơ sở dữ liệu. Vui lòng thử lại sau.", + ) from None + else: + items = _load_submitted_items() + lc = (lifecycle or "").strip().lower() + + def match(row: Dict[str, Any], *, skip_status: bool = False) -> bool: + row_status = str(row.get("status") or "") + if lc == "inbox": + if row_status in ("approved", "rejected"): + return False + elif lc == "decided": + if row_status not in ("approved", "rejected"): + return False + n = name.strip().lower() + if n and n not in str(row.get("name") or "").lower(): + return False + an = authorName.strip().lower() + auth = row.get("author") or {} + if an and an not in str(auth.get("name") or "").lower(): + return False + rn = reviewerName.strip().lower() + if rn: + rev = row.get("reviewer") or {} + if rn not in str(rev.get("name") or "").lower(): + return False + if not skip_status and status and row_status != status: + return False + if reviewStatus and str(row.get("reviewStatus") or "") != reviewStatus: + return False + sd = row.get("submittedDate") + if dateFrom and sd: + sd_day = str(sd)[:10] + if len(sd_day) == 10 and sd_day < dateFrom: + return False + if dateTo and sd: + sd_day = str(sd)[:10] + if len(sd_day) == 10 and sd_day > dateTo: + return False + return True + + filtered = [x for x in items if match(x, skip_status=False)] + reverse = sortOrder != "asc" + if sortBy == "name": + filtered.sort(key=lambda x: str(x.get("name") or ""), reverse=reverse) + elif sortBy == "author": + filtered.sort( + key=lambda x: str((x.get("author") or {}).get("name") or ""), + reverse=reverse, + ) + else: + filtered.sort(key=lambda x: str(x.get("submittedDate") or ""), reverse=reverse) + pairs = [(r, {}) for r in filtered] + + body = build_applications_list_xlsx(pairs) + safe_fn = f"sang-kien-export-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}.xlsx" + return Response( + content=body, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{safe_fn}"'}, + ) + + +BULK_APPLICATION_BACKUPS_MAX = 250 + + +@app.get("/api/applications/export-backups") +async def export_applications_backups_bundle( + authorization: Optional[str] = Header(None), + page: int = 1, + pageSize: int = 20, + name: str = "", + authorName: str = "", + reviewerName: str = "", + status: str = "", + reviewStatus: str = "", + dateFrom: str = "", + dateTo: str = "", + sortBy: str = "submittedDate", + sortOrder: str = "desc", + lifecycle: str = "", +): + """ + Admin-only: một file ZIP chứa từng file ZIP sao lưu hồ sơ (cùng bộ lọc / sắp xếp với Xuất Excel). + """ + from sqlalchemy import desc, select + + from src.audit import AuditAction, record_audit, resolve_actor_fields + from src.initiative_db.application_backup import build_backup_zipstream + from src.initiative_db.backup_naming import backup_zip_attachment_filename + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import ApplicationArtifact, ApplicationReviewDocument, User + from src.initiative_db.submissions import ( + _as_review_document_row, + resolve_submitted_initiative_for_backup, + submitted_applications_pairs_for_export, + ) + from src.minio.storage import _sanitize_filename, settings as s3_settings + from zipstream import ZipStream + + admin_uid = _require_admin_user(authorization) + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Sao lưu yêu cầu PostgreSQL.") + _ = page, pageSize + + outer = ZipStream() + used_inner_names: set[str] = set() + + def _unique_member_name(safe_fn: str) -> str: + base = safe_fn or "backup.zip" + if base not in used_inner_names: + used_inner_names.add(base) + return base + stem = base[:-4] if base.lower().endswith(".zip") else base + n = 2 + while True: + cand = f"{stem}_{n}.zip" + if cand not in used_inner_names: + used_inner_names.add(cand) + return cand + n += 1 + + packed_ids: List[str] = [] + async with get_session() as session: + try: + pairs = await submitted_applications_pairs_for_export( + session, + name=name, + author_name=authorName, + reviewer_name=reviewerName, + status=status, + review_status=reviewStatus, + date_from=dateFrom, + date_to=dateTo, + sort_by=sortBy, + sort_order=sortOrder, + lifecycle=lifecycle, + ) + except Exception: + logger.exception("GET /api/applications/export-backups (list) failed") + raise HTTPException( + status_code=503, + detail="Không thể tải danh sách hồ sơ để đóng gói sao lưu.", + ) from None + + if len(pairs) > BULK_APPLICATION_BACKUPS_MAX: + raise HTTPException( + status_code=413, + detail=f"Quá nhiều hồ sơ ({len(pairs)}). Tối đa {BULK_APPLICATION_BACKUPS_MAX} — vui lòng thu hẹp bộ lọc.", + ) + + for row, _payload in pairs: + application_id = str(row.get("id") or "").strip() + if not application_id: + continue + resolved = await resolve_submitted_initiative_for_backup(session, application_id) + if resolved is None: + continue + initiative, public_id = resolved + + arts = ( + await session.execute( + select(ApplicationArtifact).where(ApplicationArtifact.initiative_id == initiative.id) + ) + ).scalars().all() + + rd = ( + await session.execute( + select(ApplicationReviewDocument) + .where(ApplicationReviewDocument.initiative_id == initiative.id) + .order_by(desc(ApplicationReviewDocument.document_version)) + .limit(1) + ) + ).scalar_one_or_none() + review_json = _as_review_document_row(rd) if rd is not None else None + + owner = await session.get(User, initiative.owner_id) + inner_fn = backup_zip_attachment_filename( + owner_email=owner.email if owner is not None else None, + owner_full_name=owner.full_name if owner is not None else None, + public_application_id=public_id, + ) + member_name = _unique_member_name(inner_fn) + + inner_z = build_backup_zipstream( + settings=s3_settings, + initiative=initiative, + application_id=public_id, + case_code=initiative.case_code, + artifacts=list(arts), + review_doc_json=review_json, + owner_id=str(initiative.owner_id), + submitted_at=initiative.submitted_at.isoformat() + if initiative.submitted_at is not None + else None, + ) + outer.add(iter(inner_z), member_name) + packed_ids.append(public_id) + + if not packed_ids: + raise HTTPException( + status_code=404, + detail="Không có hồ sơ nào khớp bộ lọc để đóng gói sao lưu.", + ) + + actor_email, actor_role = await resolve_actor_fields(session, admin_uid) + await record_audit( + session, + actor_user_id=admin_uid, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.read, + entity_type="application_backup_bulk", + entity_id="bulk", + metadata={ + "outcome": "nested_zip_stream", + "packed_count": len(packed_ids), + "application_ids": packed_ids[:100], + "truncated_ids": len(packed_ids) > 100, + }, + ) + await session.commit() + + outer_fn = _sanitize_filename( + f"sang-kien-sao-luu-tong-hop-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}.zip" + ) or "sang-kien-sao-luu-tong-hop.zip" + return StreamingResponse( + outer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{outer_fn}"'}, + ) + + +@app.get("/api/applications/{application_id}") +async def get_application( + application_id: str, authorization: Optional[str] = Header(None) +): + """Single submitted application for review page (admin / applicant deep link).""" + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import User + from src.initiative_db.submissions import ( + _applicant_may_mutate_row, + _as_submission_item, + _resolve_initiative_and_latest_draft_for_application_id, + get_application_by_id, + ) + + uid = _require_authenticated_user(authorization) + + if is_postgres_enabled(): + try: + async with get_session() as session: + if not _is_staff_reviewer(authorization): + try: + initiative, draft = await _resolve_initiative_and_latest_draft_for_application_id( + session, application_id + ) + except LookupError as exc: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") from exc + payload = dict(draft.payload) if isinstance(draft.payload, dict) else {} + row = _as_submission_item(initiative, payload) + user = await session.get(User, uid) + email = str(user.email) if user is not None else "" + if not _applicant_may_mutate_row(initiative, row, uid, email): + raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") + row = await get_application_by_id(session, application_id) + if row is not None: + await _enrich_application_detail_full_pdf_presign(session, row) + return row + except HTTPException: + raise + except Exception: + logger.exception("application detail query (PostgreSQL) failed; refusing file index fallback while DB is configured") + raise HTTPException( + status_code=503, + detail="Không thể tải hồ sơ từ cơ sở dữ liệu. Vui lòng thử lại sau hoặc liên hệ quản trị.", + ) from None + + if not _is_staff_reviewer(authorization): + payload = decode_bearer_token(authorization) + token_email = str((payload or {}).get("email") or "").strip().lower() + row_check = _get_application_from_file_index(application_id) + if row_check is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") + auth_em = str((row_check.get("author") or {}).get("email") or "").strip().lower() + if not token_email or auth_em != token_email: + raise HTTPException(status_code=403, detail="Không có quyền xem hồ sơ này.") + + row = _get_application_from_file_index(application_id) + if row is not None: + return row + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") + + +@app.get("/api/applications/{application_id}/backup") +async def download_application_backup( + application_id: str, + authorization: Optional[str] = Header(None), +): + """ + Admin-only: stream a ZIP (manifest + submitted PDF + official DOCX/PDF + evidence + optional review JSON). + """ + from sqlalchemy import desc, select + + from src.audit import AuditAction, record_audit, resolve_actor_fields + from src.initiative_db.application_backup import build_backup_zipstream + from src.initiative_db.backup_naming import backup_zip_attachment_filename + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import ApplicationArtifact, ApplicationReviewDocument, User + from src.initiative_db.submissions import _as_review_document_row, resolve_submitted_initiative_for_backup + from src.minio.storage import settings as s3_settings + + admin_uid = _require_admin_user(authorization) + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Sao lưu yêu cầu PostgreSQL.") + + async with get_session() as session: + resolved = await resolve_submitted_initiative_for_backup(session, application_id) + if resolved is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.") + initiative, public_id = resolved + + arts = ( + await session.execute( + select(ApplicationArtifact).where(ApplicationArtifact.initiative_id == initiative.id) + ) + ).scalars().all() + + rd = ( + await session.execute( + select(ApplicationReviewDocument) + .where(ApplicationReviewDocument.initiative_id == initiative.id) + .order_by(desc(ApplicationReviewDocument.document_version)) + .limit(1) + ) + ).scalar_one_or_none() + review_json = _as_review_document_row(rd) if rd is not None else None + + actor_email, actor_role = await resolve_actor_fields(session, admin_uid) + await record_audit( + session, + actor_user_id=admin_uid, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.read, + entity_type="application_backup", + entity_id=public_id, + metadata={ + "outcome": "streaming_zip", + "initiative_id": str(initiative.id), + "case_code": initiative.case_code, + "artifact_count": len(arts), + }, + ) + await session.commit() + + owner = await session.get(User, initiative.owner_id) + safe_fn = backup_zip_attachment_filename( + owner_email=owner.email if owner is not None else None, + owner_full_name=owner.full_name if owner is not None else None, + public_application_id=public_id, + ) + + z = build_backup_zipstream( + settings=s3_settings, + initiative=initiative, + application_id=public_id, + case_code=initiative.case_code, + artifacts=list(arts), + review_doc_json=review_json, + owner_id=str(initiative.owner_id), + submitted_at=initiative.submitted_at.isoformat() + if initiative.submitted_at is not None + else None, + ) + return StreamingResponse( + z, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{safe_fn}"'}, + ) + + +@app.put("/api/applications/{application_id}") +async def update_submitted_application( + application_id: str, + body: UpdateSubmittedApplicationBody, + authorization: Optional[str] = Header(None), +): + """Update name and submitted date for the applicant's own submission (Postgres or file index).""" + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import User + from src.initiative_db.submissions import _parse_submitted_date_input, update_my_submitted_application + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để cập nhật hồ sơ.") + + if is_postgres_enabled(): + try: + async with get_session() as session: + user = await session.get(User, uid) + email = str(user.email) if user is not None else "" + return await update_my_submitted_application( + session, uid, email, application_id, body.name, body.submittedDate + ) + except LookupError: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") + except PermissionError: + raise HTTPException(status_code=403, detail="Không có quyền cập nhật hồ sơ này.") + except HTTPException: + raise + except Exception: + logger.exception("PUT /api/applications (PostgreSQL) failed") + raise HTTPException(status_code=500, detail="Không thể cập nhật hồ sơ") from None + + payload = decode_bearer_token(authorization) + token_email = str((payload or {}).get("email") or "").strip().lower() + items = _load_submitted_items() + idx = next((i for i, r in enumerate(items) if str(r.get("id")) == application_id), None) + if idx is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") + row = items[idx] + auth_em = str((row.get("author") or {}).get("email") or "").strip().lower() + if not token_email or auth_em != token_email: + raise HTTPException(status_code=403, detail="Không có quyền cập nhật hồ sơ này.") + try: + dt = _parse_submitted_date_input(body.submittedDate) + iso = dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + except Exception: + raise HTTPException(status_code=400, detail="Ngày nộp không hợp lệ.") + updated = {**row, "name": body.name.strip(), "submittedDate": iso} + if len(iso) >= 4 and iso[:4].isdigit(): + updated["calendarYear"] = int(iso[:4]) + items[idx] = updated + _save_submitted_items(items) + return items[idx] + + +@app.delete("/api/applications/{application_id}") +async def delete_submitted_application( + application_id: str, + authorization: Optional[str] = Header(None), +): + """Delete the applicant's own submission (Postgres cascade; file index removes row + PDF if present).""" + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import User + from src.initiative_db.submissions import delete_my_submitted_application + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xóa hồ sơ.") + + if is_postgres_enabled(): + try: + async with get_session() as session: + user = await session.get(User, uid) + email = str(user.email) if user is not None else "" + await delete_my_submitted_application(session, uid, email, application_id) + return {"deleted": True, "id": application_id} + except LookupError: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") + except PermissionError: + raise HTTPException(status_code=403, detail="Không có quyền xóa hồ sơ này.") + except HTTPException: + raise + except Exception: + logger.exception("DELETE /api/applications (PostgreSQL) failed") + raise HTTPException(status_code=500, detail="Không thể xóa hồ sơ") from None + + payload = decode_bearer_token(authorization) + token_email = str((payload or {}).get("email") or "").strip().lower() + items = _load_submitted_items() + idx = next((i for i, r in enumerate(items) if str(r.get("id")) == application_id), None) + if idx is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ") + row = items[idx] + auth_em = str((row.get("author") or {}).get("email") or "").strip().lower() + if not token_email or auth_em != token_email: + raise HTTPException(status_code=403, detail="Không có quyền xóa hồ sơ này.") + + files = row.get("files") or {} + ft = files.get("fullText") if isinstance(files.get("fullText"), dict) else None + url = str((ft or {}).get("url") or "") if ft else "" + if url.startswith("/submitted-initiatives/"): + fname = url.replace("/submitted-initiatives/", "").lstrip("/") + safe = "".join(c for c in fname if c.isalnum() or c in ("-", "_", ".")) + if safe: + pdf_path = SUBMITTED_INITIATIVES_DIR / safe + try: + if pdf_path.is_file(): + pdf_path.unlink() + except OSError: + logger.warning("Could not delete PDF file %s", pdf_path) + + items.pop(idx) + _save_submitted_items(items) + return {"deleted": True, "id": application_id} + + +@app.get("/api/conferences") +async def list_conference_filter_options(): + """ + Distinct hội nghị / đợt (from ``application_workflow.conference``) for council/admin list filters. + Returns a list of ``{id, name}`` objects for ``useLookupQuery`` / ``toOptionList``. + """ + from sqlalchemy import select + + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import ApplicationWorkflow + + if not is_postgres_enabled(): + return [] + try: + async with get_session() as session: + rows = (await session.execute(select(ApplicationWorkflow))).scalars().all() + seen: dict[str, dict] = {} + for wf in rows: + c = wf.conference + if not isinstance(c, dict): + continue + cid = str(c.get("id") or "").strip() + cname = str(c.get("name") or "").strip() + if not cname: + continue + opt_id = cid if cid else f"__name__:{cname}" + if opt_id not in seen: + seen[opt_id] = {"id": opt_id, "name": cname} + return sorted(seen.values(), key=lambda x: (str(x.get("name") or "").lower(), str(x.get("id") or ""))) + except Exception: + logger.exception("GET /api/conferences failed") + return [] + + +@app.get("/api/supervisors") +async def list_supervisor_filter_options(): + """Distinct supervisors from ``application_workflow.supervisor`` for dashboard filters.""" + from sqlalchemy import select + + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.models import ApplicationWorkflow + + if not is_postgres_enabled(): + return [] + try: + async with get_session() as session: + rows = (await session.execute(select(ApplicationWorkflow))).scalars().all() + seen: dict[str, dict] = {} + for wf in rows: + s = wf.supervisor + if not isinstance(s, dict): + continue + sid = str(s.get("id") or "").strip() + sname = str(s.get("name") or s.get("fullName") or "").strip() + if not sname: + continue + opt_id = sid if sid else f"__name__:{sname}" + if opt_id not in seen: + seen[opt_id] = {"id": opt_id, "name": sname} + return sorted(seen.values(), key=lambda x: (str(x.get("name") or "").lower(), str(x.get("id") or ""))) + except Exception: + logger.exception("GET /api/supervisors failed") + return [] + + +@app.get("/api/applications") +async def list_applications( + page: int = 1, + pageSize: int = 20, + name: str = "", + authorName: str = "", + reviewerName: str = "", + status: str = "", + reviewStatus: str = "", + dateFrom: str = "", + dateTo: str = "", + sortBy: str = "submittedDate", + sortOrder: str = "desc", + lifecycle: str = "", + authorization: Optional[str] = Header(None), +): + """ + Danh sách hồ sơ đã nộp (PDF) cho dashboard admin / hội đồng. + Dữ liệu lưu cục bộ qua POST /api/applications/submit. + """ + _require_staff_reviewer(authorization) + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.submissions import list_submitted_applications + + page = max(1, page) + page_size = max(1, min(100, pageSize)) + + if is_postgres_enabled(): + try: + async with get_session() as session: + return await list_submitted_applications( + session=session, + page=page, + page_size=page_size, + name=name, + author_name=authorName, + reviewer_name=reviewerName, + status=status, + review_status=reviewStatus, + date_from=dateFrom, + date_to=dateTo, + sort_by=sortBy, + sort_order=sortOrder, + lifecycle=lifecycle, + ) + except Exception: + logger.exception("application list query (PostgreSQL) failed; refusing file index fallback while DB is configured") + raise HTTPException( + status_code=503, + detail="Không thể tải danh sách hồ sơ từ cơ sở dữ liệu. Vui lòng thử lại sau hoặc liên hệ quản trị.", + ) from None + + items = _load_submitted_items() + + lc = (lifecycle or "").strip().lower() + + def match(row: Dict[str, Any], *, skip_status: bool = False) -> bool: + row_status = str(row.get("status") or "") + if lc == "inbox": + if row_status in ("approved", "rejected"): + return False + elif lc == "decided": + if row_status not in ("approved", "rejected"): + return False + n = name.strip().lower() + if n and n not in str(row.get("name") or "").lower(): + return False + an = authorName.strip().lower() + auth = row.get("author") or {} + if an and an not in str(auth.get("name") or "").lower(): + return False + rn = reviewerName.strip().lower() + if rn: + rev = row.get("reviewer") or {} + if rn not in str(rev.get("name") or "").lower(): + return False + if not skip_status and status and row_status != status: + return False + if reviewStatus and str(row.get("reviewStatus") or "") != reviewStatus: + return False + sd = row.get("submittedDate") + if dateFrom and sd: + sd_day = str(sd)[:10] + if len(sd_day) == 10 and sd_day < dateFrom: + return False + if dateTo and sd: + sd_day = str(sd)[:10] + if len(sd_day) == 10 and sd_day > dateTo: + return False + return True + + filtered_for_counts = [x for x in items if match(x, skip_status=True)] + status_counts = { + "approved": sum(1 for x in filtered_for_counts if str(x.get("status") or "") == "approved"), + "rejected": sum(1 for x in filtered_for_counts if str(x.get("status") or "") == "rejected"), + } + filtered = [x for x in items if match(x, skip_status=False)] + + reverse = sortOrder != "asc" + if sortBy == "name": + filtered.sort(key=lambda x: str(x.get("name") or ""), reverse=reverse) + elif sortBy == "author": + filtered.sort( + key=lambda x: str((x.get("author") or {}).get("name") or ""), + reverse=reverse, + ) + else: + filtered.sort(key=lambda x: str(x.get("submittedDate") or ""), reverse=reverse) + + total = len(filtered) + start = (page - 1) * page_size + page_data = filtered[start : start + page_size] + total_pages = max(1, (total + page_size - 1) // page_size) if total else 1 + + return { + "data": page_data, + "pagination": { + "page": page, + "pageSize": page_size, + "totalItems": total, + "totalPages": total_pages, + }, + "statusCounts": status_counts, + } + + +@app.get("/api/applications/{application_id}/admin-result") +async def get_application_admin_result( + application_id: str, + authorization: Optional[str] = Header(None), +): + """READ kết quả Duyệt/Từ chối do quản trị ghi nhận (theo ``applicationId``).""" + _require_admin_user(authorization) + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.application_admin_results import get_admin_result_for_application + + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL để đọc kết quả.") + async with get_session() as session: + row = await get_admin_result_for_application(session, application_id) + if row is None: + raise HTTPException(status_code=404, detail="Chưa có kết quả cho mã hồ sơ này.") + return row + + +@app.get("/api/notifications") +async def api_list_notifications( + page: int = 1, + pageSize: int = 20, + authorization: Optional[str] = Header(None), +): + """In-app inbox for the current user (applicant receives rows after admin adjudication).""" + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xem thông báo.") + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.user_notifications import list_notifications_for_user + + if not is_postgres_enabled(): + return { + "data": [], + "pagination": {"page": 1, "pageSize": max(1, min(100, pageSize)), "totalItems": 0, "totalPages": 1}, + } + async with get_session() as session: + return await list_notifications_for_user(session, uid, page=page, page_size=pageSize) + + +@app.get("/api/notifications/unread-count") +async def api_notifications_unread_count(authorization: Optional[str] = Header(None)): + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xem thông báo.") + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.user_notifications import count_unread_notifications + + if not is_postgres_enabled(): + return {"count": 0} + async with get_session() as session: + n = await count_unread_notifications(session, uid) + return {"count": n} + + +@app.patch("/api/notifications/{notification_id}/read") +async def api_mark_notification_read( + notification_id: str, + authorization: Optional[str] = Header(None), +): + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để xem thông báo.") + try: + nid = uuid.UUID(notification_id.strip()) + except ValueError: + raise HTTPException(status_code=404, detail="Không tìm thấy thông báo.") from None + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.user_notifications import mark_notification_read + + if not is_postgres_enabled(): + raise HTTPException(status_code=404, detail="Không tìm thấy thông báo.") + async with get_session() as session: + ok = await mark_notification_read(session, uid, nid) + if not ok: + raise HTTPException(status_code=404, detail="Không tìm thấy thông báo.") + return {"ok": True} + + +@app.post("/api/applications/{application_id}/admin-result") +async def create_application_admin_result( + application_id: str, + body: AdminApplicationResultBody, + authorization: Optional[str] = Header(None), +): + """CREATE kết quả — đồng bộ ``initiatives.status`` (approved / rejected).""" + admin_uid = _require_admin_user(authorization) + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.application_admin_results import create_admin_result + + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL để lưu kết quả.") + from src.initiative_db.user_notifications import best_effort_notify_applicant_after_admin_decision + + result: Optional[Dict[str, Any]] = None + try: + async with get_session() as session: + result = await create_admin_result( + session, + application_id, + admin_uid, + decision=body.decision, + feedback=body.feedback, + rationale=body.rationale, + ) + except LookupError: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ đã nộp với mã đã chọn.") from None + except ValueError as exc: + msg = str(exc) + if msg == "result_already_exists": + raise HTTPException( + status_code=409, + detail="Đã có kết quả cho hồ sơ này — dùng cập nhật hoặc xóa trước.", + ) from None + if msg == "invalid_decision": + raise HTTPException(status_code=422, detail="Quyết định không hợp lệ.") from None + raise HTTPException(status_code=400, detail=msg) from None + + await best_effort_notify_applicant_after_admin_decision(result) + return result + + +@app.put("/api/applications/{application_id}/admin-result") +async def update_application_admin_result( + application_id: str, + body: AdminApplicationResultBody, + authorization: Optional[str] = Header(None), +): + """Idempotent upsert: tạo hoặc cập nhật kết quả trong một yêu cầu (đồng bộ ``initiatives.status``).""" + admin_uid = _require_admin_user(authorization) + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.application_admin_results import upsert_admin_result + + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL để lưu kết quả.") + from src.initiative_db.user_notifications import best_effort_notify_applicant_after_admin_decision + + result: Optional[Dict[str, Any]] = None + try: + async with get_session() as session: + result = await upsert_admin_result( + session, + application_id, + admin_uid, + decision=body.decision, + feedback=body.feedback, + rationale=body.rationale, + ) + except LookupError: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ đã nộp với mã đã chọn.") from None + except ValueError as exc: + if str(exc) == "invalid_decision": + raise HTTPException(status_code=422, detail="Quyết định không hợp lệ.") from None + raise HTTPException(status_code=400, detail=str(exc)) from None + + await best_effort_notify_applicant_after_admin_decision(result) + return result + + +@app.delete("/api/applications/{application_id}/admin-result") +async def delete_application_admin_result( + application_id: str, + authorization: Optional[str] = Header(None), +): + """DELETE kết quả — trả ``initiatives.status`` về ``submitted``.""" + from src.initiative_db.engine import get_session, is_postgres_enabled + from src.initiative_db.application_admin_results import delete_admin_result + + admin_uid = _require_admin_user(authorization) + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL để xóa kết quả.") + try: + async with get_session() as session: + await delete_admin_result(session, application_id, actor_user_id=admin_uid) + except LookupError as exc: + if exc.args and exc.args[0] == "result_not_found": + raise HTTPException(status_code=404, detail="Chưa có kết quả để xóa.") from None + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ đã nộp với mã đã chọn.") from None + return {"deleted": True, "applicationId": application_id} + + +if __name__ == "__main__": + import uvicorn # type: ignore + uvicorn.run(app, host='0.0.0.0', port='4402', debug=True) \ No newline at end of file diff --git a/be0/migrations/001_initiative_schema.sql b/be0/migrations/001_initiative_schema.sql new file mode 100644 index 0000000..fd7bf95 --- /dev/null +++ b/be0/migrations/001_initiative_schema.sql @@ -0,0 +1,251 @@ +-- Initiative Recognition System — PostgreSQL schema (architecture_plan.md §4) +-- Table order respects FKs (units before users). + +CREATE EXTENSION IF NOT EXISTS citext; + +-- ========= ENUMS ========= +DO $$ BEGIN + CREATE TYPE user_role AS ENUM ('applicant','council_member','editor','admin','viewer'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE initiative_class AS ENUM ('technical','research','textbook'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE research_evidence AS ENUM ('international','domestic','poster'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE eval_level AS ENUM ('high','medium','low'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE submission_status AS ENUM ('draft','submitted','under_review','approved','rejected'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE recognition_tier AS ENUM ('excellent','good'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- ========= IDENTITY ========= +CREATE TABLE IF NOT EXISTS units ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + parent_id UUID REFERENCES units(id), + address TEXT +); + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email CITEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + full_name TEXT NOT NULL, + phone TEXT, + unit_id UUID REFERENCES units(id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS user_roles ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role user_role NOT NULL, + PRIMARY KEY (user_id, role) +); + +-- System user for anonymous draft saves (no login yet) +INSERT INTO users (id, email, password_hash, full_name) +VALUES ( + '00000000-0000-4000-8000-000000000001', + 'system@draft.local', + '-', + 'System (draft owner)' +) +ON CONFLICT (email) DO NOTHING; + +-- ========= CASE / INITIATIVE ROOT ========= +CREATE TABLE IF NOT EXISTS initiatives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_code TEXT UNIQUE NOT NULL, + owner_id UUID NOT NULL REFERENCES users(id), + status submission_status NOT NULL DEFAULT 'draft', + recognition_tier recognition_tier, + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_initiatives_owner_status ON initiatives(owner_id, status); + +-- ========= DRAFT SNAPSHOTS ========= +CREATE TABLE IF NOT EXISTS drafts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + draft_code TEXT UNIQUE NOT NULL, + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + payload JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_drafts_initiative ON drafts(initiative_id); + +-- ========= ĐƠN (APPLICATION) ========= +CREATE TABLE IF NOT EXISTS applications ( + initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE, + initiative_name TEXT NOT NULL, + investor_name TEXT, + application_field TEXT, + first_apply_date DATE, + initiative_classification initiative_class, + research_evidence_kind research_evidence, + international_journal_decl TEXT, + content_summary TEXT, + confidential_info TEXT, + conditions TEXT, + author_evaluation TEXT, + trial_evaluation TEXT, + submission_day SMALLINT, + submission_month SMALLINT, + submission_year SMALLINT, + honesty_confirmed BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT chk_first_apply_window + CHECK (first_apply_date IS NULL + OR first_apply_date BETWEEN DATE '2025-04-15' AND DATE '2026-04-15') +); + +CREATE TABLE IF NOT EXISTS authors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id), + ordinal SMALLINT NOT NULL, + full_name TEXT NOT NULL, + dob DATE, + workplace TEXT, + title TEXT, + qualification TEXT, + contribution_percent NUMERIC(5,2) NOT NULL, + is_representative BOOLEAN NOT NULL DEFAULT FALSE, + CHECK (contribution_percent >= 0 AND contribution_percent <= 100) +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_authors_repr ON authors(initiative_id) WHERE is_representative; + +CREATE TABLE IF NOT EXISTS support_staff ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + full_name TEXT, + dob DATE, + workplace TEXT, + title TEXT, + qualification TEXT, + support_content TEXT +); + +CREATE TABLE IF NOT EXISTS evidence_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('textbook','research','technical')), + storage_uri TEXT NOT NULL, + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT 'application/pdf', + byte_size BIGINT NOT NULL, + sha256 CHAR(64) NOT NULL, + uploaded_by UUID NOT NULL REFERENCES users(id), + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_kind ON evidence_files(initiative_id, kind); + +-- ========= BÁO CÁO (REPORT) ========= +CREATE TABLE IF NOT EXISTS reports ( + initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE, + introduction TEXT, + representative_phone TEXT, + representative_email TEXT, + current_status TEXT, + purpose TEXT, + implementation_steps TEXT, + first_applied_unit TEXT, + achieved_result TEXT, + novelty TEXT, + effectiveness JSONB NOT NULL DEFAULT '{}'::jsonb, + submission_date DATE +); + +CREATE TABLE IF NOT EXISTS trial_units ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + name TEXT NOT NULL, + address TEXT, + field TEXT, + ordinal SMALLINT +); + +-- ========= CONTRIBUTION CONFIRMATION ========= +CREATE TABLE IF NOT EXISTS contributions ( + initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE, + main_author TEXT NOT NULL, + position TEXT, + representative_percent NUMERIC(5,2), + submission_date TIMESTAMPTZ, + digital_signature_confirmed BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS contribution_participants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + full_name TEXT, + work_unit TEXT, + contribution_percent NUMERIC(5,2) +); + +-- ========= PHIẾU ĐÁNH GIÁ ========= +CREATE TABLE IF NOT EXISTS evaluations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + council_member_id UUID NOT NULL REFERENCES users(id), + position TEXT, + evaluation_date DATE NOT NULL, + novelty_level eval_level, + novelty_score SMALLINT, + novelty_comment TEXT, + effectiveness_level eval_level, + effectiveness_score SMALLINT, + effectiveness_comment TEXT, + total_score SMALLINT GENERATED ALWAYS AS + (COALESCE(novelty_score,0) + COALESCE(effectiveness_score,0)) STORED, + conclusion TEXT, + status submission_status NOT NULL DEFAULT 'draft', + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (novelty_score IS NULL OR (novelty_score BETWEEN 0 AND 40)), + CHECK (effectiveness_score IS NULL OR (effectiveness_score BETWEEN 0 AND 60)), + UNIQUE (initiative_id, council_member_id) +); +CREATE INDEX IF NOT EXISTS idx_eval_initiative ON evaluations(initiative_id); + +-- ========= ADMIN VERIFY ========= +CREATE TABLE IF NOT EXISTS verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + field_name TEXT NOT NULL, + content_hash CHAR(64) NOT NULL, + verified_by UUID NOT NULL REFERENCES users(id), + verified_at TIMESTAMPTZ NOT NULL DEFAULT now(), + result TEXT +); + +-- ========= AUDIT TRAIL ========= +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGSERIAL PRIMARY KEY, + actor_id UUID REFERENCES users(id), + action TEXT NOT NULL, + entity TEXT NOT NULL, + entity_id UUID NOT NULL, + diff JSONB, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity, entity_id); diff --git a/be0/migrations/002_application_storage_extensions.sql b/be0/migrations/002_application_storage_extensions.sql new file mode 100644 index 0000000..402a40b --- /dev/null +++ b/be0/migrations/002_application_storage_extensions.sql @@ -0,0 +1,71 @@ +-- Versioned tab payloads + immutable submit snapshots + workflow/taxonomy + artifact registry. +-- Apply on existing DBs: psql "$INITIATIVE_DATABASE_URL" -f migrations/002_application_storage_extensions.sql +-- (use sync driver URL, not asyncpg, for psql) + +-- ========= DRAFT TAB SNAPSHOTS (fe0: report | application | contribution) ========= +CREATE TABLE IF NOT EXISTS draft_tab_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + draft_id UUID REFERENCES drafts(id) ON DELETE SET NULL, + tab TEXT NOT NULL CHECK (tab IN ('report', 'application', 'contribution')), + tab_version INTEGER NOT NULL DEFAULT 1, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + source TEXT NOT NULL DEFAULT 'autosave', + captured_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_draft_tab_snapshots_init_tab_ver + ON draft_tab_snapshots (initiative_id, tab, tab_version DESC); +CREATE INDEX IF NOT EXISTS idx_draft_tab_snapshots_captured + ON draft_tab_snapshots (captured_at DESC); + +-- ========= SUBMIT SNAPSHOTS (immutable row per successful submit) ========= +CREATE TABLE IF NOT EXISTS application_submit_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + submission_record_id TEXT NOT NULL, + merged_tabs JSONB NOT NULL DEFAULT '{}'::jsonb, + submit_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + full_pdf_uri TEXT NOT NULL, + captured_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_submit_snapshots_init_time + ON application_submit_snapshots (initiative_id, captured_at DESC); + +-- ========= WORKFLOW / LIST PROJECTION (council fields) ========= +CREATE TABLE IF NOT EXISTS application_workflow ( + initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE, + review_status TEXT NOT NULL DEFAULT 'not_reviewed', + review_deadline DATE, + reviewer JSONB, + supervisor JSONB, + conference JSONB, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ========= TAXONOMY (subjectId, groupId, topicType from fe0 ApplicationItem) ========= +CREATE TABLE IF NOT EXISTS application_taxonomy ( + initiative_id UUID PRIMARY KEY REFERENCES initiatives(id) ON DELETE CASCADE, + subject_id TEXT NOT NULL DEFAULT '', + group_id TEXT NOT NULL DEFAULT '', + topic_type TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ========= ARTIFACTS (PDF + future abstract/poster URIs; complements evidence_files) ========= +CREATE TABLE IF NOT EXISTS application_artifacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ( + 'full_pdf', 'abstract', 'poster', + 'textbook_evidence', 'research_evidence', 'technical_evidence', 'other' + )), + storage_uri TEXT NOT NULL, + original_name TEXT, + mime_type TEXT NOT NULL DEFAULT 'application/pdf', + byte_size BIGINT, + sha256 CHAR(64), + uploaded_by UUID REFERENCES users(id), + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (initiative_id, role) +); +CREATE INDEX IF NOT EXISTS idx_application_artifacts_init ON application_artifacts (initiative_id); diff --git a/be0/migrations/003_review_documents.sql b/be0/migrations/003_review_documents.sql new file mode 100644 index 0000000..6126364 --- /dev/null +++ b/be0/migrations/003_review_documents.sql @@ -0,0 +1,22 @@ +-- Persist ReviewPanel JSON bundles (templateData + officialBieuMau + full trees) +-- Apply on existing DBs: +-- psql "$INITIATIVE_DATABASE_URL" -f migrations/003_review_documents.sql + +CREATE TABLE IF NOT EXISTS application_review_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + case_id TEXT NOT NULL, + document_version INTEGER NOT NULL DEFAULT 1, + official_bieu_mau JSONB NOT NULL DEFAULT '{}'::jsonb, + template_data JSONB, + full_bundle JSONB, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (initiative_id, document_version) +); + +CREATE INDEX IF NOT EXISTS idx_review_docs_initiative_time + ON application_review_documents (initiative_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_review_docs_case_time + ON application_review_documents (case_id, created_at DESC); + diff --git a/be0/migrations/004_application_admin_results.sql b/be0/migrations/004_application_admin_results.sql new file mode 100644 index 0000000..9eeecab --- /dev/null +++ b/be0/migrations/004_application_admin_results.sql @@ -0,0 +1,18 @@ +-- Admin-recorded adjudication outcome per initiative (linked to applicant application id API). +-- One row per initiative; CRUD via /api/applications/{applicationId}/admin-result + +CREATE TABLE IF NOT EXISTS application_admin_results ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + decision TEXT NOT NULL CHECK (decision IN ('approved','rejected')), + feedback TEXT NOT NULL DEFAULT '', + rationale TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + updated_by UUID REFERENCES users(id), + CONSTRAINT uq_application_admin_results_initiative UNIQUE (initiative_id) +); + +CREATE INDEX IF NOT EXISTS idx_application_admin_results_initiative + ON application_admin_results(initiative_id); diff --git a/be0/migrations/004_evidence_artifact_review.sql b/be0/migrations/004_evidence_artifact_review.sql new file mode 100644 index 0000000..4c02fd3 --- /dev/null +++ b/be0/migrations/004_evidence_artifact_review.sql @@ -0,0 +1,13 @@ +-- Evidence staff review (approve / reject) on application_artifacts — must match be0/src/initiative_db/models.py ApplicationArtifact +-- New DBs: loaded by docker-compose postgres init (04_...). +-- Existing DBs: run once, e.g. +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/004_evidence_artifact_review.sql +-- # or: psql "$INITIATIVE_DATABASE_URL" -f be0/migrations/004_evidence_artifact_review.sql + +ALTER TABLE application_artifacts + ADD COLUMN IF NOT EXISTS review_status TEXT, + ADD COLUMN IF NOT EXISTS reviewed_by UUID REFERENCES users (id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_application_artifacts_review + ON application_artifacts (initiative_id, review_status); diff --git a/be0/migrations/006_user_notifications.sql b/be0/migrations/006_user_notifications.sql new file mode 100644 index 0000000..eda4665 --- /dev/null +++ b/be0/migrations/006_user_notifications.sql @@ -0,0 +1,26 @@ +-- In-app notifications for applicants (admin adjudication → inbox). +-- Best-effort insert after PUT/POST admin-result; full text duplicated for read UX. + +CREATE TABLE IF NOT EXISTS user_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + recipient_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('admin_application_decision')), + title TEXT NOT NULL, + body TEXT NOT NULL, + application_id TEXT NOT NULL, + related_initiative_id UUID REFERENCES initiatives(id) ON DELETE SET NULL, + source_admin_result_id UUID REFERENCES application_admin_results(id) ON DELETE SET NULL, + decision TEXT NOT NULL CHECK (decision IN ('approved','rejected')), + merit_category_label TEXT, + feedback_text TEXT NOT NULL DEFAULT '', + rationale_text TEXT, + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS user_notifications_inbox_idx + ON user_notifications (recipient_user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS user_notifications_unread_idx + ON user_notifications (recipient_user_id) + WHERE read_at IS NULL; diff --git a/be0/migrations/007_user_roles_email_policy_admin.sql b/be0/migrations/007_user_roles_email_policy_admin.sql new file mode 100644 index 0000000..21e3248 --- /dev/null +++ b/be0/migrations/007_user_roles_email_policy_admin.sql @@ -0,0 +1,33 @@ +-- Policy-sourced admin rows: safe to drop when email leaves AUTH_ADMIN_EMAILS (app reconciliation). +-- Apply on existing DBs: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/007_user_roles_email_policy_admin.sql +-- Fresh docker-compose init: add this file as docker-entrypoint-initdb.d/07_*.sql + +ALTER TABLE user_roles ADD COLUMN IF NOT EXISTS admin_from_email_policy BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN user_roles.admin_from_email_policy IS + 'TRUE when admin was granted by email allow-list (AUTH_ADMIN_EMAILS). Reconciliation may DELETE this row if the user email is no longer in the list. FALSE preserves manually granted admin (future / exceptional).'; + +-- One-time cleanup: remove admin for addresses not in the default institutional allow-list +-- (must match default in auth_api._DEFAULT_POLICY_ADMIN_EMAILS when AUTH_ADMIN_EMAILS is unset). +DELETE FROM user_roles ur +USING users u +WHERE ur.user_id = u.id + AND ur.role::text = 'admin' + AND lower(u.email::text) NOT IN ( + 'thaontt@ump.edu.vn', + 'nltanh@ump.edu.vn', + 'ldbaochau@ump.edu.vn', + 'htchuong@ump.edu.vn' + ); + +UPDATE user_roles ur +SET admin_from_email_policy = TRUE +FROM users u +WHERE ur.user_id = u.id + AND ur.role::text = 'admin' + AND lower(u.email::text) IN ( + 'thaontt@ump.edu.vn', + 'nltanh@ump.edu.vn', + 'ldbaochau@ump.edu.vn', + 'htchuong@ump.edu.vn' + ); diff --git a/be0/migrations/008_audit_events.sql b/be0/migrations/008_audit_events.sql new file mode 100644 index 0000000..3ad294f --- /dev/null +++ b/be0/migrations/008_audit_events.sql @@ -0,0 +1,38 @@ +-- Unified append-only audit trail (see assets/docs/audit-log-implementation.md). +-- Application role should be granted INSERT, SELECT only (configure per deployment). + +DO $$ +BEGIN + CREATE TYPE audit_action AS ENUM ( + 'create', + 'read', + 'update', + 'delete', + 'login', + 'logout', + 'login_failed' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END +$$; + +CREATE TABLE IF NOT EXISTS audit_events ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + actor_email TEXT NOT NULL, + actor_role TEXT NOT NULL, + action audit_action NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT, + before JSONB, + after JSONB, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + request_id UUID +); + +CREATE INDEX IF NOT EXISTS idx_audit_actor_time ON audit_events (actor_user_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_events (entity_type, entity_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_action_time ON audit_events (action, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_metadata_gin ON audit_events USING gin (metadata); diff --git a/be0/migrations/009_backup_artifact_roles_storage_kind.sql b/be0/migrations/009_backup_artifact_roles_storage_kind.sql new file mode 100644 index 0000000..875f62c --- /dev/null +++ b/be0/migrations/009_backup_artifact_roles_storage_kind.sql @@ -0,0 +1,35 @@ +-- Backup / canonical storage: official printable DOCX+PDF roles + explicit storage_kind. +-- Apply: psql "$INITIATIVE_DATABASE_URL" -f migrations/009_backup_artifact_roles_storage_kind.sql + +ALTER TABLE application_artifacts DROP CONSTRAINT IF EXISTS application_artifacts_role_check; +ALTER TABLE application_artifacts ADD CONSTRAINT application_artifacts_role_check CHECK (role IN ( + 'full_pdf', + 'abstract', + 'poster', + 'textbook_evidence', + 'research_evidence', + 'technical_evidence', + 'other', + 'official_form_docx', + 'official_form_pdf' +)); + +ALTER TABLE application_artifacts + ADD COLUMN IF NOT EXISTS storage_kind TEXT; + +UPDATE application_artifacts SET storage_kind = CASE + WHEN storage_uri LIKE 'http://%' OR storage_uri LIKE 'https://%' THEN 'external_url' + WHEN storage_uri LIKE '/submitted-initiatives/%' THEN 'filesystem' + WHEN role IN ('research_evidence', 'textbook_evidence', 'technical_evidence') THEN 'minio_attachments' + ELSE 'minio_exports' +END +WHERE storage_kind IS NULL; + +ALTER TABLE application_artifacts DROP CONSTRAINT IF EXISTS application_artifacts_storage_kind_check; +ALTER TABLE application_artifacts ADD CONSTRAINT application_artifacts_storage_kind_check + CHECK (storage_kind IS NULL OR storage_kind IN ( + 'minio_exports', + 'minio_attachments', + 'filesystem', + 'external_url' + )); diff --git a/be0/migrations/010_user_staff_profiles.sql b/be0/migrations/010_user_staff_profiles.sql new file mode 100644 index 0000000..32a5710 --- /dev/null +++ b/be0/migrations/010_user_staff_profiles.sql @@ -0,0 +1,114 @@ +-- User staff profiles (1:1 with users) — HR / verification workflow +-- Apply: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/010_user_staff_profiles.sql + +DO $$ BEGIN + CREATE TYPE profile_verification_status AS ENUM ('draft', 'pending', 'verified', 'rejected'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS academic_titles ( + code TEXT PRIMARY KEY, + label_vi TEXT NOT NULL, + label_en TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE +); + +INSERT INTO academic_titles (code, label_vi, label_en, sort_order) VALUES + ('professor', 'Giáo sư', 'Professor', 10), + ('associate_professor', 'Phó Giáo sư', 'Associate Professor', 20), + ('doctor_sc', 'Tiến sĩ', 'Doctor of Science', 30), + ('bsckii', 'BSCKII', 'Specialist level II', 35), + ('bscki', 'BSCKI', 'Specialist level I', 36), + ('master', 'Thạc sĩ', 'Master', 40), + ('doctor_md', 'Bác sĩ', 'Physician', 45), + ('pharmacist', 'Dược sĩ', 'Pharmacist', 46), + ('bachelor', 'Cử nhân', 'Bachelor', 50), + ('other', 'Khác (ghi rõ)', 'Other (specify)', 100) +ON CONFLICT (code) DO NOTHING; + +CREATE TABLE IF NOT EXISTS user_staff_profiles ( + user_id UUID PRIMARY KEY + REFERENCES users(id) ON DELETE CASCADE, + + employee_id TEXT, + academic_title_code TEXT REFERENCES academic_titles(code), + academic_title_other TEXT, + unit_name_freetext TEXT, + job_title TEXT, + + profile_verification_status profile_verification_status + NOT NULL DEFAULT 'draft', + verification_submitted_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + verified_by_user_id UUID REFERENCES users(id), + rejection_reason TEXT, + + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT employee_id_shape + CHECK (employee_id IS NULL OR employee_id ~ '^[A-Z0-9-]{3,32}$'), + + CONSTRAINT academic_title_other_invariant CHECK ( + CASE + WHEN academic_title_code IS NULL THEN academic_title_other IS NULL + WHEN academic_title_code = 'other' THEN + academic_title_other IS NOT NULL AND length(trim(academic_title_other)) > 0 + ELSE academic_title_other IS NULL + END + ), + + CONSTRAINT verified_requires_metadata CHECK ( + profile_verification_status <> 'verified' + OR (verified_at IS NOT NULL AND verified_by_user_id IS NOT NULL) + ), + + CONSTRAINT rejected_requires_reason CHECK ( + profile_verification_status <> 'rejected' + OR (rejection_reason IS NOT NULL AND length(trim(rejection_reason)) > 0) + ), + + CONSTRAINT non_terminal_clears_verification CHECK ( + profile_verification_status NOT IN ('draft', 'pending') + OR (verified_at IS NULL AND verified_by_user_id IS NULL) + ), + + CONSTRAINT rejected_clears_verification_metadata CHECK ( + profile_verification_status <> 'rejected' + OR (verified_at IS NULL AND verified_by_user_id IS NULL) + ), + + CONSTRAINT verified_clears_rejection CHECK ( + profile_verification_status <> 'verified' + OR rejection_reason IS NULL + ), + + CONSTRAINT job_title_length CHECK ( + job_title IS NULL OR length(job_title) <= 120 + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS ix_usp_employee_id_unique + ON user_staff_profiles (employee_id) + WHERE employee_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS ix_usp_pending_queue + ON user_staff_profiles (verification_submitted_at) + WHERE profile_verification_status = 'pending'; + +CREATE INDEX IF NOT EXISTS ix_usp_verifier_activity + ON user_staff_profiles (verified_by_user_id, verified_at DESC) + WHERE verified_by_user_id IS NOT NULL; + +-- Backfill one row per existing user (draft, NULL fields) +INSERT INTO user_staff_profiles (user_id, profile_verification_status) +SELECT u.id, 'draft'::profile_verification_status +FROM users u +WHERE NOT EXISTS ( + SELECT 1 FROM user_staff_profiles p WHERE p.user_id = u.id +); + +COMMENT ON TABLE user_staff_profiles IS + 'Institutional staff profile and verification state; scalars only — no MinIO.'; diff --git a/be0/migrations/011_academic_titles_vn.sql b/be0/migrations/011_academic_titles_vn.sql new file mode 100644 index 0000000..eb53874 --- /dev/null +++ b/be0/migrations/011_academic_titles_vn.sql @@ -0,0 +1,19 @@ +-- Extend / refresh academic_titles for UMP staff profile dropdown (VN labels + BSCK codes). +-- Apply after 010: psql … -f be0/migrations/011_academic_titles_vn.sql + +INSERT INTO academic_titles (code, label_vi, label_en, sort_order, active) VALUES + ('professor', 'Giáo sư', 'Professor', 10, TRUE), + ('associate_professor', 'Phó Giáo sư', 'Associate Professor', 20, TRUE), + ('doctor_sc', 'Tiến sĩ', 'Doctor of Science', 30, TRUE), + ('bsckii', 'BSCKII', 'Specialist level II', 35, TRUE), + ('bscki', 'BSCKI', 'Specialist level I', 36, TRUE), + ('master', 'Thạc sĩ', 'Master', 40, TRUE), + ('doctor_md', 'Bác sĩ', 'Physician', 45, TRUE), + ('pharmacist', 'Dược sĩ', 'Pharmacist', 46, TRUE), + ('bachelor', 'Cử nhân', 'Bachelor', 50, TRUE), + ('other', 'Khác (ghi rõ)', 'Other (specify)', 100, TRUE) +ON CONFLICT (code) DO UPDATE SET + label_vi = EXCLUDED.label_vi, + label_en = EXCLUDED.label_en, + sort_order = EXCLUDED.sort_order, + active = EXCLUDED.active; diff --git a/be0/migrations/012_password_reset.sql b/be0/migrations/012_password_reset.sql new file mode 100644 index 0000000..cbe7f6c --- /dev/null +++ b/be0/migrations/012_password_reset.sql @@ -0,0 +1,19 @@ +-- Password reset tokens + JWT credential invalidation (see auth_api, auth_credential_middleware). +-- Apply: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/012_password_reset.sql + +ALTER TABLE users ADD COLUMN IF NOT EXISTS credential_version INTEGER NOT NULL DEFAULT 0; + +COMMENT ON COLUMN users.credential_version IS + 'Incremented on password change/reset. JWT ''cv'' claim must match or token is rejected.'; + +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at); diff --git a/be0/migrations/013_email_verification.sql b/be0/migrations/013_email_verification.sql new file mode 100644 index 0000000..89cedd5 --- /dev/null +++ b/be0/migrations/013_email_verification.sql @@ -0,0 +1,21 @@ +-- Email verification before login (see auth_api deliver_email_verification_email). +-- Apply: docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/013_email_verification.sql + +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE; + +COMMENT ON COLUMN users.email_verified IS + 'FALSE until user confirms institutional inbox via email link; login and API tokens require TRUE.'; + +CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user_id ON email_verification_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens(expires_at); diff --git a/be0/migrations/014_registration_otp.sql b/be0/migrations/014_registration_otp.sql new file mode 100644 index 0000000..6c2f45d --- /dev/null +++ b/be0/migrations/014_registration_otp.sql @@ -0,0 +1,20 @@ +-- Registration email verification via 6-digit OTP (replaces magic-link issuance on register). +-- Apply after 013_email_verification.sql: +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/014_registration_otp.sql + +CREATE TABLE IF NOT EXISTS registration_otp_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + otp_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + failed_attempts INT NOT NULL DEFAULT 0, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_registration_otp_codes_user_pending + ON registration_otp_codes (user_id) + WHERE used_at IS NULL; + +COMMENT ON TABLE registration_otp_codes IS + 'Hashed 6-digit OTP for register verification; pending rows deleted when superseded by resend.'; diff --git a/be0/migrations/015_document_templates.sql b/be0/migrations/015_document_templates.sql new file mode 100644 index 0000000..e162383 --- /dev/null +++ b/be0/migrations/015_document_templates.sql @@ -0,0 +1,24 @@ +-- Admin-managed document templates: a .docx (stored in MinIO bucket initiative-templates) +-- plus its extracted Jinja placeholder fields. Applicants render a filled PDF by template id. +-- Apply after 014_registration_otp.sql: +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/015_document_templates.sql + +CREATE TABLE IF NOT EXISTS document_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + storage_key TEXT NOT NULL, + original_filename TEXT, + content_sha256 TEXT, + fields JSONB NOT NULL DEFAULT '[]'::jsonb, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_document_templates_active + ON document_templates (is_active, created_at DESC); + +COMMENT ON TABLE document_templates IS + 'Admin-managed DOCX templates (file in MinIO initiative-templates) with extracted Jinja placeholder fields. Applicants render filled PDFs by template id.'; diff --git a/be0/migrations/016_research_projects.sql b/be0/migrations/016_research_projects.sql new file mode 100644 index 0000000..1031001 --- /dev/null +++ b/be0/migrations/016_research_projects.sql @@ -0,0 +1,133 @@ +-- Research-project proposals (Thuyết minh đề tài, Mẫu III.06-TM.ĐTUD) + the PI "cockpit" entities. +-- A proposal row IS the project across its lifecycle: draft -> submitted -> approved | rejected. +-- On approval the cockpit unlocks; child tables (members/datasets/models/assets/milestones) hang off it. +-- Owner+admin authz (v1): a project is owned by owner_user_id; admins may review/approve/reject. +-- Apply after 015_document_templates.sql: +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/016_research_projects.sql + +CREATE TABLE IF NOT EXISTS research_projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','submitted','approved','rejected')), + code TEXT, + title TEXT NOT NULL DEFAULT '', + level TEXT NOT NULL DEFAULT '', + pi_name TEXT NOT NULL DEFAULT '', + period_months INTEGER, + budget_total NUMERIC(14,2), + content JSONB NOT NULL DEFAULT '{}'::jsonb, + submitted_at TIMESTAMPTZ, + reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + review_note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_research_projects_owner ON research_projects (owner_user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_research_projects_status ON research_projects (status, created_at DESC); + +CREATE TABLE IF NOT EXISTS research_project_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT '', + access TEXT NOT NULL DEFAULT '', + org TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + months INTEGER, + tasks TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_research_project_members_project ON research_project_members (project_id, sort_order); + +CREATE TABLE IF NOT EXISTS research_project_datasets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT '', + records INTEGER, + source TEXT NOT NULL DEFAULT '', + sensitivity TEXT NOT NULL DEFAULT '', + ethics TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_research_project_datasets_project ON research_project_datasets (project_id, sort_order); + +CREATE TABLE IF NOT EXISTS research_project_models ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + task TEXT NOT NULL DEFAULT '', + framework TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL DEFAULT '', + dataset TEXT NOT NULL DEFAULT '', + auc NUMERIC(6,4), + sensitivity NUMERIC(6,4), + specificity NUMERIC(6,4), + accuracy NUMERIC(6,4), + owner TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_research_project_models_project ON research_project_models (project_id, sort_order); + +CREATE TABLE IF NOT EXISTS research_project_assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + acquisition TEXT NOT NULL DEFAULT '', + value NUMERIC(14,2), + owner TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_research_project_assets_project ON research_project_assets (project_id, sort_order); + +CREATE TABLE IF NOT EXISTS research_project_milestones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL DEFAULT '', + deliverable TEXT NOT NULL DEFAULT '', + start_period TEXT NOT NULL DEFAULT '', + end_period TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL DEFAULT '', + budget NUMERIC(14,2), + progress INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_research_project_milestones_project ON research_project_milestones (project_id, sort_order); + +CREATE TABLE IF NOT EXISTS research_project_audit ( + id BIGSERIAL PRIMARY KEY, + project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + actor_name TEXT NOT NULL DEFAULT '', + role_label TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + subject TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_research_project_audit_project ON research_project_audit (project_id, occurred_at DESC); + +COMMENT ON TABLE research_projects IS + 'Research-project proposals (Thuyet minh de tai) that become managed projects on approval. Owner and admin authz. Content JSONB holds the full proposal form. Child research_project_* tables hold cockpit entities.'; diff --git a/be0/migrations/017_imagehub_datasets.sql b/be0/migrations/017_imagehub_datasets.sql new file mode 100644 index 0000000..139bd07 --- /dev/null +++ b/be0/migrations/017_imagehub_datasets.sql @@ -0,0 +1,76 @@ +-- ImageHub: content-addressed imaging dataset versioning (milestone 1 walking skeleton). +-- A dataset is owned by a user (investigator/PI). Files are stored as content-addressed, +-- globally deduped blobs in MinIO (one imagehub_blobs row per distinct sha256). The current +-- working file set lives in imagehub_dataset_files; a version freezes a manifest snapshot. +-- Admin sees all datasets (clinical data repository); owners see their own (research data). +-- Apply after 016_research_projects.sql: +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/017_imagehub_datasets.sql + +CREATE TABLE IF NOT EXISTS imagehub_datasets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private','internal','public')), + modality_tags JSONB NOT NULL DEFAULT '[]'::jsonb, + default_branch TEXT NOT NULL DEFAULT 'main', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_imagehub_datasets_owner ON imagehub_datasets (owner_user_id, created_at DESC); + +-- Globally content-addressed blob registry: identical bytes across datasets dedupe to one row. +CREATE TABLE IF NOT EXISTS imagehub_blobs ( + sha256 TEXT PRIMARY KEY, + size_bytes BIGINT NOT NULL DEFAULT 0, + media_type TEXT NOT NULL DEFAULT 'application/octet-stream', + storage_bucket TEXT NOT NULL DEFAULT '', + storage_key TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Current working file set on a dataset default branch (one row per logical path). +CREATE TABLE IF NOT EXISTS imagehub_dataset_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE, + logical_path TEXT NOT NULL DEFAULT '', + blob_sha256 TEXT NOT NULL REFERENCES imagehub_blobs(sha256) ON DELETE RESTRICT, + size_bytes BIGINT NOT NULL DEFAULT 0, + media_type TEXT NOT NULL DEFAULT 'application/octet-stream', + imaging_meta JSONB NOT NULL DEFAULT '{}'::jsonb, + uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_files_path ON imagehub_dataset_files (dataset_id, logical_path); + +-- Frozen version snapshots (the versioning spine; DAG-ready via parent_version_id). +CREATE TABLE IF NOT EXISTS imagehub_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE, + seq INTEGER NOT NULL DEFAULT 1, + message TEXT NOT NULL DEFAULT '', + manifest JSONB NOT NULL DEFAULT '[]'::jsonb, + parent_version_id UUID REFERENCES imagehub_versions(id) ON DELETE SET NULL, + author_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_versions_seq ON imagehub_versions (dataset_id, seq); + +-- Append-only audit trail per dataset. +CREATE TABLE IF NOT EXISTS imagehub_dataset_audit ( + id BIGSERIAL PRIMARY KEY, + dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + actor_name TEXT NOT NULL DEFAULT '', + role_label TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + subject TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_audit_dataset ON imagehub_dataset_audit (dataset_id, occurred_at DESC); + +COMMENT ON TABLE imagehub_datasets IS + 'ImageHub content-addressed imaging datasets. Owner and admin authz. Files dedupe into imagehub_blobs by sha256 — imagehub_versions freezes a manifest snapshot.'; diff --git a/be0/migrations/018_imagehub_segmentation_links.sql b/be0/migrations/018_imagehub_segmentation_links.sql new file mode 100644 index 0000000..38f1b37 --- /dev/null +++ b/be0/migrations/018_imagehub_segmentation_links.sql @@ -0,0 +1,21 @@ +-- ImageHub: link organ-segmentation masks to their parent image file (Phase D). +-- A mask file (file_kind='segmentation') points at the image it segments via a +-- self-referential parent_file_id (e.g. an organ mask of ct.nii.gz); organ_label +-- names the organ. Regular files stay file_kind='image'. Idempotent (ADD COLUMN IF +-- NOT EXISTS) so the startup runner can apply it to volumes that predate it. +-- Apply after 017_imagehub_datasets.sql (no semicolons inside comments — the runner +-- splitter is naive): +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/018_imagehub_segmentation_links.sql + +ALTER TABLE imagehub_dataset_files + ADD COLUMN IF NOT EXISTS file_kind TEXT NOT NULL DEFAULT 'image' CHECK (file_kind IN ('image','segmentation')); + +ALTER TABLE imagehub_dataset_files + ADD COLUMN IF NOT EXISTS parent_file_id UUID REFERENCES imagehub_dataset_files(id) ON DELETE CASCADE; + +ALTER TABLE imagehub_dataset_files + ADD COLUMN IF NOT EXISTS organ_label TEXT NOT NULL DEFAULT ''; + +-- List all masks of an image efficiently. +CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_files_parent + ON imagehub_dataset_files (parent_file_id); diff --git a/be0/migrations/019_imagehub_cloud_import.sql b/be0/migrations/019_imagehub_cloud_import.sql new file mode 100644 index 0000000..20b6454 --- /dev/null +++ b/be0/migrations/019_imagehub_cloud_import.sql @@ -0,0 +1,53 @@ +-- ImageHub: Cloud Import — storage methods + external (referenced, not copied) dataset files. +-- A storage method holds verified credentials (config_encrypted, never returned to the client) +-- for an external bucket (S3/GCS/Azure). A dataset file is then EITHER a local content-addressed +-- blob (blob_sha256 set) OR an external reference (storage_method_id + external_path set) that +-- streams from the bucket and is never copied to our servers (privacy rule C4). Idempotent +-- (CREATE/ADD ... IF NOT EXISTS) so the startup runner can apply it to volumes that predate it. +-- Apply after 018 (no semicolons inside comments or string literals — the runner splitter is naive): +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/019_imagehub_cloud_import.sql + +CREATE TABLE IF NOT EXISTS imagehub_storage_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + provider TEXT NOT NULL CHECK (provider IN ('s3','gcs','azure')), + access_mode TEXT NOT NULL DEFAULT 'read' CHECK (access_mode IN ('read','readwrite')), + bucket TEXT NOT NULL, + region TEXT, + config_encrypted TEXT NOT NULL, + verification_status TEXT NOT NULL DEFAULT 'pending' CHECK (verification_status IN ('pending','verified','failed')), + verification_reason TEXT, + verification_checked_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_imagehub_storage_methods_owner + ON imagehub_storage_methods (owner_id); + +-- Allow a dataset file to be an external reference instead of a local blob. Existing rows keep +-- blob_sha256 set and the new columns NULL, so they satisfy the local-blob branch of the CHECK. +ALTER TABLE imagehub_dataset_files + ALTER COLUMN blob_sha256 DROP NOT NULL; + +ALTER TABLE imagehub_dataset_files + ADD COLUMN IF NOT EXISTS storage_method_id UUID REFERENCES imagehub_storage_methods(id) ON DELETE RESTRICT; + +ALTER TABLE imagehub_dataset_files + ADD COLUMN IF NOT EXISTS external_path TEXT; + +-- A file is EITHER a local content-addressed blob OR an external reference, never both or neither. +ALTER TABLE imagehub_dataset_files + DROP CONSTRAINT IF EXISTS ck_imagehub_file_storage_mode; + +ALTER TABLE imagehub_dataset_files + ADD CONSTRAINT ck_imagehub_file_storage_mode CHECK ( + (blob_sha256 IS NOT NULL AND storage_method_id IS NULL AND external_path IS NULL) + OR + (blob_sha256 IS NULL AND storage_method_id IS NOT NULL AND external_path IS NOT NULL) + ); + +CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_files_storage_method + ON imagehub_dataset_files (storage_method_id); diff --git a/be0/migrations/020_imagehub_dataset_stages.sql b/be0/migrations/020_imagehub_dataset_stages.sql new file mode 100644 index 0000000..d295673 --- /dev/null +++ b/be0/migrations/020_imagehub_dataset_stages.sql @@ -0,0 +1,26 @@ +-- ImageHub: labeling-pipeline stages on a dataset (Label -> Review_1 -> Review_2 ...). Each stage +-- has a kind (label/review), an order (seq), an optional review_percent (review stages only), and +-- an auto_assign flag (the "Automatic Task Assignment" toggle). Idempotent (CREATE ... IF NOT +-- EXISTS) so the startup runner can apply it to volumes that predate it. Apply after 019 (no +-- semicolons inside comments or string literals — the runner splitter is naive): +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/020_imagehub_dataset_stages.sql + +CREATE TABLE IF NOT EXISTS imagehub_dataset_stages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'label' CHECK (kind IN ('label','review')), + seq INTEGER NOT NULL DEFAULT 0, + review_percent INTEGER CHECK (review_percent IS NULL OR (review_percent >= 0 AND review_percent <= 100)), + auto_assign BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Stages of a dataset, in pipeline order. +CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_stages_dataset + ON imagehub_dataset_stages (dataset_id, seq); + +-- A stage name is unique within its dataset. +CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_stages_name + ON imagehub_dataset_stages (dataset_id, name); diff --git a/be0/migrations/021_imagehub_task_pipeline.sql b/be0/migrations/021_imagehub_task_pipeline.sql new file mode 100644 index 0000000..7cba7ad --- /dev/null +++ b/be0/migrations/021_imagehub_task_pipeline.sql @@ -0,0 +1,37 @@ +-- ImageHub: per-file work TASKS that flow through a dataset's pipeline stages (single-user MVP). +-- A task is a NEW join row (one per dataset file) carrying its pipeline position (current_stage_id +-- + pipeline_state), per-user queue status, assignee, priority, and the Ground-Truth reference flag. +-- The file row itself (imagehub_dataset_files) stays a pure storage record. Membership / multi-labeler +-- assignment is a later phase, so for now task access reuses the dataset owner-or-admin gate. +-- Idempotent (CREATE ... IF NOT EXISTS) so the startup runner can apply it to volumes that predate it. +-- Apply after 020 (no semicolons inside comments or string literals — the runner splitter is naive): +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/021_imagehub_task_pipeline.sql + +CREATE TABLE IF NOT EXISTS imagehub_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE, + dataset_file_id UUID NOT NULL REFERENCES imagehub_dataset_files(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + current_stage_id UUID REFERENCES imagehub_dataset_stages(id) ON DELETE SET NULL, + pipeline_state TEXT NOT NULL DEFAULT 'inLabel' CHECK (pipeline_state IN ('inLabel','inReview','groundTruth','issue')), + queue_status TEXT NOT NULL DEFAULT 'assigned' CHECK (queue_status IN ('assigned','saved','pendingFinalization','skipped')), + assignee_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + assignment_mode TEXT NOT NULL DEFAULT 'auto' CHECK (assignment_mode IN ('auto','manual')), + priority DOUBLE PRECISION NOT NULL DEFAULT 0 CHECK (priority >= 0 AND priority <= 1), + is_reference_standard BOOLEAN NOT NULL DEFAULT FALSE, + skipped_seq BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- One task per file (MVP simplification — droppable later for multi-task-per-file). +CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_tasks_file + ON imagehub_tasks (dataset_file_id); + +-- Queue scan: a dataset's tasks at a given stage and status, highest priority first. +CREATE INDEX IF NOT EXISTS idx_imagehub_tasks_queue + ON imagehub_tasks (dataset_id, current_stage_id, queue_status, priority DESC); + +-- A user's personal labeling queue across datasets. +CREATE INDEX IF NOT EXISTS idx_imagehub_tasks_assignee + ON imagehub_tasks (assignee_user_id, queue_status); diff --git a/be0/migrations/022_imagehub_task_annotations.sql b/be0/migrations/022_imagehub_task_annotations.sql new file mode 100644 index 0000000..e7bd10e --- /dev/null +++ b/be0/migrations/022_imagehub_task_annotations.sql @@ -0,0 +1,8 @@ +-- ImageHub: a task's labeler annotations (bbox / points / pen / brush / polygon) stored as JSON. +-- The shared viewer's annotation overlay emits normalized [0..1] vector geometry per slice — small +-- JSON, persisted on the task so the AnnotationTool can load + save a labeler's work. Idempotent +-- (ADD COLUMN IF NOT EXISTS) so the startup runner can apply it to volumes that predate it. Apply +-- after 021 (no semicolons inside comments or string literals — the runner splitter is naive): +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/022_imagehub_task_annotations.sql + +ALTER TABLE imagehub_tasks ADD COLUMN IF NOT EXISTS annotations JSONB NOT NULL DEFAULT '[]'::jsonb; diff --git a/be0/migrations/023_imagehub_dataset_members.sql b/be0/migrations/023_imagehub_dataset_members.sql new file mode 100644 index 0000000..5badfa5 --- /dev/null +++ b/be0/migrations/023_imagehub_dataset_members.sql @@ -0,0 +1,23 @@ +-- ImageHub: dataset membership — lets users other than the owner work a dataset's tasks +-- (multi-labeler). MVP treats all members as labelers: they view the dataset and work tasks +-- assigned to them, while dataset / stage / settings management stays with the owner + platform +-- admins. The role column is reserved for a future project-admin tier. Idempotent. Apply after 022 +-- (no semicolons inside comments or string literals — the runner splitter is naive): +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/023_imagehub_dataset_members.sql + +CREATE TABLE IF NOT EXISTS imagehub_dataset_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('project_admin','member')), + added_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- One membership per user per dataset. +CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_members_user + ON imagehub_dataset_members (dataset_id, user_id); + +-- "Datasets I am a member of" lookup (the member's dataset list). +CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_members_user + ON imagehub_dataset_members (user_id); diff --git a/be0/migrations/024_imagehub_dataset_project_link.sql b/be0/migrations/024_imagehub_dataset_project_link.sql new file mode 100644 index 0000000..c6c61b4 --- /dev/null +++ b/be0/migrations/024_imagehub_dataset_project_link.sql @@ -0,0 +1,13 @@ +-- ImageHub: link a dataset to a research project (the "workspace" superstructure). Nullable, +-- so existing datasets stay unlinked and a dataset can still exist standalone. A dataset created +-- from a project cockpit attaches to that project. ON DELETE SET NULL so deleting a project +-- orphans its datasets rather than dropping the imaging data. Idempotent. Apply after 023 +-- (no semicolons inside comments or string literals — the runner splitter is naive): +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/024_imagehub_dataset_project_link.sql + +ALTER TABLE imagehub_datasets + ADD COLUMN IF NOT EXISTS research_project_id UUID REFERENCES research_projects(id) ON DELETE SET NULL; + +-- "Datasets in this project" lookup (the project-scoped dataset list). +CREATE INDEX IF NOT EXISTS idx_imagehub_datasets_research_project + ON imagehub_datasets (research_project_id); diff --git a/be0/migrations/025_imagehub_task_review_events.sql b/be0/migrations/025_imagehub_task_review_events.sql new file mode 100644 index 0000000..3bb91c9 --- /dev/null +++ b/be0/migrations/025_imagehub_task_review_events.sql @@ -0,0 +1,25 @@ +-- ImageHub: structured review decisions. The task pipeline applies accept/acceptWithCorrections/ +-- reject moves, but until now the verdict survived only as a free-text Vietnamese audit string — +-- not queryable, no reviewer/stage FK, no reject reason. This append-only table records every +-- review decision so review history + per-reviewer accept/reject counters become real. Idempotent. +-- Apply after 024 (no semicolons inside comments or string literals — the runner splitter is naive): +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/025_imagehub_task_review_events.sql + +CREATE TABLE IF NOT EXISTS imagehub_task_review_events ( + id BIGSERIAL PRIMARY KEY, + dataset_id UUID NOT NULL REFERENCES imagehub_datasets(id) ON DELETE CASCADE, + task_id UUID NOT NULL REFERENCES imagehub_tasks(id) ON DELETE CASCADE, + stage_id UUID REFERENCES imagehub_dataset_stages(id) ON DELETE SET NULL, + reviewer_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + decision TEXT NOT NULL CHECK (decision IN ('accept','acceptWithCorrections','reject')), + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Per-reviewer counters over a date window (the productivity panel query). +CREATE INDEX IF NOT EXISTS idx_imagehub_review_events_reviewer + ON imagehub_task_review_events (dataset_id, reviewer_user_id, created_at); + +-- A task's review history (chronological). +CREATE INDEX IF NOT EXISTS idx_imagehub_review_events_task + ON imagehub_task_review_events (task_id, created_at); diff --git a/be0/migrations/026_imagehub_file_folder_path.sql b/be0/migrations/026_imagehub_file_folder_path.sql new file mode 100644 index 0000000..ab5cf80 --- /dev/null +++ b/be0/migrations/026_imagehub_file_folder_path.sql @@ -0,0 +1,21 @@ +-- ImageHub: persist the relative folder path of each uploaded file (Option B — real folders inside +-- a dataset). Until now logical_path was basename-flattened, so an uploaded directory structure +-- (e.g. the nnU-Net imagesTr/labelsTr layout) was lost once files reached MinIO. folder_path keeps +-- the relative directory so the dataset browser can render a real folder tree and the structure +-- round-trips. The working-file natural key moves from (dataset_id, logical_path) to +-- (dataset_id, folder_path, logical_path) so two files sharing a basename in different folders no +-- longer collide and silently merge. Existing rows default folder_path to the empty string, so the +-- new key stays unique wherever the old one was. Idempotent. +-- Apply after 025 (no semicolons inside comments or string literals — the runner splitter is naive): +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/026_imagehub_file_folder_path.sql + +ALTER TABLE imagehub_dataset_files + ADD COLUMN IF NOT EXISTS folder_path TEXT NOT NULL DEFAULT ''; + +DROP INDEX IF EXISTS uq_imagehub_dataset_files_path; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_imagehub_dataset_files_folder_path + ON imagehub_dataset_files (dataset_id, folder_path, logical_path); + +CREATE INDEX IF NOT EXISTS idx_imagehub_dataset_files_folder + ON imagehub_dataset_files (dataset_id, folder_path); diff --git a/be0/migrations/027_imagehub_dataset_label_map.sql b/be0/migrations/027_imagehub_dataset_label_map.sql new file mode 100644 index 0000000..b671b8b --- /dev/null +++ b/be0/migrations/027_imagehub_dataset_label_map.sql @@ -0,0 +1,12 @@ +-- ImageHub: per-dataset value to name label map for multi-label segmentation masks. A multi-label +-- labelsTr/.nii.gz encodes each organ or structure as an integer voxel value (1, 2, 3 …). Until +-- now the viewer named those values from a fixed TotalSegmentator-v2 117-class map, so a non +-- TotalSegmentator dataset (KiTS = 1 kidney / 2 tumor / 3 cyst, or any custom nnU-Net labels) showed +-- confidently-wrong organ names. label_map stores the dataset own value to name mapping (a JSON object +-- with string keys), so the organ panel labels each overlay correctly and a user can edit them. The +-- empty default keeps the TotalSegmentator fallback for datasets without a map. Idempotent. +-- Apply after 026 (no semicolons inside comments or string literals — the runner splitter is naive): +-- docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/027_imagehub_dataset_label_map.sql + +ALTER TABLE imagehub_datasets + ADD COLUMN IF NOT EXISTS label_map JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/be0/requirements-dev.txt b/be0/requirements-dev.txt new file mode 100644 index 0000000..60d3c00 --- /dev/null +++ b/be0/requirements-dev.txt @@ -0,0 +1,6 @@ +# Test-only dependencies for CI (not installed in the runtime image). +# be0 tests are a mix of unittest.TestCase (incl. IsolatedAsyncioTestCase) and +# pytest-style; pytest runs both. pytest-asyncio covers the pytest async tests. +-r requirements.txt +pytest>=8,<9 +pytest-asyncio>=0.23,<0.24 diff --git a/be0/requirements.txt b/be0/requirements.txt new file mode 100644 index 0000000..63a66a3 --- /dev/null +++ b/be0/requirements.txt @@ -0,0 +1,43 @@ +uvicorn[standard] +httpx +sqlalchemy[asyncio]>=2.0 +asyncpg>=0.29 +greenlet>=3.0 +argon2-cffi>=23.1.0 +PyJWT>=2.8.0 +ollama +fastapi +asyncio +python-multipart + +langchain +langchain-core +langgraph + +langchain-community +sentence-transformers +huggingface +scikit-learn + +neo4j + +nltk +rake-nltk +pypdf +pydantic +pydantic-settings +aioboto3 +zipstream-ng +boto3 +numpy +pandas + +pyvi +docling +pymupdf +docxtpl>=0.16 +openpyxl>=3.1.0 + +# ImageHub: best-effort imaging metadata sniff (DICOM / NIfTI). See src/imagehub_routes.py. +pydicom +nibabel \ No newline at end of file diff --git a/be0/scripts/__pycache__/apply_initiative_migrations.cpython-313.pyc b/be0/scripts/__pycache__/apply_initiative_migrations.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3be66ad22d7a58beede56ae70c62bdb05a0208ec GIT binary patch literal 7359 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 literal 0 HcmV?d00001 diff --git a/be0/scripts/__pycache__/repair_split_submission.cpython-313.pyc b/be0/scripts/__pycache__/repair_split_submission.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..64eb3f44b3ca4682aeaa80819ed99d543d85a3b5 GIT binary patch literal 4692 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 literal 0 HcmV?d00001 diff --git a/be0/scripts/add_ump_ideas.py b/be0/scripts/add_ump_ideas.py new file mode 100644 index 0000000..afd9964 --- /dev/null +++ b/be0/scripts/add_ump_ideas.py @@ -0,0 +1,93 @@ +""" +Script to add the 10 UMP innovation ideas to the vector database +""" +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.infrastructure.vector_db.qdrant_service import get_qdrant_service + +UMP_IDEAS = [ + { + "title": "Nền tảng Trợ lý AI học tập lâm sàng (Clinical AI Tutor)", + "description": "Ứng dụng AI đóng vai trò trợ giảng cho sinh viên y, hỗ trợ phân tích ca bệnh giả lập, giải thích cận lâm sàng, và gợi ý chẩn đoán theo phác đồ Việt Nam.", + "category": "Giáo dục - AI" + }, + { + "title": "Hệ thống bệnh án điện tử học thuật (Academic EMR Sandbox)", + "description": "Môi trường EMR mô phỏng cho đào tạo và nghiên cứu, cho phép sinh viên và giảng viên thực hành nhập – phân tích – khai thác dữ liệu y khoa mà không ảnh hưởng dữ liệu bệnh nhân thật.", + "category": "Giáo dục - Chuyển đổi số" + }, + { + "title": "Trung tâm mô phỏng y khoa bằng AR/VR & Digital Twin", + "description": "Xây dựng phòng lab mô phỏng phẫu thuật, cấp cứu, và quy trình điều trị bằng AR/VR, kết hợp mô hình \"digital twin\" của cơ thể người phục vụ đào tạo nâng cao.", + "category": "Giáo dục - AR/VR" + }, + { + "title": "Chương trình Y tế cộng đồng số cho vùng sâu vùng xa", + "description": "Kết hợp telehealth, trợ lý ảo y tế (agentic care) và AI sàng lọc sớm bệnh không lây (NCD) cho người dân vùng nông thôn, miền núi và hải đảo.", + "category": "Tác động xã hội - Telehealth" + }, + { + "title": "Nền tảng nghiên cứu AI y sinh dùng chung (UMP AI Research Hub)", + "description": "Cung cấp hạ tầng GPU, kho dữ liệu y khoa ẩn danh, và công cụ phân tích AI cho giảng viên – nghiên cứu sinh – startup hợp tác nghiên cứu.", + "category": "Nghiên cứu - AI" + }, + { + "title": "Hệ thống theo dõi và dự báo sức khỏe sinh viên & nhân viên y tế", + "description": "Ứng dụng phân tích dữ liệu và AI để phát hiện sớm stress, burnout, và vấn đề sức khỏe tâm thần trong cộng đồng sinh viên và nhân viên y tế.", + "category": "Tác động xã hội - Sức khỏe" + }, + { + "title": "Vườn ươm khởi nghiệp công nghệ y sinh (MedTech Incubator)", + "description": "Hỗ trợ sinh viên, bác sĩ và giảng viên phát triển startup MedTech, HealthTech, AI y tế thông qua mentoring, quỹ seed và kết nối bệnh viện – doanh nghiệp.", + "category": "Khởi nghiệp - MedTech" + }, + { + "title": "Hệ thống quản lý chất lượng đào tạo và kiểm định số", + "description": "Số hóa toàn bộ quy trình đảm bảo chất lượng nội bộ (IQA), đánh giá chương trình đào tạo, và chuẩn hóa theo tiêu chuẩn quốc tế (WFME, AUN-QA).", + "category": "Giáo dục - Quản lý chất lượng" + }, + { + "title": "Nền tảng dữ liệu lớn phòng chống dịch và bệnh không lây", + "description": "Phân tích dữ liệu dịch tễ, môi trường, và hành vi để dự báo dịch bệnh, hỗ trợ Sở Y tế và Bộ Y tế trong ra quyết định chính sách.", + "category": "Nghiên cứu - Dịch tễ học" + }, + { + "title": "Học viện Y học chính xác & Y học cá thể hóa", + "description": "Kết hợp dữ liệu gen, hình ảnh y khoa, lối sống và AI để nghiên cứu và ứng dụng điều trị cá thể hóa cho bệnh ung thư, tim mạch và bệnh mạn tính.", + "category": "Nghiên cứu - Y học chính xác" + } +] + +async def main(): + """Add all UMP ideas to the database""" + print("Initializing Qdrant service...") + qdrant_service = get_qdrant_service() + + print("Initializing collection...") + await qdrant_service.initialize_collection() + + print(f"Adding {len(UMP_IDEAS)} ideas to the database...") + results = [] + for i, idea in enumerate(UMP_IDEAS, 1): + try: + print(f"Adding idea {i}/{len(UMP_IDEAS)}: {idea['title']}") + result = await qdrant_service.add_idea( + title=idea['title'], + description=idea['description'], + category=idea['category'] + ) + results.append(result) + print(f"✓ Added: {result['id']}") + except Exception as e: + print(f"✗ Error adding idea {i}: {e}") + + print(f"\n✓ Successfully added {len(results)}/{len(UMP_IDEAS)} ideas") + return results + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/be0/scripts/apply-migration-007.sh b/be0/scripts/apply-migration-007.sh new file mode 100755 index 0000000..8a8811d --- /dev/null +++ b/be0/scripts/apply-migration-007.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Apply migration 007 (user_roles.admin_from_email_policy) to an EXISTING Postgres. +# initdb scripts in docker-entrypoint-initdb.d run only on first volume creation. +# +# Default (full SQL file): adds column, runs one-time policy DELETE/UPDATE (see +# be0/migrations/007_user_roles_email_policy_admin.sql before running on prod). +# +# Usage (from anywhere): +# ./be0/scripts/apply-migration-007.sh +# ./be0/scripts/apply-migration-007.sh --schema-only # only ADD COLUMN (safest repeat) +# +# On a remote host (SSH to be0/docker host, repo or copy of migrations present): +# export POSTGRES_CONTAINER=initiative-postgres POSTGRES_USER=initiative POSTGRES_DB=initiatives +# ./be0/scripts/apply-migration-007.sh +# +# From repo root (wrapper): +# ./scripts/apply-migration-007-postgres.sh +set -euo pipefail + +SCHEMA_ONLY=0 +for arg in "$@"; do + case "$arg" in + --schema-only) SCHEMA_ONLY=1 ;; + -h|--help) + sed -n '2,20p' "$0" + exit 0 + ;; + esac +done + +BE0_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SQL_FULL="$BE0_ROOT/migrations/007_user_roles_email_policy_admin.sql" +CONTAINER="${POSTGRES_CONTAINER:-initiative-postgres}" +PGUSER="${POSTGRES_USER:-initiative}" +PGDATABASE="${POSTGRES_DB:-initiatives}" + +if ! docker info >/dev/null 2>&1; then + echo "error: Docker is not reachable (is the daemon running?)" >&2 + exit 1 +fi +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "error: container not found: $CONTAINER (set POSTGRES_CONTAINER)" >&2 + exit 1 +fi +if [[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" != "true" ]]; then + echo "error: container is not running: $CONTAINER" >&2 + exit 1 +fi + +apply_schema_only() { + docker exec -i "$CONTAINER" psql -U "$PGUSER" -d "$PGDATABASE" -v ON_ERROR_STOP=1 <<'SQL' +ALTER TABLE user_roles ADD COLUMN IF NOT EXISTS admin_from_email_policy BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN user_roles.admin_from_email_policy IS + 'TRUE when admin was granted by email allow-list (AUTH_ADMIN_EMAILS). Reconciliation may DELETE this row if the user email is no longer in the list. FALSE preserves manually granted admin (future / exceptional).'; +SQL +} + +apply_full() { + if [[ ! -f "$SQL_FULL" ]]; then + echo "error: missing migration file: $SQL_FULL" >&2 + exit 1 + fi + docker exec -i "$CONTAINER" psql -U "$PGUSER" -d "$PGDATABASE" -v ON_ERROR_STOP=1 <"$SQL_FULL" +} + +verify_column() { + local out + out="$(docker exec "$CONTAINER" psql -U "$PGUSER" -d "$PGDATABASE" -tAc \ + "SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'user_roles' AND column_name = 'admin_from_email_policy'")" + if [[ "${out//$'\r'/}" != "1" ]]; then + echo "error: verification failed: column admin_from_email_policy missing on public.user_roles" >&2 + exit 1 + fi +} + +if (( SCHEMA_ONLY )); then + echo "Applying schema only (ADD COLUMN + COMMENT) → $CONTAINER / $PGDATABASE" + apply_schema_only +else + echo "Applying full 007_user_roles_email_policy_admin.sql → $CONTAINER / $PGDATABASE" + apply_full +fi + +verify_column +echo "ok: user_roles.admin_from_email_policy is present; admin register/login should work with current be0." diff --git a/be0/scripts/apply_initiative_migrations.py b/be0/scripts/apply_initiative_migrations.py new file mode 100644 index 0000000..7529823 --- /dev/null +++ b/be0/scripts/apply_initiative_migrations.py @@ -0,0 +1,533 @@ +""" +Apply idempotent SQL fixes when the DB volume predates newer migrations. + +- ``008_audit_events.sql`` when ``audit_events`` is missing (older volumes never + ran ``docker-entrypoint-initdb.d`` for new files). +- ``009_backup_artifact_roles_storage_kind.sql`` when ``storage_kind`` is missing. +- ``010_user_staff_profiles.sql`` + ``011_academic_titles_vn.sql`` when + ``academic_titles`` is missing (staff profile / register flow). +- ``013_email_verification.sql`` when ``email_verification_tokens`` is missing. +- ``014_registration_otp.sql`` when ``registration_otp_codes`` is missing. + +Run automatically from entrypoint when ``INITIATIVE_DATABASE_URL`` is set. +Standalone: + + INITIATIVE_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dbname \\ + python scripts/apply_initiative_migrations.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + + +def _async_url_to_asyncpg_dsn(url: str) -> str: + u = url.strip() + if "+asyncpg" in u: + u = u.replace("postgresql+asyncpg://", "postgresql://", 1) + return u + + +def _strip_sql_comments(text: str) -> str: + lines: list[str] = [] + for line in text.splitlines(): + s = line.strip() + if s.startswith("--"): + continue + lines.append(line) + return "\n".join(lines) + + +def _split_sql_statements(text: str) -> list[str]: + """Split on semicolons outside ``$$`` dollar-quoted blocks (008 uses ``DO $$``).""" + statements: list[str] = [] + buf: list[str] = [] + i = 0 + n = len(text) + in_dollar = False + while i < n: + if text.startswith("$$", i): + in_dollar = not in_dollar + buf.append("$$") + i += 2 + continue + ch = text[i] + if ch == ";" and not in_dollar: + stmt = "".join(buf).strip() + if stmt: + statements.append(stmt) + buf = [] + i += 1 + continue + buf.append(ch) + i += 1 + tail = "".join(buf).strip() + if tail: + statements.append(tail) + return statements + + +async def _needs_audit_events_migration(conn) -> bool: + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'audit_events' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_backup_migration(conn) -> bool: + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'application_artifacts' + AND column_name = 'storage_kind' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_staff_profiles_migration(conn) -> bool: + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'academic_titles' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_email_verification_migration(conn) -> bool: + """True when verification tokens table is missing (013 also adds users.email_verified).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'email_verification_tokens' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_registration_otp_migration(conn) -> bool: + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'registration_otp_codes' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_document_templates_migration(conn) -> bool: + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'document_templates' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_research_projects_migration(conn) -> bool: + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'research_projects' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_imagehub_datasets_migration(conn) -> bool: + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'imagehub_datasets' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_imagehub_segmentation_columns_migration(conn) -> bool: + """True when imagehub_dataset_files lacks the segmentation-link columns (018).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'imagehub_dataset_files' + AND column_name = 'file_kind' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_cloud_import_migration(conn) -> bool: + """True when the cloud-import storage_methods table is absent (019).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'imagehub_storage_methods' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_imagehub_stages_migration(conn) -> bool: + """True when the dataset-stages table is absent (020).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'imagehub_dataset_stages' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_imagehub_tasks_migration(conn) -> bool: + """True when the per-file task-pipeline table is absent (021).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'imagehub_tasks' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_imagehub_task_annotations_migration(conn) -> bool: + """True when imagehub_tasks lacks the annotations column (022).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'imagehub_tasks' + AND column_name = 'annotations' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_imagehub_members_migration(conn) -> bool: + """True when the dataset-membership table is absent (023).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'imagehub_dataset_members' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_imagehub_dataset_project_link_migration(conn) -> bool: + """True when imagehub_datasets.research_project_id is absent (024).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'imagehub_datasets' + AND column_name = 'research_project_id' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_imagehub_review_events_migration(conn) -> bool: + """True when the task-review-events table is absent (025).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'imagehub_task_review_events' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_imagehub_folder_path_migration(conn) -> bool: + """True when imagehub_dataset_files.folder_path is absent (026).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'imagehub_dataset_files' + AND column_name = 'folder_path' + LIMIT 1 + """ + ) + return row is None + + +async def _needs_imagehub_label_map_migration(conn) -> bool: + """True when imagehub_datasets.label_map is absent (027).""" + row = await conn.fetchrow( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'imagehub_datasets' + AND column_name = 'label_map' + LIMIT 1 + """ + ) + return row is None + + +async def _apply_sql_file(conn, path: Path, label: str) -> None: + body = _strip_sql_comments(path.read_text(encoding="utf-8")) + for stmt in _split_sql_statements(body): + await conn.execute(stmt) + print(f"apply_initiative_migrations: {label} applied.") + + +async def main() -> int: + raw_url = (os.environ.get("INITIATIVE_DATABASE_URL") or "").strip() + if not raw_url.lower().startswith("postgresql"): + print("apply_initiative_migrations: no PostgreSQL URL; skipping.", file=sys.stderr) + return 0 + + root = Path(__file__).resolve().parent.parent + m008 = root / "migrations" / "008_audit_events.sql" + m009 = root / "migrations" / "009_backup_artifact_roles_storage_kind.sql" + m010 = root / "migrations" / "010_user_staff_profiles.sql" + m011 = root / "migrations" / "011_academic_titles_vn.sql" + for p in (m008, m009, m010, m011): + if not p.is_file(): + print(f"apply_initiative_migrations: missing {p}", file=sys.stderr) + return 1 + + import asyncpg + + dsn = _async_url_to_asyncpg_dsn(raw_url) + conn = await asyncpg.connect(dsn, timeout=60) + try: + if await _needs_audit_events_migration(conn): + print("apply_initiative_migrations: applying 008_audit_events …") + await _apply_sql_file(conn, m008, "008_audit_events") + else: + print("apply_initiative_migrations: audit_events present; OK.") + + if await _needs_backup_migration(conn): + print("apply_initiative_migrations: applying 009_backup_artifact_roles_storage_kind …") + await _apply_sql_file(conn, m009, "009_backup_artifact_roles_storage_kind") + else: + print("apply_initiative_migrations: application_artifacts.storage_kind present; OK.") + + if await _needs_staff_profiles_migration(conn): + print("apply_initiative_migrations: applying 010_user_staff_profiles …") + await _apply_sql_file(conn, m010, "010_user_staff_profiles") + print("apply_initiative_migrations: applying 011_academic_titles_vn …") + await _apply_sql_file(conn, m011, "011_academic_titles_vn") + else: + print("apply_initiative_migrations: academic_titles present; OK.") + + m013 = root / "migrations" / "013_email_verification.sql" + if not m013.is_file(): + print(f"apply_initiative_migrations: missing {m013}", file=sys.stderr) + return 1 + if await _needs_email_verification_migration(conn): + print("apply_initiative_migrations: applying 013_email_verification …") + await _apply_sql_file(conn, m013, "013_email_verification") + else: + print("apply_initiative_migrations: email_verification_tokens present; OK.") + + m014 = root / "migrations" / "014_registration_otp.sql" + if not m014.is_file(): + print(f"apply_initiative_migrations: missing {m014}", file=sys.stderr) + return 1 + if await _needs_registration_otp_migration(conn): + print("apply_initiative_migrations: applying 014_registration_otp …") + await _apply_sql_file(conn, m014, "014_registration_otp") + else: + print("apply_initiative_migrations: registration_otp_codes present; OK.") + + m015 = root / "migrations" / "015_document_templates.sql" + if not m015.is_file(): + print(f"apply_initiative_migrations: missing {m015}", file=sys.stderr) + return 1 + if await _needs_document_templates_migration(conn): + print("apply_initiative_migrations: applying 015_document_templates …") + await _apply_sql_file(conn, m015, "015_document_templates") + else: + print("apply_initiative_migrations: document_templates present; OK.") + + m016 = root / "migrations" / "016_research_projects.sql" + if not m016.is_file(): + print(f"apply_initiative_migrations: missing {m016}", file=sys.stderr) + return 1 + if await _needs_research_projects_migration(conn): + print("apply_initiative_migrations: applying 016_research_projects …") + await _apply_sql_file(conn, m016, "016_research_projects") + else: + print("apply_initiative_migrations: research_projects present; OK.") + + m017 = root / "migrations" / "017_imagehub_datasets.sql" + if not m017.is_file(): + print(f"apply_initiative_migrations: missing {m017}", file=sys.stderr) + return 1 + if await _needs_imagehub_datasets_migration(conn): + print("apply_initiative_migrations: applying 017_imagehub_datasets …") + await _apply_sql_file(conn, m017, "017_imagehub_datasets") + else: + print("apply_initiative_migrations: imagehub_datasets present; OK.") + + m018 = root / "migrations" / "018_imagehub_segmentation_links.sql" + if not m018.is_file(): + print(f"apply_initiative_migrations: missing {m018}", file=sys.stderr) + return 1 + if await _needs_imagehub_segmentation_columns_migration(conn): + print("apply_initiative_migrations: applying 018_imagehub_segmentation_links …") + await _apply_sql_file(conn, m018, "018_imagehub_segmentation_links") + else: + print("apply_initiative_migrations: imagehub_dataset_files.file_kind present; OK.") + + m019 = root / "migrations" / "019_imagehub_cloud_import.sql" + if not m019.is_file(): + print(f"apply_initiative_migrations: missing {m019}", file=sys.stderr) + return 1 + if await _needs_cloud_import_migration(conn): + print("apply_initiative_migrations: applying 019_imagehub_cloud_import …") + await _apply_sql_file(conn, m019, "019_imagehub_cloud_import") + else: + print("apply_initiative_migrations: imagehub_storage_methods present; OK.") + + m020 = root / "migrations" / "020_imagehub_dataset_stages.sql" + if not m020.is_file(): + print(f"apply_initiative_migrations: missing {m020}", file=sys.stderr) + return 1 + if await _needs_imagehub_stages_migration(conn): + print("apply_initiative_migrations: applying 020_imagehub_dataset_stages …") + await _apply_sql_file(conn, m020, "020_imagehub_dataset_stages") + else: + print("apply_initiative_migrations: imagehub_dataset_stages present; OK.") + + m021 = root / "migrations" / "021_imagehub_task_pipeline.sql" + if not m021.is_file(): + print(f"apply_initiative_migrations: missing {m021}", file=sys.stderr) + return 1 + if await _needs_imagehub_tasks_migration(conn): + print("apply_initiative_migrations: applying 021_imagehub_task_pipeline …") + await _apply_sql_file(conn, m021, "021_imagehub_task_pipeline") + else: + print("apply_initiative_migrations: imagehub_tasks present; OK.") + + m022 = root / "migrations" / "022_imagehub_task_annotations.sql" + if not m022.is_file(): + print(f"apply_initiative_migrations: missing {m022}", file=sys.stderr) + return 1 + if await _needs_imagehub_task_annotations_migration(conn): + print("apply_initiative_migrations: applying 022_imagehub_task_annotations …") + await _apply_sql_file(conn, m022, "022_imagehub_task_annotations") + else: + print("apply_initiative_migrations: imagehub_tasks.annotations present; OK.") + + m023 = root / "migrations" / "023_imagehub_dataset_members.sql" + if not m023.is_file(): + print(f"apply_initiative_migrations: missing {m023}", file=sys.stderr) + return 1 + if await _needs_imagehub_members_migration(conn): + print("apply_initiative_migrations: applying 023_imagehub_dataset_members …") + await _apply_sql_file(conn, m023, "023_imagehub_dataset_members") + else: + print("apply_initiative_migrations: imagehub_dataset_members present; OK.") + + m024 = root / "migrations" / "024_imagehub_dataset_project_link.sql" + if not m024.is_file(): + print(f"apply_initiative_migrations: missing {m024}", file=sys.stderr) + return 1 + if await _needs_imagehub_dataset_project_link_migration(conn): + print("apply_initiative_migrations: applying 024_imagehub_dataset_project_link …") + await _apply_sql_file(conn, m024, "024_imagehub_dataset_project_link") + else: + print("apply_initiative_migrations: imagehub_datasets.research_project_id present; OK.") + + m025 = root / "migrations" / "025_imagehub_task_review_events.sql" + if not m025.is_file(): + print(f"apply_initiative_migrations: missing {m025}", file=sys.stderr) + return 1 + if await _needs_imagehub_review_events_migration(conn): + print("apply_initiative_migrations: applying 025_imagehub_task_review_events …") + await _apply_sql_file(conn, m025, "025_imagehub_task_review_events") + else: + print("apply_initiative_migrations: imagehub_task_review_events present; OK.") + + m026 = root / "migrations" / "026_imagehub_file_folder_path.sql" + if not m026.is_file(): + print(f"apply_initiative_migrations: missing {m026}", file=sys.stderr) + return 1 + if await _needs_imagehub_folder_path_migration(conn): + print("apply_initiative_migrations: applying 026_imagehub_file_folder_path …") + await _apply_sql_file(conn, m026, "026_imagehub_file_folder_path") + else: + print("apply_initiative_migrations: imagehub_dataset_files.folder_path present; OK.") + + m027 = root / "migrations" / "027_imagehub_dataset_label_map.sql" + if not m027.is_file(): + print(f"apply_initiative_migrations: missing {m027}", file=sys.stderr) + return 1 + if await _needs_imagehub_label_map_migration(conn): + print("apply_initiative_migrations: applying 027_imagehub_dataset_label_map …") + await _apply_sql_file(conn, m027, "027_imagehub_dataset_label_map") + else: + print("apply_initiative_migrations: imagehub_datasets.label_map present; OK.") + + return 0 + except Exception as exc: + print(f"apply_initiative_migrations: FAILED: {exc}", file=sys.stderr) + if os.environ.get("INITIATIVE_DB_STRICT_MIGRATE", "").strip().lower() in ("1", "true", "yes"): + return 1 + return 0 + finally: + await conn.close() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/be0/scripts/repair_split_submission.py b/be0/scripts/repair_split_submission.py new file mode 100644 index 0000000..f0bf943 --- /dev/null +++ b/be0/scripts/repair_split_submission.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +CLI: merge a mis-linked submission onto the real CASE-* initiative row and delete the orphan initiative. + +Usage (dry-run — default, no writes): + + cd be0 + export INITIATIVE_DATABASE_URL="postgresql+asyncpg://user:pass@host:5432/initiatives" + python scripts/repair_split_submission.py --submission-id sub-d560fbb6f2944ec6 + +Apply (commits one transaction): + + python scripts/repair_split_submission.py --submission-id sub-... --good-case CASE-YOURCODE --execute + +Requires the same Postgres URL as the API (`INITIATIVE_DATABASE_URL` / `DATABASE_URL`). +""" +from __future__ import annotations + +import argparse +import asyncio +import os +import sys + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +async def _main_async() -> int: + p = argparse.ArgumentParser(description="Repair split submission / wrong initiative linkage.") + p.add_argument( + "--submission-id", + required=True, + help="submissionRecord.id (e.g. sub-d560fbb6f2944ec6)", + ) + p.add_argument( + "--good-case", + dest="good_case", + default=None, + help="Explicit CASE-* code for the autosave row (recommended if owner has multiple drafts)", + ) + p.add_argument( + "--execute", + action="store_true", + help="Apply changes (otherwise dry-run only)", + ) + args = p.parse_args() + + os.environ.setdefault("INITIATIVE_DATABASE_URL", os.getenv("DATABASE_URL") or "") + from src.initiative_db.engine import get_session, init_engine, is_postgres_enabled + from src.initiative_db.repair_split_submission import repair_submission_cross_initiative_merge + + if not is_postgres_enabled(): + print("Error: set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives", file=sys.stderr) + return 2 + + await init_engine() + + async with get_session() as session: + report = await repair_submission_cross_initiative_merge( + session, + submission_record_id=args.submission_id.strip(), + good_case_code_explicit=(args.good_case or "").strip() or None, + dry_run=not args.execute, + ) + + lines = [ + f"dry_run={report.dry_run}", + f"submission_record_id={report.submission_record_id}", + f"owner_id={report.owner_id or '(n/a)'}", + f"bad_case={report.bad_case_code or '(n/a)'}", + f"good_case={report.good_case_code or '(n/a)'}", + ] + if report.skipped: + lines.append(f"SKIPPED: {report.skipped}") + lines.extend(report.actions) + print("\n".join(lines)) + + if args.execute and report.skipped: + return 3 + return 0 + + +def main() -> None: + raise SystemExit(asyncio.run(_main_async())) + + +if __name__ == "__main__": + main() diff --git a/be0/src/__init__.py b/be0/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/__pycache__/MCP.cpython-311.pyc b/be0/src/__pycache__/MCP.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e92aba0ddc5c363e97ca462dccf551043ea03f1 GIT binary patch literal 3354 zcmbVOO>7&-6`uX&k5&;&Nt9$u_BytsGIdGIHY`;!Y)e9#8m(!e&Y>nyEY{qSw9ayu znOQ~_K@~<}AOf005d?7CB&beq0{7sfuW>IqQUQSw3m9-w^w1jv=Md=B_m<>RawH(g z?ChI2GjC?+&AjiM{m0;7l0b_4SJrkDg!~69y(D*)7gs@fKrCVj4k=;oV$Js}^ZESDFV5v=F3#oV&gITsoWF4P?1kEgx4f!u zGOC-n@)gRt?YX9--@LH|KA6r12VbC(`=;yX^miz&=o?;@K__3QjO%>OV;y(RQmKj? z)bSEG&|X1X-STwT^YwRJZ~ct!Q^yH6tjZ|^#q?jcsg30FaPB-`>K#Z9s#!TuO*jLe z2LrateO4{`uvCUF2Wme~ICWMy?uouTls7AtJZGgm9BQtz5yTAxqsuUA>FBU?y|w`u z!_R`aN1kix#z?*`iJ9b<{J}t56($#jEx8#V|8FMSc>U^5W}%r`z^*NLUceYwLg(Ov z7_XMgCff*9M*S*tOVOEt6~-*qU;GYM_<+;}Jnv7$O({YA=o|^T6X|twCxVJ4O~BAj z`WR0CQ?V{tily4ZKVuJJ)<@BoHs!j!9o2ecuzU#bqo`q1f%$ttCfj>rD>5Ngysmsq zJ{9-Jnn3Exb`ODlW6-R`D%lV5O|`DxBqxdgO5gAlVL#oB{eZ70>#>8rehbKDGxmoV zl1E2d7uLyL=@wZRvIB*HWJQ*O&#|ANh=Zub^IaJ3YD?W715vch>p`qLP3>nb*oBs-_x`4 zAkOH!RmvfL<%$RK3xrD8I0}rsK(XlU>S`dt;2`nl-4d1+@i2Vh>KxAM_w<+(l0xEB6z5cf!1C1X>qv5B@K zr;}|0VoPl&2NRiRW5++*Yz>cn^u}M_YYxBGNS*EE*67jJ#B?M3Mq~U^J0_36@(ls; zEsCvQwKXzv;_rih8~p3gCqrA;T3YIZUw^GleWgt`j?I2{4Fu#JZLX=!HMF@_=Jz z>pkB=YAY}tN{+6+zmQ*;C11=Y7qik+VQTS|^z@VhdZ0N@+34-=!SH?Dg>N0Mn;#5c zaUXo85Wcdd0DRRS2VVf8ydA+8T#C~7V6Gej?%1JVjphP6ihzD6)@ud;UkCw=p%$iX z4CwDAppOFq?2qx;W7wCZJx<_3>bD&#iD)9E5H^Jq9S5^gzsRTLpBo&@bMrs(cU?Iot@Hs^5U2w(uN%FJZz=yY3=kS9O@PsCG+tHO^Z ziowyZwbNf|r+2jJrZ(Nsrk{`Nd#K}ZZWwD0z1kc)kK{1d7yB6E`j;mf$F4)((F#qi z(9jCc(nlMU7az}nfV`8QZKh`%+AMNK$Tk9a(k6Ww+FyPG_Lm{=q^~s7R~p(Cv~Npj z1`0m~yYv3SYYRE@MQ&tiRC+4r7iXlWGlQsKPA{dTf29=A3wS9QhHI9oVFXFTD0^1b zL0vP9cdMq;?THzN<&_MB;r$s%aCI>J1bZFC=Y$QRjvUVrrlCNT@;`yN7d>rJRN}BN zAXHUZ1h4kUBO^*?`(&Fy@mLCrC!HeH_lPs2%2fd^v3z{9Q$FdIo!Vitvrb%MRt@7m zNWh<2uIk$k@BYO38x`AKWpd9AhJRyy|90b-Iy-ckorewxKf+xQu=9fOoDAIypBBkB c_UGqhtg%10IRZdc*bnAA40J=l}o! literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/Memory_Manager.cpython-311.pyc b/be0/src/__pycache__/Memory_Manager.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b1fe4d8fcdfd0434eeb64c71e038476d51f710d5 GIT binary patch literal 4536 zcmb_fUu@gP89)9NB~i8T|L`Pfi@Ztd+Va5&}fmi70{AK zQca@St1LP3dCqTOe?a~LHaOY1J(@#hIB7|qznNT2m}bQr@g6gAA&yZyQ3(H zwz34+$m4h4-FU>yW?k}PyoU6ms5XU{gV%&f8m1ruuW!P1LihTkV0uF?c`LN zB4s*FlblJjBpOi8rOzLhgfo1M#LO^LGmbNQT^leJt~&Bz(8u(G1+*=ARg@y){iU0`k_ z72#>Z6hjLAK9yz^MrD_2P|T(|g;RNjQK)5B;i2qPdK4ez0^|bZevl2QL6ukeWjfZ< z(dJ+rKgfwn0FQH662=Wed#Gz0t%%UpLl)a>hc2pX1^vcFIWNP4QrVU{KrMCx1K&QP z@QT){b(Cq%#>6enN1^V5#C`aj2jp7gtfQ+fzS7ahl||q(Z*L0a5RBl405}m!oGWjN zE{D)n2pi|A2rk!|gN@nG54ah|YRRsnioa9d<*^6vy9HRhf^cA@%F#p;9B(Aoh|Sg< znAIRXGx>s|+5xweQ*=CYJ9I&pi>q_8DKE)}YV(So zdso$MF=u38(7c*8RRxE#p=!%Iww!^T5s!a+G$|L0Nkh*jodC!*BOp<{VGEfI4)#o@ z9Pva)!mSVCfl5F&Q9UxW#oi1)j7Zk$xoTv-7MaK8x**;R?g_D7A-0{U3Q|pwEJ3Od z4sWqFAyz+n41{_gMh0sUX)khSH*&^$Y1Vr4XIA7)H6qs{a&ru4gsL!D69z3e?So>l zldhU2J)dm_9aQ2iNCNgF60m*J%Ki;b*j<|*p?VD2ZMZrhS6IA zU1^1@rw_eOS6ZRl+2Yl!FzwC#G5YPH4FUKCuNSWFh=u#>^p2FKAKI;T)u8@x}l(JdXFqTW&czk97wv=H2G?kAo{BSl-_E6jj@^~&E zHwxL8r{lJ-DYRIYo-FmcoYPb#zNBX5lA*?5mjwL?tk?D#paB%yj9$v?IJ9+a2b(u? z`4vsIxvW-zt>vG;o>hw`2uiH}9F`g+Qo3rCG}ES314lp{9{qXT+7G0B)VqH=cVz_H zjS+~+P1K+Tf2`j3{D*^kePg?QW7WP>wZ2mgM2Fs?cE;CaCp9&A6zaSA=AJmbD-Ku1<2CWPB_6N$58OKU_+f7py7^;mQWwLn ztaa?-S0g|mRmD_IOj%;8-XFa+;l7~sF-+YbgWPxpO!ceS52EPfnc&P>=D{fu_y=d% znMvP+Ne1)Zr7%A|1pJrLz-)l|a&&l>WB$o;z}uoy$Yz$o`$gGY^_*}R5(r@6U4pL} z3+Nt|x`!m%aq%d2l7pDUtF~V&$Vx^5ye$uSecNgnfj;~AU;=Oy#4T1I0KPj+eF`n0 zmD-@p4sdEime~PBPdi1|DL6+#zI~zbo~i&>^c;F;0B`E$Wr?%d6|Jyj(*?t3v2ktoY9W``Pr>KeH@=|&I}=HTNxsX3xIu0hm@z zvT^Z6sXXKjEN|;~aPJxr2(Do7%~u+T_b0pJ^CZA?c;I&MgP=8Zz8apYg{Q3Wln0;B zRl^gt@Prkfz)(Diq0}K5hbJ)%cht38+guZW4@q^!UVvXnkJ?DPU8stQnwYTMWc0zp zet%|Wn0YYF&W!jTj8Q;6_|ydzb-dy9lR)lK1R4!2dj7c-+?PI(HI!dYl_FR9Vli!fzR##6kU@#`7Ni&%SCMPrP%@VZ1E& zHcr$YJG|`9_Gyo1vMN|m6lzmaFTQf;+aRT0tpkm=`mdpO<9KY0>eG!Dg2V z1&v%TY{oEk0=4=XJoZ^kFjVL)CKH%UVL~)x*AbgLw-bCm>6keG1mMig*4v5&lHTX@U^F^CCZxrF0*kO0_Tjqx!t3=7nX$JYJBbFw+r|p7o zh3`3jepSB!YVdY4J^=#95JlBd@22Oiqu?fa>!{yyQym?#-1G-xdG> literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/Memory_Manager.cpython-38.pyc b/be0/src/__pycache__/Memory_Manager.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ed2184d7438098bb5c437d96e811f03e688192e GIT binary patch literal 6521 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 literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/Response_Manager.cpython-311.pyc b/be0/src/__pycache__/Response_Manager.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a471a50a264192d1145df30027d0610394497de GIT binary patch literal 4245 zcma)9>u(#!5#QrI_&VxES)!i4`W0VoONAXbk&*_LEx+PeGAuvf1A@bucao^yk@Vit zwpc0<5(DK2e@I&c7Fq{T0K;`*e{j+C4^Te$18ER2ae#mn{nC6>pacl~sWZ!mMA<>R zTn=Y<=62_1e=|G$ODq;4(0=!?ujUVg3Hdh;8cnD(o6mrGKultCHgPzOa{^kx;WeJa zxqvM=q9!t)w;4>(C&1) zv@R#E#o0JycRRbZT~3eI8F?HaP3t%L8KOqN z#i7dkv9p&iUp#duX=S}sChZHSQ?}(tu4L_uVIr?`e(ZwfWa#1rBW=uCbP8s|7tPsA zmSI{H2I6EUd2U)2eQD<8*;5xL{OGy#Z03@6GiSM;-*z&S_N+TzW84Yjq@8nNv&K~H zBF#A2hK3sOg^yD3q%fIEdaB^ZQ|XkKGVD~|(hc3t%z+Vp=bV+csNq>UwcKnb?OJ|E zb1GTe(vNSSsc)LB`+@!N^}*&vU>*>QXq-v3fXUq@8Xq8*U&4pk&5x!`^W&maZJY0e3 zwOxRE4by<%X$0ssk26JJ)YJ_V4P{%GDG$fucI+0 zWFMgim%z@2=KibO_S@1k`2zQ6^2HA4apXFL!xHxiIC-Yd-R5qSJN$KWn^T1;pXB>T zM&X3r(H)K(fyulylSv*^#?^=~IvLZlDO%=BbwtFHIZCaxr(2GZvS|oqGUN5EL0yYd z38&f|24>xS56e^+qjern7vaam^ zGWaN1oUF)0>w8AO9DOv3{O%I#Kd!eYHV79!vPG(L$G!6}<)Ihy(2DbfTX9yLvV6EA zA1=MA{|Ie^hXvr*?FFkI9hi{GnjD$AxHX#hlHd`w+%|IR`d4TQ` ze{ZM&Sq#95$6Rj!&b}qli#Em(IJ_woL|=z z0EEaY3}0^rZU^Le(1d4RZw20KT-_jSg~pQLH8UU~3r)QO0Js1b!o$EETmsL8$6(Xb zrk$<8TR@vxD~LO6k|29F*I(TR-^LRFTd^R$DSH+~26t{q%GkEy7`~94Hvqj0E|d@=mJTlM134W=rS=bJrl%$?j20GC zYLMaI4?#FVZuaJxCG-&LMpJ1pBbQ{wNQgG>cEL0}!$rTGCJ;?AJ`cb z+l2ezN%!wSmdQpy4i5sHAH4U6_g3zd;|DA8gT={e|M1KHLofOdJ(>K+fpY&?rGKn= ze!X|-{%@;21J%9i^WT*EPhjsmS?wLz2nva|?+B3ZkrdDV5Fy=(`{OI~<@j5b_*<~v zRz@xjzx~7n0=+CBugJ$s^6_eXpg0ZT6tA@J#aJ5LB1}}AE>2^L9>l2W0=qw*_|*wQ zz9yX~y7{l;9MF~+LC>%lc^?>7OThhW2DYc>t;7sSM*Kor-zp11Tl@m>rAkxAyAU&x zx}Vw4^-NANsHGT65(+gtWu%iZa9ya7K`(0tLkNg&RL!^!(Ovh^;^7Gp?;Q9X%ifi4>uQ519S{zzG&v0 ztP7FKt_v0)Qq5s59yWN2g#rB~4Dy3J;lDjvkAsH$0T9H0tV7=DB5hq8apVY)9}+OU zr>#h_AP}0=Go8SyNT(kRv2KFr*cz&CGbA% z4bFh5UQ^ZLH{`)z1Raw8>!njeavGn(JH2+FRYP)7pg0NtL+A+ns!^=K(uQN{x*ySX zC=GKq^0Kbq%o%n~Lid0w#bi#g3Z_Sp{2B>5k77McKSXj439esr?JneypoV)N$a3?u z5eSJ;gf&SwIrh%;eb3#$sb8r$jTa=+Rc-sIg-^sp2>n*Fr%i-YX_7sh7+ZB9KZvoP zBsGK4Om)By&KeMx*%YfawqDuqf&zA=_E)Y^zOs_Ijj5m)WOE21>2{8!}XC?N#z|>Hq6h kGFW<3|Ar(>|EpI?Y?*yFgmDgz^>y+c&VBy|0Xypd0g`k0tKZF=^*+sh(HF6K#l_t7qb9~6oz01O-8?!3`HPe1o2BmKe3=dzqlw_ gKR!M)FS8^*Uaz3?7Kcr4eoARhsvSu6XCP((06BFN-~a#s literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/admin_audit_routes.cpython-311.pyc b/be0/src/__pycache__/admin_audit_routes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5bc8eb42a462cf0579af95f23b41b2ce89ad4e81 GIT binary patch literal 13701 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 DAqqHN literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/admin_audit_routes.cpython-313.pyc b/be0/src/__pycache__/admin_audit_routes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12c354895e61d20c1b2cf4bc8d7fc98c89311fe8 GIT binary patch literal 12312 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 Yc8P=U`_A5F^kddN><1cP>CM>x0-WI^)c^nh literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/admin_user_profile_routes.cpython-311.pyc b/be0/src/__pycache__/admin_user_profile_routes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8e8d2044411dc590781e71f47f3b72abfd1466d7 GIT binary patch literal 35864 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 literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/audit.cpython-311.pyc b/be0/src/__pycache__/audit.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..704db57f53dbbb7691d30f154b1c776f3556ddbd GIT binary patch literal 9191 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 literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/audit.cpython-313.pyc b/be0/src/__pycache__/audit.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0da919a864a0de2406fae62c0332ecec42f27db9 GIT binary patch literal 8111 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

Bạn (hoặc ai đó) đã yêu cầu đặt lại mật khẩu.

" + f'

Đặt lại mật khẩu

' + "

Nếu bạn không yêu cầu, hãy bỏ qua email này.

" + ) + await asyncio.to_thread(_send_smtp_sync, to_email, subject, text_body, html_body) + + +async def deliver_registration_otp_email(to_email: str, otp_plaintext: str) -> OtpDeliveryChannel: + """Send OTP via SMTP, log plaintext when AUTH_MAIL_LOG_ONLY=1, or no-op when SMTP unset.""" + ttl_phrase = _registration_otp_validity_phrase_vi() + if os.getenv("AUTH_MAIL_LOG_ONLY", "").lower() in ("1", "true", "yes"): + logger.info("AUTH_MAIL_LOG_ONLY: registration OTP for %s: %s", to_email, otp_plaintext) + return "log_only" + + host = os.getenv("SMTP_HOST", "").strip() + if not host: + logger.warning( + "Registration OTP created but mail is not configured " + "(set SMTP_HOST or AUTH_MAIL_LOG_ONLY=1). User: %s", + to_email, + ) + return "none" + + subject = "Mã xác minh đăng ký — hệ thống sáng kiến" + text_body = ( + "Cảm ơn bạn đã đăng ký.\n\n" + f"Mã xác minh của bạn: {otp_plaintext}\n\n" + f"Mã có hiệu lực trong {ttl_phrase}. Nhập đúng 6 chữ số trên trang đăng ký " + "ngay sau khi nhận email.\n" + "Nếu bạn không đăng ký, hãy bỏ qua email này." + ) + html_body = ( + "

Cảm ơn bạn đã đăng ký.

" + f"

Mã xác minh (nhập trên trang đăng ký): {otp_plaintext}

" + f"

Mã có hiệu lực trong {ttl_phrase}; vui lòng nhập mã trong thời gian này.

" + "

Nếu bạn không đăng ký, hãy bỏ qua email này.

" + ) + await asyncio.to_thread(_send_smtp_sync, to_email, subject, text_body, html_body) + return "smtp" + + +async def deliver_email_verification_email(to_email: str, raw_token: str) -> None: + """Log link, send via SMTP, or warn if misconfigured (same policy as password reset).""" + link = verify_email_link(raw_token) + if os.getenv("AUTH_MAIL_LOG_ONLY", "").lower() in ("1", "true", "yes"): + logger.info("AUTH_MAIL_LOG_ONLY: verify-email link for %s: %s", to_email, link) + return + + host = os.getenv("SMTP_HOST", "").strip() + if not host: + logger.warning( + "Email verification token created but mail is not configured " + "(set SMTP_HOST or AUTH_MAIL_LOG_ONLY=1). User: %s", + to_email, + ) + return + + subject = "Xác minh email — hệ thống sáng kiến" + text_body = ( + "Cảm ơn bạn đã đăng ký.\n\n" + f"Mở liên kết sau để kích hoạt tài khoản (hiệu lực giới hạn):\n{link}\n\n" + "Nếu bạn không đăng ký, hãy bỏ qua email này." + ) + html_body = ( + "

Cảm ơn bạn đã đăng ký.

" + f'

Xác minh email và kích hoạt tài khoản

' + "

Nếu bạn không đăng ký, hãy bỏ qua email này.

" + ) + await asyncio.to_thread(_send_smtp_sync, to_email, subject, text_body, html_body) diff --git a/be0/src/auth_rate_limit.py b/be0/src/auth_rate_limit.py new file mode 100644 index 0000000..319c274 --- /dev/null +++ b/be0/src/auth_rate_limit.py @@ -0,0 +1,89 @@ +"""Simple in-memory rate limits for unauthenticated auth endpoints (single-process).""" + +from __future__ import annotations + +import threading +import time +from collections import defaultdict + +_lock = threading.Lock() +_buckets: dict[str, list[float]] = defaultdict(list) + +_WINDOW_SEC = 3600.0 +_MAX_FORGOT_PER_EMAIL = 5 +_MAX_FORGOT_PER_IP = 30 +_MAX_RESET_PER_IP = 60 +_MAX_RESEND_VERIFY_PER_EMAIL = 5 +_MAX_RESEND_VERIFY_PER_IP = 30 +_MAX_RESEND_OTP_PER_EMAIL = 5 +_MAX_RESEND_OTP_PER_IP = 30 + + +def _prune(ts_list: list[float], now: float) -> None: + cutoff = now - _WINDOW_SEC + while ts_list and ts_list[0] < cutoff: + ts_list.pop(0) + + +def _hit(key: str, max_hits: int) -> bool: + """Return True if under limit (request allowed). False if rate limited.""" + now = time.monotonic() + with _lock: + bucket = _buckets[key] + _prune(bucket, now) + if len(bucket) >= max_hits: + return False + bucket.append(now) + return True + + +_MAX_LOGIN_PER_EMAIL = 5 +_MAX_LOGIN_PER_IP = 10 +_LOGIN_WINDOW_SEC = 900.0 + + +def allow_login(email_normalized: str, client_ip: str) -> bool: + """Return True if under limit (request allowed). False if rate limited.""" + now = time.monotonic() + with _lock: + for key, max_hits in ( + (f"login:email:{email_normalized}", _MAX_LOGIN_PER_EMAIL), + (f"login:ip:{client_ip}", _MAX_LOGIN_PER_IP), + ): + bucket = _buckets[key] + cutoff = now - _LOGIN_WINDOW_SEC + while bucket and bucket[0] < cutoff: + bucket.pop(0) + if len(bucket) >= max_hits: + return False + for key in (f"login:email:{email_normalized}", f"login:ip:{client_ip}"): + _buckets[key].append(now) + return True + + +def allow_forgot_password(email_normalized: str, client_ip: str) -> bool: + if not _hit(f"forgot:email:{email_normalized}", _MAX_FORGOT_PER_EMAIL): + return False + if not _hit(f"forgot:ip:{client_ip}", _MAX_FORGOT_PER_IP): + return False + return True + + +def allow_reset_password(client_ip: str) -> bool: + return _hit(f"reset:ip:{client_ip}", _MAX_RESET_PER_IP) + + +def allow_resend_verification(email_normalized: str, client_ip: str) -> bool: + if not _hit(f"resend_verify:email:{email_normalized}", _MAX_RESEND_VERIFY_PER_EMAIL): + return False + if not _hit(f"resend_verify:ip:{client_ip}", _MAX_RESEND_VERIFY_PER_IP): + return False + return True + + +def allow_resend_registration_otp(email_normalized: str, client_ip: str) -> bool: + if not _hit(f"resend_otp:email:{email_normalized}", _MAX_RESEND_OTP_PER_EMAIL): + return False + if not _hit(f"resend_otp:ip:{client_ip}", _MAX_RESEND_OTP_PER_IP): + return False + return True diff --git a/be0/src/be01/README.md b/be0/src/be01/README.md new file mode 100644 index 0000000..b1650fe --- /dev/null +++ b/be0/src/be01/README.md @@ -0,0 +1,86 @@ +# Biểu mẫu Sáng kiến – Auto-fill Toolkit + +Tự động điền hồ sơ sáng kiến (Mẫu số 01–04 + Bản cam kết) từ file JSON. + +## Files trong bộ công cụ + +| File | Mô tả | +|---|---| +| `template_sang_kien.docx` | Template Word với placeholder `{{...}}` (docxtpl/Jinja2) | +| `data_blank.json` | Schema JSON rỗng – copy rồi điền dữ liệu vào | +| `data_sop.json` | Ví dụ đã điền sẵn (SOP xét duyệt đạo đức nghiên cứu trên động vật) | +| `fill_template.py` | Script điền JSON vào template → xuất file `.docx` | +| `build_template.py` | Script tạo lại template (nếu cần chỉnh sửa bố cục) | + +## Cài đặt + +```bash +pip install docxtpl +``` + +## Sử dụng + +1. Copy `data_blank.json` → `data_cua_toi.json` +2. Điền dữ liệu vào file JSON +3. Chạy lệnh: + ```bash + python fill_template.py data_cua_toi.json output.docx + ``` + +## Quy ước JSON + +### Text đơn giản +```json +"ten_sang_kien": "Tên sáng kiến của tôi" +``` + +### Bảng (nhiều dòng) +Thêm nhiều object vào array – template sẽ tự nhân dòng: +```json +"danh_sach_tac_gia": [ + {"stt": "1", "ho_ten": "Nguyễn Văn A", "ngay_sinh": "01/01/1990", ...}, + {"stt": "2", "ho_ten": "Trần Thị B", "ngay_sinh": "02/02/1992", ...} +] +``` + +### Checkbox +```json +"phan_loai": { + "giai_phap_ky_thuat": true, // ☑ được đánh dấu + "sang_kien_tu_nckh": false, // ☐ không đánh dấu + "sang_kien_tu_sach": false +} +``` + +### Ngày tháng +```json +"ngay_ky": {"ngay": "15", "thang": "10", "nam": "2025"} +``` + +## Cấu trúc JSON + +``` +trang_bia → Trang bìa ngoài + trong +mau_01 → Mẫu số 01: Báo cáo mô tả sáng kiến +mau_02 → Mẫu số 02: Đơn đề nghị công nhận +mau_03 → Mẫu số 03: Bản xác nhận tỷ lệ đóng góp +mau_04 → Mẫu số 04: Phiếu đánh giá +ban_cam_ket → Bản cam kết +``` + +## Liên kết với fe0 (ứng viên) + +- **Frontend** (`fe0`) giữ bản mẫu có **nhãn tiếng Việt** làm khóa: `fe0/public/assets/bieu_mau_sang_kien_template.json` (và bản import trong `fe0/src/components/applicant/initiative-draft/bieu_mau_sang_kien_template.json`). +- Hàm `buildOfficialBieuMauFromDraft` trong `mapDraftToOfficialBieuMau.ts` điền đối tượng này từ **Đơn / Báo cáo / Xác nhận đóng góp** (cùng nguồn với `ReviewPanel`). +- Trường **`officialBieuMau`** trong JSON bundle xuất từ **Review** (`Tải JSON đầy đủ`) là cùng cấu trúc — backend có thể lưu JSONB hoặc `json.dumps` rồi chạy `fill_template.py` sau khi map sang `data_blank.json` (snake_case) nếu template Word dùng khóa Latin. + +## Lưu ý kỹ thuật + +- Template dùng cú pháp `docxtpl` (mở rộng Jinja2 cho Word) +- Bảng dùng pattern `{%tr for %}` / `{%tr endfor %}` – 2 dòng đánh dấu sẽ bị xóa khi render, chỉ còn các dòng dữ liệu +- Checkbox dùng `{% if %}☑{% else %}☐{% endif %}` +- Muốn xuống dòng trong cùng một field text: dùng `\n` trong JSON (docxtpl xử lý tự động) + +## Minh chứng đính kèm (2.1 / 2.2) + +Các tệp minh chứng PDF (và loại khác) **không** đi qua bộ `fill_template.py` ở đây. Chúng được tải lên MinIO qua **be0** và hiển thị trong **fe0** (tab Minh chứng). API trả về `downloadUrl` (tải / mở tab) và `viewUrl` (xem PDF nhúng với `Content-Disposition: inline`). diff --git a/be0/src/be01/__pycache__/build_template.cpython-313.pyc b/be0/src/be01/__pycache__/build_template.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a09bc8f3a4047cfeb545a6a23e68d0bdb65b937 GIT binary patch literal 35427 zcmd6Q3tUuJn&+*efZ`zt;@jv|QK_h)h*2>Q5fl(Xr3#}FQz?p)Dk-Y)R$U(?W?+&Y zagwaqc2Cor?rCf%6YcJ7)18@VMz*`%!O6Ps0_lh~>6xZyW_R6Z9-ZBt*_qw_f9Kp+ zy+G5+{)YU#b?-Uf`ObIF`QGQ;n@{G<$rk8u=x=;O=LO*}*@JT7HG=TT+Es$^RlzPu zg56{{H<`_AOUh!;=?&TYa-KY*h%B7Pc;I-eepCzqijz$BneuUevm{xgxRNzf>)2wX72CbGDlJa|-BX z8uY^EO7&j)}pxQ+3E`k-oD7b*uKQR)V^#-I&ko^$56K1z5=C7?c$D1tvq|H znNuPax6VWBV*5%!uG*0X2>!ep&&BMSV_IWhYhP!0PQ*|Whry=yF+;D9$F*6n^~$N( zkbq;O)`HWONJps#!EuynIC+araUBq@p6O&DJ<7p3v%fuiN6N z+8$TGBd$KN$4adQr*dZkl}~CAyw9iNa6YZoFKlj5c~$?lKZE~v?J!Gg?NzO7{fpI_ z*0s$|>b?GJH<1K4AJj|KJIU7i_$r}HuFe}tcaxC*_!Lk<=acTH0I2eug+Fz3rMx(v% z*Tk}bMryBrC@c&%*@`N2yU&0{ktOJzh8<~|mf5dy7_V=PIZFG2kKI-eNx*hi8lZIRgy!wZ(oc0Vck$E%sJJtE6MaN@Dh%lJ#p`$XBuPab-1&(8g$CXI>c_*FTT89*PXSz(%>$?qXJ$X2Fuc{vhLTjRe!~F6TlE@Bc&olRihncCy1s&Xul-fFqP)eI z5^(%hLdk`Ml5AixTJ?UdUryP@gciT8LGXTGi=*uIxcWEZ>i%!-Vr0)mm%AgOtvd$knJ6xvur2FH=AsKS>xj5?NiJ4PA1zsQ^@wtq_Vx!o?Nzf z+LOulPJ1%h-f2%J+dJ)Cwy$X8AzhMuOphwrUxAiiYyYZ&%YO&lL3ZP7%qA!257jGs zvh~SFTWOj6u&1P_+5A3bA4Fe9KkO;{*Aq(OeWR!Bi6#Ho(2C~f=>)Iq=>)Iq=>$B< zO5QYJPCC{v(2f^jBoFV0J;UbonMYd>g~PwWMrriJJ{z7`e~wn-QSorAndpaoHay>q z8COyqU*P(2?ftu;A<+-}7NgAQZw;{$%068+_-o&0rPomUdxla?0yOLT7T}B-sozhq zDv530j%#!MA==!CYxA9iHs1xrY}Vy8^Bi}Opa|fyYh}<%*ipNwk%alN~;W(MbC^>sdmMHpJb>?u`K^2p`2Tm??ZW z98;Q{&RMO6p~qd#Us9uQ{a4LQCyPO)YBtttWvo8{U#PlXDQmu{V^Z&0tNARb-bD%+ zHp40mJW86UYQ@t2(9jbv=QEP6%?OvwcLBE;lJy6M9Pl5a#F)wbqqyfE#XV=+N65R= z;;TPK{SuykQz9;k_Lta=lCDXj)+({&rU6gUBiVsHY%caZnw+Gx1D_2~q8)ftJgMx! zXTuX4k0-_Ph1-EqwxaRz_&-G{?KybChsJnZKK{9LoqxwL%e&Rf1{NeNAqt}jOO3XGMbe3C~j>c=vB2P%E~0Q6Uywy*@;@ko{k%^a0!|~ z$Msl>sapNQ0K;2N z8%k2O`lSJexB5$r%cuwcDgolJ4J8U*=s08E=HJ_M6v^i}{{wJxO~9Z3V*-W;2_^q2 zq2xa&%=mvniK<;WCI2;!2mf2#^M6lh@nIaqM+p!g$3c9O0P(9hh))3#%NzeA4&r|% zjP8FWl>ALX$=}9dQFgU7Ir-n{H`b&2J62-kTa8P#R*5BlZ@{y#`LgP7>c6dX4SJ#; zXB+-c>)bqHi&-!2$SpDB=!E<-F4?6_QY(AZVLclxGp{xlHZqP$YPJ@DSufOz}z^k|6?>c6EuJfDLCqpk=d}qSl(ATki4%!nU-i`CvF|uI`Fv)Cb&NPsnDD7Cu|! ztlD2++vGf0)m&BETy?N6YDL-Z<~>ynXkDPyAKugKsIRWtk3#dQ0IoR)&UX3t_&tQX zXKR@^*ySm6`(1Lor%YXIXSWnQ>2Zhkf)EzO+3E8HN{Iv9;1cbR&Kl+5-zOiV^s7RP z(5zMIHvw~txj9XLR`1j(E0A`KlKkw?uAK%5SnOtauYW8Ef%GGXcA~Z&S++*>Xn;;@ zNjLIxAfqLtIa5Jp_&JuPKG-cP-yh*V22mgPXHU0hsPe4-io&dzGgZvT=BNl~=)-Ey zQgN&8)aQrjD_dCu|47;QG9iFf=C^=p2?RY#W zNwk2`w2&t(5i>^9P>;v36wPpTcY6Zuw#=x-?eU|(&ND8lBNR0Qi-np-voyk^7EgPJ zhbU&_AQlq&Uvx=Ga+kGvHf=6zBh~0s<)pOxTr?luItjUxH0smCxsUhO|JmH~e#=x&{u@t!Fkc)hA8H>u zJhb%g{Ehv4r*aD~?Yp?|E7IV)D=!Z1y}JKiam9FX#c<0=-QD6nleq?fwO4w6Z_Ci> zt1sPKw{3jgwh_yS>+ZT;cUMnZT)2KzNy^# z_i~qw=PnyO{C;lnM{|UNEg$9y=~)*V`WgnZer8$n2}UABg}qG=h^^gLdQ|>Mrs>}& zf1*miQ8y?+nv`2xEOt}n5kYWahK@m&`Cl`wdfZ=&)vxJ`1KZHfoTQzR5~Y1iyQOHC z#@gY`fT2HPlej-)yB2G6Zd^4U16jv3xzVMiCCjf-VW`(M0qQl)(_+HG2KMKw69n`E zs^b4zbnTaYtQQ0j7pC0hPyZm1isgtN_qLQgb@ zKe0EkU5tK{`im>W-SbPee^ph-O+5c>0lw3EHX-QL4rx6@_l#0n{g9Rn`7L<)IDC;-9ku#N*H?Zz*)?!vzI=-M(EwtUuTFMGH zjW5{LZ+ab&+4r)Rj%O|H-TlFWMS}~job28G>VXgDFC0w2aul~sf1a5$l{@EB{l)r$ z_SX*do2Clpzn(EQZ}IC^f-kz_?KfS@xR?P<3)X43Ik~-c63#>kZq2mfK@-^D!2>hb zNd(rwgy~1t_%(F{Q0leeNb35;J8xKTXkV?-`-anrV3YUxBBF2lLJiJInC5l=OR@Lsg zVV20NVlx<`l~wsZyW7@#`CZ zW+~x}r%{WR+T5JLjNweWsYURknE%0R)EG-G!>K$oO#wVQRg06=u&DLTM(j#qONL*o zXI8_aSq-hcIhpo1wj-;Nu2~Jeou+?P3ZTOc?cg11y3_!n>+{Sb>vUUjWSedmj%jwF zMc3z<%@}R`pKb%}87;{nG|v;hS!R>84k#p=Tj_DSkW!b{(}hV`9+R6WmRr%{A*RNq zQi3R>%O<+e>BNVS#T!=XF@lVf#iyu@3G6PqV^T>x$fZ(cxjC96#k6-L0QTZv=mIX_ zy19kDHLuoBS#vMtU(6pgja!%e`fB&lr2b0{Gcffk#?)YAqdEZP^rs0tG4jK@KN$x1 zhZvH(KVTGt8IWe|$08obsvJ)55 zV7d3KOTa;Qt;-&siC|nm6Td_=n#jxXGl6?RlK6mDC?STX!cXonHaey;Q44!-$DU?~ zMzUwnK6y-CrrnG>#F4bm7gLkXYAnd?&aL>_up2wa=#$H8JH24CO}3Q)60aXkqMO5%4djJ;#TnXh{k@OCJ%>vJ`k8ONnQB z(ivKz;{mgaO`j6)@17nV6pxCI>6<@saPB8V`T*}R2sbR8zjY1yG)g}$$L03G^v$7q zv2J?w^=h=PdGGtvqu;I;9d%>9O?Bcyln+qlm&NM3u{XrV`lh-Ei49Myl8aI582e6a;!P}Zu>4(zco-SN3t?NOXft#4WIB2td+|jvOnE_P zo6l7m_5_?-PD(s~UMAz_pcGvPuv{w(c*oxD62n?SOxmqd>{%F#&E_*}qq!^8}g zjDzD?0ZJR5*3*TA4s932v~3RtoTq(60$ECjmn9a%zJTcW(GZbsydaj`9-uJ=kO~b> z-@NMEAnxgM`TQ7S&&n8NsK)@&+UZBG&56Y6`STCRn#yF09o?n0eyBXZOg@t&NpbSgz_uGfrMZ%L6u_6dvQuc%?9aLY*X zEpMdd*~klRk(2%jOV_V5gsg&rc>`@j*5SgD@^S0ay;UDra{8+x`CCV7Zb=iCead6$ zu;Z3_!csj$i=DTM?=(+X4k|55uA_PR4Dcn_!y~IEEKe!$>#ny=ST@f9UvTs>!W;ydJg}IgwS&1`56FLnG*C#&MS}T6t@KFj`+)go zPTa}lM{X`f=6m#8l()Y5wk+CAOc>-wEKo@;*y&9l*ooLmCLZmh-1O-8k&8Y(ee*3f z+s%3KlaF}oY?8T{Ys#$4K##BH?r&9WyrBZjNJJ7DP`-grsyG&)WG!U*L`BUjPBjj zqi?%nXx|B3qu*uucQG*b9a)r6KRx;b>hx_AG)A1*jc%{eM4`$pvgwMQ16w5~mR6)^ z9z>|iB|A55F6|0B+g&n5htQ^9np>%IXB=J$zU z(!R>%_@Q^4r)9U()$L@91J$<}6jXp;lWoXU${nnBGiDBx1LVXI*fSwTt;`XvqsmYs z>Bt{xi=~3|9UI1jon8zNbLzq%-;Lo4QC?2s!5BhWY_&Zf)0=K^sLwc|F=TX+Sd}Hm zP3t)JZLrqZJDAw_hGj55iN`fJsD~Uv33E1$!RY?e8>mJtC=L=I-hAs^GDhW1Pm??o zJ`(7&SS?6V3_3=jg0K_FzV8NASasT(dMILSl|3u;wS=YdI=NMCcfqiF!eq$ER#cRN z)tht)jG-d+R+=Ar!nhk(DK`Ka?couPSY?{KL}?cJk{MMOmt&<{$QJ1u;>7tn^<7}X zb{`Oe=C!Pw!!&O*>WxZKChtn))IbL&F(7tKkG>JuKofiO?Q?8?-)1^d8v8?0f(&(3 zG7n=t+#>iGZ8X(jrC7CMoT8XncEi1BlY?$&*b9oiuZCcR+4UTmQ0=lTPqrX5Yr zZIwpN(21Q8*cuupG(*r$&0VZ{jSGSgzQ9@;%nMW$*p_69iGSD&w}sp=!&qsC-%&M! zpUn$fBN-e6l4Q~Vwn~Y7Y3zzxq~Mtlwt9m8P*1kQ)#mqzwUVzLTT*6+L#CNv${UvB zt7MQyZ@9}e8f3E0aJoldgIYn|MR_#}QfOf#i>qEL*@y?wK0?oi7uSR(5ov7Ehn*r3 zzM!Hy+c6DrTisr5f!3VwnS%u+xY95QAM-BxBOJhR(CG%xPqVZoQ9z)OSb_#>et9U{yN;@PH)gf_ivC)>D;9*JnY&J zlsp6;em4j^^VE2eDlbq5u!24)sWi zLEFKH$B=C^%PTS3^%NZ8AOd_3oCbg~W9m&CVx<_(_XmAWs3?dfX<82&k|)0&XA-~} zU}(5QF;m6eF(%@$k?jCD2Q-CH8ZwhD$Oj^XcGo#zTE$7g7;uz=<`4H2impq^@zYP;}+JlL8JHf=XzUvu*6ux61+_QrF4W1f8OsFr+2Jv8Z zbv01Tb&;Iof=VZ%Ntz6DHI4I2rv4gV;|+?F$rqS;-*kqB6y*)La_~S&%Ldqzb^(2% zHr)X)^#pa&vq-lYOu*HZE$vBz29Zf$JOS$uloU{e^_Z4hCRO<>qk~|-6OPfxVvoTp z=`k(M6}J1R3yQDzkG+CS2Fr6Cz|VU_O{83YTV8~^l7_J#!;JBI1oW^LyasD$Goph=9FF<~8JL+2=7R+Ms}!*$Ntd9IXxK^hI0+|xt=*e8?& z0Xx6Oe#uKN)%Q~lqKTZ0MF9hQC#E|xaccTsSUmy$9>fF2E`@SNM#`p zR5$kJD)GqJE0m2GtVafFdi3|jrrNsO*Q>_E1(5Qa?u3ZB929Ho%(o7h5!;~ANtU{o`UJ2CQOS^Fo-*GTL^&&3=YVJ;u_Q7h6-6Ubb zgN>9-16gL^4`>5W_jAG$nNjc$5Y1H)h0i(R=s~HFY1k<4k4;sS#7i2@#WaNp{3PJn zu^Y%#q&f}=w?+ikU-o_jy)k(&Psly~viF-TIM^uC0=-S>DPa?SZ|PDnFo!bPe<`Nr>t>GtrI!@WnNv?}R!r z-DIx(L6@%-9spT?P}F&j3>Zwvn)AOLybN%UKjdK#1N0DRNAor3Mdn4QsfEtz(LaV# zhiCK-l2v*N1h$g2G}wSNGCTta7l>5yEMdwY1z_Puzm9{MItF8KLJ@sxeNk84DF0pR zL221*th_Ve?)1(wrkE+Dv@dKixj>xG7!p}#jPgHhaHJH-#YK3jd)ed^v-74(OsDb0 z?aR!OgojSZCDxcQQRc3j8M6(f9HBlH`QZ+0K#7PckBBKAz06wtRKE6*6F~arbDjY3 zyql%s=%}!om zt<-Hk6m_z2|25Xl?Ex)ZF-9h{#a6@>YPiZr$VhWXf@z#Ep2INaEA*s^ zaaOcgUnbYJh72I%VL0=gEdw1SW#1&v1)=k->5^1yW+XYrZMS90_-dp0IdgT#0kNck{UKpup0;+kVX=EF&@ghaMp z9z?eea*nWLlW# z&35UPh=VdWBsuEFpdl~;lBFu=Y$;`~`)N3|TGU7*)FTl?6jF)PiDyvB)Qn6A;0(N| z2Rgn6J*u*Qv`lSqQ;i^&) z^M{;L(i)tr2(M;zlTycsBCWf>GJnF-yIit)N2a}-;% z01_BAZ%UWMiX@Uq+aK6hHW+R!+f^_cy_N>PWjj3%Fda5k=zIwej48z7yPGnl4d5Y@ zHK96|r(*|OkPU;$y!loa4!Uqy8}ktyhJA(-2+U>T6qi`BNkwUs>41ve7#!*@sDV0m zP+`+%m|)}!#7$es@FNit!X7=KzJ0DzegSxpQ!*HFw!OiW7SG@ybpYoqfFQ_)4F`j7 z6ypwsN$4sO?IaRgD!M3a|wKZ21? zS#dPw1EO_DxQPU$k%aQ?>U@>TU;6(;0(Y740%pJ)Ly5L>rMmG42U1c7Y4cK+VLrQ-&VaZvmfvDyeTx6PL5L@49Pt)y{z`jR8*~M&gqH0$E|<0stSqMnq^=pM zKFBZ`F=$@wEU}$RRR>WXkCJ(`x&+Dhi=HuxA$$dP%Uh(nRjxaw^dANS;x0{Fnks2Rqa3@TyJ$$*rRQ;L~;x7AgR zw7dTkhx}J4D;ew4jvA3Rg39I!$`}{33=!>1%$z%AhZAEoWi011hak0662}eNu1n0Q zFi~9JP(sm=cpx4{ywgVMo;FGV>A5c(^K9$kb=R?N14n(6vV-Bk^8co&O-QA3P*rn> zl-wAN+$f(g$8?I*NSEq6Ad8LHrkT^Yh*+h%x~2@Bd>yh*ax0VIUq*X6Qfh4PcDvh2 zLim{k)$Il#?c5mwHSbj(CB~w3-EM8nitot7)-pp{j!jkwHY~tg&>K)fY`TxF?Eq z-e%4-=>T>Bl4tLkj}as5Uw|l=$}c?p)RxaSwd(0pT4qe8K0cc7n4zRlTQ~z7zbZPC z5^}icNVF|}Qgz->^8Ua>PdF{%D?t7ET1n{3Qn`sqf+nhLPfJbhshpz^!Z2~EMWS}b zb>j*IJ8ps5){LG1A!Dyis(}y|3#rG&RYJkZlz8QS1Z*s_+$>QE?uPK(=thh_o8?7c zN__S3^6&*N59NFis332|x7Ku?OF91{9U4cNEdSPw>!4UVr;Pniq`7BS<=A7wWb$K` zpF0z><<-2xhZ!N%Yr=dW#HH`=sNoZGSICqq`-@@-L{pe_#fPL6B}dh~KV&*FEgySI zj~N8jxgz#*Q|=#p`4;rZG)uFQYM_l?IyY-uZ5yJ=pFUTLxxPD#GkH7|YYbN_Uc1WRnxrJBoK%-4dI-oU|DbJsb&h=p>j>vp zxBZmtqaecor7=7M-mFpLiDKhr!TRfiuSXTxQOf|QgI4r>CJjI+6&UYs`y zv)3QK73e?%o?~V$RG#F=b4iZFB0Gshu98uXi~$|-5Pu2a6&B*|WT)6Sibu!?pqFRZ zt~XFKW%wGzB}PFo0`cY_d-aBNkOoeZ_fwJ`ts!S-CW@u>0Q-t;9Q`VT6he_bfgnJ^c|e6R`)H?v2w1Hy@y~7wgMe8 zoe$%Q5Q^{?0>v-CfvrRJdvqb!X-_+{et1tblf7-acW;wKn=O)Az1Y(B?qx+bi&|;h z7tM4cf8%yKqZay>nUn_TXb#<$hP*Dx)6Ty#LI=+z+Uberu{uvc#u0t`t_}f({m2r;qrCh13q#%c)}Of51&TU@U8~?T13x2*8YXC?dQVAp9@QWF0A}T)~?=+kF7bTrN3HMV4Cx3Nrq|8$AxB- z?bGZzrs7YW?4g3Z|_7dHq4W z;kyp6+X~h91#ADjNM6}+`>hhZ${T5YK61i4A^1MZ5YmBZVAWmA{9e<2A+z5U$yqj3 zF(H(&hYtlI_gFfv?djfhoaQn4(iK#zukV-;wkBj4qct(MF<_^efoaPg(j!Btq>Qt#|jvPHc zAv~|pQ#$OM5OyU&z3zJZgs??Hy){y5Q~e_MhtOyqB?XJY!*R+Wp-56AMngpDXp% z^j7trx^F2MSUupnymoNqQ1Nx|q-EPjX~LY4i6$(s-+jq@(K~SJV&}cQ72|m;hD_sm zt0KkClQ>Ew%t=~1bYNurgiv!oMRlb3$w}d<)V13ug&nDDcT5VEsXC4nS4s&8NK zp8hTOt#j$^_q*1`y;b+=z4v~3uwud@vinHB?Rxcie%bJv@%(M?Teg3ciFOcCE_ksW zH$<8bPYBN{>%V?d*r4!ie;dS`7BX&y^9r%TIa7HBv1`Xf$iZ%|{+_Fvx z^~z)EaQGHhXFq!!*gYvMjJt~zm5&tNIx-nmCP zmiyL)gO;hsRt@bLY#QD(vUT|BspYGOS_Yrz55Gz`XH}a%NfQc|fHQN8NWd&CsRX_nv0wWMC3CQ!r9bdw4{;ePei(c z6GHb?@_`R+y}o9sbY#t~`6J8k$dSXx?gS#9lM(-^NV_y?4H?F5jnV9P%CSg_Qh4%K z#htT}=U<5UPDZ5Agb-HBU6GT4$my3Sgx^pU#)jeM31K_*MY{DuV_)OIslijdjSpEiP*VaNu6kN7_t?Y8y(8}?`;vv`d_K}i1 zg_D;3q%u|z$G9$?xOigV&_&;+;KkseYdlYktU4HZ7V`Go6uu{hkFE7@?yJ9Wpzi>Q zCg2B57r~~**Xu?wo~I2(^eqQrv!UQ|`eKEmS(IiQhj%m0sNs2h7!VL%t-v-669}EE z6|BAP9l;#zVns=ar7uDVPo~ff&rfQmjVqKST7282uw9Yi;z?mm5(rXL3IrLuBoGE1 zYbS+u%HnMrhGu$FSz4~clp6XBMirYr=r@RIzANolFoA7p444WUEmY9NZ6Xzf%~T+p zmEH#rjayb|+HzmyPz#e`<`a-AQaVa%$;kW>%2v`4ZgU6I5MF2qrfdxci-4-hM(?vU zF@1VKwb5}vHH4|be1LwAn2$1q&zXM#raj(we9$yx>OCGwU)5`#%30WJe$@)5&FwJv zT8U}B@8_=2nReCJ)?HpVxbpJj_X<~y7p@xGJYKkF=oDsa`<+#jmPTS*k=VBFQpd%P z0oTQo_wtsH=fNWz&s!N;jnN*P5L&0wGcP>b_iX>6zUS_x&mT{p-^;XL+d#oZXuvhs zkBoo|;SvB1FQU(u2<3)?^~2k5t(p+_7>Y`nBpM3V4+8>xp<&T^SwXvb1iZYHK_n%2 z3qFM+JjGUuX4*%yCz-?a^OAW_8tLu5X<9X#<3{r#sy=Fdfqs1EPBztkGc<%X=fe5E z^Kc4Q_MVSq7WZ1Fvhyz4F4_jFE^fG&y=**tSzl(axfkvwD2^1WW+U0-0DP(Wd}P{P zq!8@PY!S2Pk$H~DF=wQM6eU5rBlDgZ3EzROaB=Xu9aXmnTA}4Hb;%tsXi~mNMetMmsr7WX;jY z3+)jzkDNZsJmFN>ph}NL+?^2#nn3S6m=KyTbB&Lh-mzJapu`>*}#dZf$w9twUjr6kbw31h=k~&2<0C!7x4z zMTSyYeRzE=o>MG=qPSZb*Bs|uLrk@ooaqB;cBYFQTo?7X4Fb_J1<`@X;pZZ4-1%bW zV>7-t)uDPi%oApi`duWCeB8_lp>CQ7#2@|xm*M>p`?C>eM@T8K+$A^nXT=2G1O5`*Jqc6Ol>v|ZQtw>~> z=|igksjKa)?XT+Fch6EZZYhd!C%2D4Ke7;1aTJe6+D=Re9V8Y?$!T!nE^`gz&^^qa zvkH{XZy{Uwyt$3lxnt{?kK-}>SlvmzE>gt3It~A(5eRM-dD=4(`phka{0#=O5(#xi zy#5KHOM@W_>xjZb_#ZEjO+TztL3K@3_pJFCt83NkB1IHDYiJ@xTSp+!duOI_;DDw+ zOuZ}nER@&k-`s!bB1SvcfNR5W#Rx1HL=ZxY+*6I>hcgj2xT+v6zmW($Y<`Y@+RP{E zCuEixxu-!cYeIZO)G3c8#z$TB^*e>z5!otAKuLo|@}9a?6FCS}M-^0R7|I@r96J%v z${irDxtpHOnR^&HFPas@vO@b@o^U{GD3daKIc+z0Xq%ZsYquyZDn=^qn6b3UWMz_k zLuR{5Cxx;k5NjrdwF<=QNuf9aqW|ehVTl4jun7PlEVrJ80Q(REu&!VmnNNy`e!h6D z*bsokh5%be_Dl%7mByHVX2qy6OF3eZ&`17h7DSxkt7a@vW`Tlf=0YpaEs<7=Q742> zZ5BHy!x&6In@+5Jj%OGN()Z7a57y1ti977 zImSH+h1TO4fb(Po7{Z{6a2LMs=isr90#{`PT)nl~N*&v+3ckjO<4DBiM&eMTd_S$f zBkeddX3z6bE0TA7Qc3ioGT+7KM{}D_O;Vaf>E{47e>Uv}*4&lM7OuKpH2f@cbkKy% wT>GtcOcUc#XMO#S=XeR^7ed+vYoE1$0h7}7h;_wK#^BjVM#;|v8%vM>KXd<&R{#J2 literal 0 HcmV?d00001 diff --git a/be0/src/be01/__pycache__/docx_normalize.cpython-311.pyc b/be0/src/be01/__pycache__/docx_normalize.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5583d1a7736ee294ffa7f2f20e40ff5d7d6b4d56 GIT binary patch literal 87799 zcmd?S3wT>qo+l_-k|kSy#�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 literal 0 HcmV?d00001 diff --git a/be0/src/be01/__pycache__/docx_normalize.cpython-313.pyc b/be0/src/be01/__pycache__/docx_normalize.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..963ef0cc7dbd27d7e6772d2bd405de1cacc215c0 GIT binary patch literal 78324 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 literal 0 HcmV?d00001 diff --git a/be0/src/be01/__pycache__/docx_to_pdf.cpython-311.pyc b/be0/src/be01/__pycache__/docx_to_pdf.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b12589b6d003da3cb8dc44e73d8e0cd70d50b359 GIT binary patch literal 4775 zcma)AU2GFq7QW;0&v-nE6Fa|9$TXw{7i_nsRZC#mLLh~0fQqm!N!i*H&m^A2_PBSh zNn-3O%c5OVMS@nW$ZA_D)fSc&JoI6ut;)-8*{$|rAMD5yqmd#(cX{Z`%2oSN^=Z!? z$FUti-5KZH-*fJ{=iYP9H~C{r3yuiD@XRXpcZnsLuxWg))<4!2;30IOE=d8JoggeQP^GVOR2il!7cfmd`$ZnaJ zJ+km2D|g8q7o6iQGCPU_-Pw~vTI#HViFoM5&}ngEo+!FVH1YLA!(v8PQ&VDGk>rG; z>*9zyft3@JlWI&6cO;~FEknelL}GE^cvHi&sLZ7`Oah^=>2?B)>5)PQ^(CXF#jSB-WDEc}}d53{IIW%#T$KTBX#wEJDn;|6!&1=JBGg)+|*ID zxTSH|vVFr`N6zto?b_S4Ye%yb*imPFNAiZz^Gv{*4V|1klfz7Rf;PAAk@^yt#R8$wRIr%G-8<+lEU!|=4x zslA53r!Yd_3Ja|Vd~4BrxWo^a`C&_~-<&&t?w9!sd6?_yS@rrZ@4h&2UycX)T9CtH=-*sbJJr3sBU&YI$Y`|ZyDbT z@TS8mun6+u~ zUeXXNWm~fspJzxz{A-S25K~y-Zisao*MJCXgBk15>|rhPh zMU;0CZ*3eTZHOp&XU-`*vLG$*%DI-Qw>^H>Ih$?RH0Ej^o8t3s6_Kv`deeYAs?Rk>ZfoM@nt4^`8k6VA z=GuIOrdw}&@7o;ai zBuX+Z(qOzx#F^A1K^Kew6a%7X5`-c%z-S@flJ;|*qPDmq>JW=zMoLyNouLycHX}+p z;IvdmN{C*E$5h2MC(Sn>holQ+Qp~>^R zEZHDhgM@c@E-j_x(YQKEs%WArSyu?Pz8(M}jkXI@3>-|TGqnSNDC?#sejXBS5RkVsjg?7tF56Nwi**25E|kzBaV@EwqF@J> zq`1Z8lsT|$M4QR-!C(#jXSpDtj)XR)Wj(aEcH-F%+L%i22KS(%Db(%r(c%aK<6H3bD z-;fd+#X>a};J{A01dV7WM_rP7r0F{aH4Ot$JfvbJMl?KM1yCLfjz!jTYH|j4XJIK! zgC(Z%&*b&YL>g-`_<%B5a0ipGex}60f|y1%fLjEr*ebyim1|2Zd&JGt=j1kn`|s8TShhQR3VcCi&n>3SYD?p z)4)`J@D?hxckLjpkF2T=YX}Yx!}xai(>vi{7EomX30)N53^5=1m&SkB_hH|S-7CGJ zVsB`*wWrv7pwxP>+U|6?w3pMm&@*#D`>0Zsm~6s`noRT_jg>`QE(X@ zo0qgwN3h^Fxc0l;mKAQx(sYRnl(|5W3mD$6rA;O8wu0T@giHK;{L%|I1`2$M3zxZY zkqh4w{D#nD2wjHIYY6>@u*neGp}8t}FS{;|ERN8M`{MD%bzN4gIue2i2 z`R$T_XMr<3-o>9TNh_YdqNi_p?(X)NR<^%X+P|QWoH4xJcfI{9 z-u{wzpzIx}`~+-h8M02kp?3X_l6v2xYTtkV{iC|;eLBt5e|zRo?{FLYWt)At!}Vo1 z12ka66x&(O65y*hdVqF_lsrX=b+>7}t6p%cX7SK@fmYKMp*J!TG5JVjGGoP&2&OQj z>U~(`miqepAhb&b?OX1aQl*)4F7l7k4KM1t=Dt4B!!AHlB g62Z^FcSAFI9&PF~w(m3s0u|352F~$EiM7rD0S_YNIsgCw literal 0 HcmV?d00001 diff --git a/be0/src/be01/__pycache__/docx_to_pdf.cpython-313.pyc b/be0/src/be01/__pycache__/docx_to_pdf.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e1bea650a99ae0c6fe43614c95d0b5134dc12f9 GIT binary patch literal 3854 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 literal 0 HcmV?d00001 diff --git a/be0/src/be01/__pycache__/export_applications_list_xlsx.cpython-311.pyc b/be0/src/be01/__pycache__/export_applications_list_xlsx.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b624eb58232a96a8186a5688a1a43a53d2c4458 GIT binary patch literal 6347 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 B@{0ff literal 0 HcmV?d00001 diff --git a/be0/src/be01/__pycache__/fill_template.cpython-313.pyc b/be0/src/be01/__pycache__/fill_template.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c047d009f72ef8bf1eebde6ef5985e55582f3b9e GIT binary patch literal 1812 zcmbVM-A~(A6u-7_V#iLPfK(c`wYOB$h}1%>!p0CoVw8%74XT)^m6FObi4#l{$Gvt+ zU_zqW!_ux2baX1x`oNyX-uf@>KcE9NxN9Hw@zOUcHdX7>?sa0IUA0ZSme0Az_k7*o zImdgEhz!_T{nMNY0Pw3P)CrZnwU_1q_zI{11C>+xQ4Vt+=P^&YFe=0$hKQZP(Lg+i zg9xNyJ4lN?)Q+vhks3ct)xa4A031>qR52~9Qd;hYYN!oN!)XpH>98vIT>(J10Z2!v zFFx_+Ve8u7_pd5_%~ZFI>Nd6mHSB+L`axMWmy+IzEPSX2Ix`<)owMjXoLCWzV`(C5s&tG~Yv~J&XFMosy+7{Q;Ef zFO^-p>}uIkrZR9t@)mJ`PH zvWbnNVYyEI5~htz%W-uI1&iR?1^FohYb?eQcu9AsC=XwtClhs$=34#BYL0H@wQHtf zdC9#{7!*d<6G+HI!KMy~QpbP^nR+F>dITvI=({I-9iOWAF91oe^Ht$}fd>|!+P6tS z&+aAC4wAgzU6!EIxlzGEy;GvEoC@vlYBNYh-_GY2Ou|IO*E`^s1z!UJszH12)s^H9 zG*V6esS1-^1315*AMHPI5}XDdb%iXwagIme1VHo|yZnK00_3<@V2p5hE>L!JXL>38 zl3`^^S<}jw-=KECJXl5-J)l{^%B+^i(Qg>pJ`@WPSh5X^Kn6a6g_54d3^F1(E+*2% z@F$l(9-J5^L2OuA0~5#$Nx;UY<;DV-)dMrY2$v&brewKB#U%pm35U%TYkEYY4$gE< z8fVXSz1sN%vM;!msvf1Ulc8h*{1%E-&6SqbnIA%356?YrK6*d)Wo%_|`E*r$E&%!L z@6AAIz2`jWU7p{N-(DEnhT`Y(oAK(*CTwNb!h zrN&#Kt``pdv4K0SgWbaS-SUULNF+@w>ZYY>Pg_0?wf|IF)C){!v|v)jj7h^VnS z)-)WZGR1V(AtGz)f;ov<9}}@YLN(B79uyEm(@4je{E)VIZ+YExpJTgaWIY zj`KG#{j~xDjf&~kXNdVA5}hH>+jn4(Dgew7CQ^T~lr7UA4USQfCF`tFvMX>L_lys7 zaIXz;;h%u~1RQ$;(9cTalJv#Ej|zIEp!HMzkCgre=~=LWdv9rI7f`mE`Gc|Db^ycG zxeXz@Bg)nDi@mpcKbv2=z9Amlks|92@l9!TTT&LK+tO0g9cBAS%YEstw9<4}S@>w@ y_{r5%-*$Y{@pW-c-#Fe?brvhPDl6RWPaj@g7}f3@0N94v9Q*|as<2i7 literal 0 HcmV?d00001 diff --git a/be0/src/be01/__pycache__/official_to_data_blank.cpython-311.pyc b/be0/src/be01/__pycache__/official_to_data_blank.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d743b0987de1c1fa3d69256dd198fa1658095ac3 GIT binary patch literal 41188 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 A=l}o! literal 0 HcmV?d00001 diff --git a/be0/src/be01/__pycache__/official_to_data_blank.cpython-313.pyc b/be0/src/be01/__pycache__/official_to_data_blank.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba546b4e2cbe5cac26de26ca8eae8ee7eb964146 GIT binary patch literal 36812 zcmeHwd30OXc_#>NAPMf9c0ExP7l{kFiCVdV;v$KZM3B@%$q)#D1Vr+I0`Mr&ODrdy zhKkz}72CDkIAbb~BQmK+Qk_<^*i>?#;P3%a;IhTVy%eZW&acjl@LZV7zyeTG@b+}D#yMuLg!|S-CCbz9r z?Tu|_C%tj4cC-8W^e_mse|~gY$UXQM^Z7n_@`kEUg`W<`3E=U?4;Yd?oiDXDm+e+lqNC-O(|_=ZY8fgT0I z4KdQR2IBk(syem!ti=C@DN_XuS-$s?7@dlZiO@B^Jt2Vii%6q~6k%)%0mXP&9?AKIOgv$HXSd=9RPzXAS^W zNg$0z;6<{mPjX~lP)UL9$@r11IFqL|g-z*8k!C_sP!cIYc@L+QYPr7vZ;;&E)7;(8 zv@HBVGgHZQE#3PO$LOk=zxomLaQfrxEHkk*|0?rW^URRL$yglbi|+AJrsS;KX<~}SiEvtuUCsH>LVD6L3NZXUG{vo7X>-ECurtUBhb)9P}JPFPzVY`-1c z@Nlom+-i4?kD4aW?qRGK$49{-yBV_s{BtEG1t=M^TSqM}xHw(zGRAGQvdQ#0W8&O4 zAPOk${`k6`F)#d>9cJ7M*Udn#*if2I4K~u-mgeWoIG^{*n1@$obMrSAQT-gKNp_nC zN3D$0aiM}aGHS6p@u>@90n^&TjF~1Gn`y$zSjWcQlVQvXA%q#^+(7&2sMX>pIQfFZ zIRaBQTbaMV^g2w)j*}Z5b>ZBY;|`|<7{I!%7u}R`4(RW+mb*+7c9`Cf(=kTd8ICqr zB!7u(){l;CEH2?df_!dXkU2k zDAT`i=?K%`2_Na1-lcmpN0^@G&=GQB1esz5c}3?E{Nzh)*lJ>Jj0+@c6XiN=U%L0M z6XU{{>F^LH_Ad+Iy+~4)4TQ)rg*(jX()>3;6Ba%I#ttuhFz(dAC#CV;WV_4Gy4)t# zZ1u(tTixC`3q)=j483uqc9*+U>(xks(;Ev`;Ef&ARpUdAH+I4_3LRYTVQ(C0jLsX& zI_%y!vyC%*?7O}1ftJ*C{mdvhMdSB-HYx?i!2$@Bm%%aO%XIo?~CP?xH#TNgVku_cOX8qNAetk8`t|1wFhldV=3nNpf{0 zt7=OBcj;M+`Rk^W|1l+X%6w(`@-UybhotQ#Df=$9{#{}U08OR*G9@kISbv+HKc)Aj zuf4YQZMTqKNYV>^x%t;(-${Hck!R}3nq4G!_q6&K*}2ozi|aRjyYsUoRnD3x$trD{ z6QZZe4F3Xuf#l}v@RmHe89>#ff0*+cVt2=?RjexL-g~fT*k1u8HB$I-sSS`Z8v>~h z2InoRGF8BlgWQK8WN(Im&8UV|`(rn&hGI(Ny1g;Z=i@o|Q28#%@-$Y~?682hD2?;R zImWH5H^bQ4+}mtyIo#ZR$au85x5FEU*()fs!(@RT2{sdJ8MTVZp{RukVwUTy26EA@ zxKQ~nseDPPkJC2I#Lf_%H+2uujP?+=GU>RTRhiy1Bp0SR~qFU7x}0Pm!CRH_2i0A>#KPs(j2Ua>KFw&5ix?~QY9QVPA28JfrxT!PcWSf z;b@$qjM60BW$8<&GRg>>u`*)jDiFh^u8f$q3dC$mW6%zi5pyKO$o!*At`&MyJBXKQ zZM-6nbEVOu;Bj6U{mO{>65x4^h6nB z+cUr@dIlK9D`OZ+D&mf+yKbmx0mG$clVtOT1cXsRWEH)3Df;fTy@-O)WlICdp()Z77#UlX2zg%tPc)1&wW$VSG`>)vX%0Dk|LS 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 literal 0 HcmV?d00001 diff --git a/be0/src/be01/build_template.py b/be0/src/be01/build_template.py new file mode 100644 index 0000000..f5db2f5 --- /dev/null +++ b/be0/src/be01/build_template.py @@ -0,0 +1,583 @@ +""" +Build a DOCX template with docxtpl (Jinja2) placeholders. +The template mirrors the original 'Biểu mẫu kèm TB' structure: + - Trang bìa + - Mẫu số 01: Báo cáo mô tả sáng kiến + - Mẫu số 02: Đơn đề nghị công nhận sáng kiến + - Mẫu số 03: Bản xác nhận tỷ lệ đóng góp + - Bản cam kết +""" +from docx import Document +from docx.shared import Pt, Cm, Inches +from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK +from docx.enum.table import WD_ALIGN_VERTICAL +from docx.oxml.ns import qn +from docx.oxml import OxmlElement + +OUTPUT = "/home/claude/template_project/template_sang_kien.docx" + +# Page margins — edit these to retune layout (python-docx: Cm / Inches / Pt) +MARGIN_TOP_CM = 2.0 +MARGIN_BOTTOM_CM = 2.0 +MARGIN_LEFT_IN = 0.79 +MARGIN_RIGHT_IN = 0.49 + + +# ---------- helpers ---------- +def apply_page_margins(doc: Document) -> None: + """Apply section margins to every section in the document.""" + for section in doc.sections: + section.top_margin = Cm(MARGIN_TOP_CM) + section.bottom_margin = Cm(MARGIN_BOTTOM_CM) + section.left_margin = Inches(MARGIN_LEFT_IN) + section.right_margin = Inches(MARGIN_RIGHT_IN) + + +def set_cell_border(cell, **kwargs): + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + tcBorders = OxmlElement("w:tcBorders") + for edge in ("top", "left", "bottom", "right"): + if edge in kwargs: + border = OxmlElement(f"w:{edge}") + border.set(qn("w:val"), kwargs[edge].get("val", "single")) + border.set(qn("w:sz"), str(kwargs[edge].get("sz", 4))) + border.set(qn("w:color"), kwargs[edge].get("color", "000000")) + tcBorders.append(border) + tcPr.append(tcBorders) + + +def set_paragraph_indent(paragraph, *, left=0, right=0): + """Set paragraph left/right indents in twips.""" + p = paragraph._p + pPr = p.get_or_add_pPr() + ind = pPr.find(qn("w:ind")) + if ind is None: + ind = OxmlElement("w:ind") + pPr.append(ind) + ind.set(qn("w:left"), str(left)) + ind.set(qn("w:right"), str(right)) + + +def set_cell_margins(cell, *, top=0, right=0, bottom=0, left=0): + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + tcMar = tcPr.find(qn("w:tcMar")) + if tcMar is None: + tcMar = OxmlElement("w:tcMar") + tcPr.append(tcMar) + for side, value in (("top", top), ("right", right), ("bottom", bottom), ("left", left)): + node = tcMar.find(qn(f"w:{side}")) + if node is None: + node = OxmlElement(f"w:{side}") + tcMar.append(node) + node.set(qn("w:w"), str(value)) + node.set(qn("w:type"), "dxa") + + +def add_para(doc, text="", bold=False, italic=False, align=None, size=13, before=0, after=0): + p = doc.add_paragraph() + if align == "center": + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + elif align == "right": + p.alignment = WD_ALIGN_PARAGRAPH.RIGHT + elif align == "justify": + p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY + pf = p.paragraph_format + pf.space_before = Pt(before) + pf.space_after = Pt(after) + if text: + r = p.add_run(text) + r.bold = bold + r.italic = italic + r.font.size = Pt(size) + r.font.name = "Times New Roman" + return p + + +def add_run(p, text, bold=False, italic=False, size=13): + r = p.add_run(text) + r.bold = bold + r.italic = italic + r.font.size = Pt(size) + r.font.name = "Times New Roman" + return r + + +def page_break(doc): + p = doc.add_paragraph() + p.add_run().add_break(WD_BREAK.PAGE) + + +def set_cell_text(cell, text, bold=False, italic=False, align=None, size=13): + cell.text = "" + p = cell.paragraphs[0] + if align == "center": + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + elif align == "right": + p.alignment = WD_ALIGN_PARAGRAPH.RIGHT + elif align == "justify": + p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY + r = p.add_run(text) + r.bold = bold + r.italic = italic + r.font.size = Pt(size) + r.font.name = "Times New Roman" + return p + + +def add_cell_para(cell, text, bold=False, italic=False, align=None, size=13): + p = cell.add_paragraph() + if align == "center": + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + elif align == "right": + p.alignment = WD_ALIGN_PARAGRAPH.RIGHT + r = p.add_run(text) + r.bold = bold + r.italic = italic + r.font.size = Pt(size) + r.font.name = "Times New Roman" + return p + + +# ---------- build ---------- +doc = Document() + +# Default font + page margins +style = doc.styles["Normal"] +style.font.name = "Times New Roman" +style.font.size = Pt(13) +apply_page_margins(doc) + + +# ===================================================================== +# TRANG BÌA (rendered twice: outer + inner, identical content) +# ===================================================================== +def render_cover(doc): + add_para(doc, "BỘ Y TẾ", bold=False, align="center", size=13, before=20) + add_para(doc, "ĐẠI HỌC Y DƯỢC\nTHÀNH PHỐ HỒ CHÍ MINH", bold=True, align="center", size=11) + add_para(doc, "===== ===== =====", bold=True, align="center", size=13, after=30) + + add_para(doc, "BÁO CÁO MÔ TẢ SÁNG KIẾN", bold=True, align="center", size=18, before=40, after=40) + + # Tên sáng kiến + p = add_para(doc, "", align="center", before=20, after=10) + add_run(p, "Tên sáng kiến (Tiếng Việt): ", bold=True, size=14) + add_run(p, "{{ trang_bia.ten_sang_kien }}", bold=True, size=14) + + # Tác giả + p = add_para(doc, "", align="center", before=20, after=10) + add_run(p, "Tác giả/nhóm tác giả sáng kiến: ", bold=True, size=14) + add_run(p, "{{ trang_bia.tac_gia }}", size=14) + + # Đơn vị + p = add_para(doc, "", align="center", before=20, after=10) + add_run(p, "Đơn vị công tác: ", bold=True, size=14) + add_run(p, "{{ trang_bia.don_vi }}", size=14) + + # Liên hệ + p = add_para(doc, "", align="center", before=20, after=10) + add_run(p, "Thông tin liên hệ (Điện thoại, Email): ", bold=True, size=14) + add_run(p, "{{ trang_bia.thong_tin_lien_he }}", size=14) + + # Năm + p = add_para(doc, "", align="center", before=60, after=10) + add_run(p, "Tp. Hồ Chí Minh – Năm {{ trang_bia.nam }}", bold=True, size=14) + + +render_cover(doc) +page_break(doc) +render_cover(doc) +page_break(doc) + + +# ===================================================================== +# MẪU SỐ 01 – BÁO CÁO MÔ TẢ SÁNG KIẾN +# ===================================================================== +add_para(doc, "Mẫu số 01", italic=True, align="right", size=12) +add_para(doc, "BÁO CÁO MÔ TẢ SÁNG KIẾN", bold=True, align="center", size=16, before=12, after=12) + +# 1. Mở đầu +p = add_para(doc, "") +add_run(p, "1. Mở đầu ", bold=True) +add_run(p, "(Giới thiệu về những vấn đề liên quan đến sáng kiến ở trong và ngoài đơn vị/trường mà tác giả đã biết, những khó khăn/bất cập/hạn chế tại đơn vị/trường liên quan đến nội dung của sáng kiến; từ đó nêu ra sự cần thiết phải thực hiện sáng kiến):", italic=True) +add_para(doc, "{{ mau_01.mo_dau }}", align="justify") + +# 2. Tên sáng kiến +p = add_para(doc, "") +add_run(p, "2. Tên sáng kiến (tên quy trình, giải pháp, phương pháp): ", bold=True) +add_run(p, "{{ mau_01.ten_sang_kien }}") + +# 3. Lĩnh vực áp dụng +p = add_para(doc, "") +add_run(p, "3. Lĩnh vực áp dụng của sáng kiến ", bold=True) +add_run(p, "(ví dụ: cải cách hành chính, quản lý giáo dục, bảo vệ môi trường, …): ", italic=True) +add_run(p, "{{ mau_01.linh_vuc_ap_dung }}") + +# 4. Mô tả sáng kiến +add_para(doc, "4. Mô tả sáng kiến:", bold=True) + +# 4.1 +p = add_para(doc, "") +add_run(p, "4.1 Tình trạng giải pháp đã biết hoặc hiện trạng công tác khi chưa có sáng kiến ", bold=True) +add_run(p, "(nêu hiện trạng trước khi áp dụng giải pháp mới/chưa có sáng kiến, phân tích ưu nhược điểm của giải pháp cũ để cho thấy sự cần thiết của việc đề xuất giải pháp mới để khắc phục nhược điểm của giải pháp cũ):", italic=True) +add_para(doc, "{{ mau_01.tinh_trang_da_biet }}", align="justify") + +# 4.2 +add_para(doc, "4.2. Nội dung giải pháp đề nghị công nhận là sáng kiến:", bold=True) + +p = add_para(doc, "") +add_run(p, "- Mục đích của sáng kiến (nêu vấn đề cần giải quyết): ", bold=True) +add_run(p, "{{ mau_01.muc_dich }}") + +p = add_para(doc, "") +add_run(p, "- Về nội dung của sáng kiến: ", bold=True) +add_run(p, "Mô tả ngắn gọn, đầy đủ và rõ ràng:", italic=True) + +add_para(doc, "+ Các bước thực hiện giải pháp:", bold=True) +add_para(doc, "{{ mau_01.cac_buoc_thuc_hien }}", align="justify") + +add_para(doc, "+ Các điều kiện cần thiết để áp dụng giải pháp:", bold=True) +add_para(doc, "{{ mau_01.dieu_kien_ap_dung }}", align="justify") + +p = add_para(doc, "") +add_run(p, "+ Lĩnh vực áp dụng: ", bold=True) +add_run(p, "{{ mau_01.linh_vuc_ap_dung_2 }}") + +add_para(doc, "+ Kết quả thu được:", bold=True) +add_para(doc, "{{ mau_01.ket_qua_thu_duoc }}", align="justify") + +add_para(doc, "+ Danh sách đơn vị/cá nhân đã tham gia áp dụng thử hoặc lần đầu (nếu có):", bold=True) + +# Table for danh sách áp dụng (3-row pattern for {%tr %} loops) +tbl = doc.add_table(rows=4, cols=4) +tbl.style = "Table Grid" +hdr = tbl.rows[0].cells +for i, h in enumerate(["TT", "Tên tổ chức/cá nhân", "Địa chỉ", "Lĩnh vực áp dụng sáng kiến"]): + set_cell_text(hdr[i], h, bold=True, align="center") +# marker row: {%tr for %} +set_cell_text(tbl.rows[1].cells[0], "{%tr for item in mau_01.danh_sach_ap_dung %}") +# data row +set_cell_text(tbl.rows[2].cells[0], "{{ item.tt }}", align="center") +set_cell_text(tbl.rows[2].cells[1], "{{ item.ten_to_chuc }}") +set_cell_text(tbl.rows[2].cells[2], "{{ item.dia_chi }}") +set_cell_text(tbl.rows[2].cells[3], "{{ item.linh_vuc }}") +# marker row: {%tr endfor %} +set_cell_text(tbl.rows[3].cells[0], "{%tr endfor %}") + +# Tính mới +p = add_para(doc, "", before=12) +add_run(p, "- Về tính mới của sáng kiến:", bold=True) +add_para(doc, "{{ mau_01.tinh_moi }}", align="justify") + +# Tính hiệu quả +p = add_para(doc, "", before=12) +add_run(p, "- Về tính hiệu quả: ", bold=True) +add_run(p, "So sánh hiệu quả về mặt kinh tế, xã hội, khoa học thu được hoặc dự kiến thu được khi áp dụng sáng kiến (phải có số liệu cụ thể, căn cứ để tính toán, kiểm tra, đánh giá):", italic=True) + +hieu_qua_fields = [ + ("+ Tạo ra lợi ích kinh tế: ", "loi_ich_kinh_te"), + ("+ Đem lại hiệu quả trong giảng dạy: ", "hieu_qua_giang_day"), + ("+ Tăng năng suất lao động: ", "tang_nang_suat"), + ("+ Nâng cao hiệu quả công việc: ", "nang_cao_hieu_qua"), + ("+ Nâng cao chất lượng công việc, dịch vụ: ", "nang_cao_chat_luong"), + ("+ Giảm chi phí: ", "giam_chi_phi"), + ("+ Cải thiện môi trường, điều kiện học tập, làm việc, sống: ", "cai_thien_moi_truong"), + ("+ Bảo vệ sức khỏe: ", "bao_ve_suc_khoe"), + ("+ Đảm bảo an toàn lao động, PCCC: ", "an_toan_lao_dong"), + ("+ Nâng cao khả năng, trình độ, nhận thức, trách nhiệm: ", "nang_cao_nhan_thuc"), +] +for label, key in hieu_qua_fields: + p = add_para(doc, "") + add_run(p, label, bold=True) + add_run(p, "{{ mau_01.tinh_hieu_qua." + key + " }}") + +# 6. Bảo mật +p = add_para(doc, "", before=12) +add_run(p, "6. Những thông tin cần được bảo mật (nếu có): ", bold=True) +add_run(p, "{{ mau_01.thong_tin_bao_mat }}") + +# Chữ ký Mẫu 01 +add_para(doc, "") +sig = doc.add_table(rows=1, cols=2) +sig.autofit = True +left, right = sig.rows[0].cells +set_cell_text(left, "LÃNH ĐẠO ĐƠN VỊ", bold=True, align="center") +add_cell_para(left, "(Ký, ghi rõ họ tên)", italic=True, align="center") +add_cell_para(left, "") +add_cell_para(left, "") +add_cell_para(left, "{{ mau_01.lanh_dao_don_vi }}", bold=True, align="center") + +set_cell_text(right, "Tp. Hồ Chí Minh, ngày {{ mau_01.ngay_ky.ngay }} tháng {{ mau_01.ngay_ky.thang }} năm {{ mau_01.ngay_ky.nam }}", italic=True, align="center") +add_cell_para(right, "Tác giả chính / Đại diện nhóm tác giả sáng kiến", bold=True, align="center") +add_cell_para(right, "(chữ ký và ghi rõ họ tên)", italic=True, align="center") +add_cell_para(right, "") +add_cell_para(right, "{{ mau_01.tac_gia_sang_kien }}", bold=True, align="center") +# remove borders on signature table +for row in sig.rows: + for cell in row.cells: + set_cell_border(cell, + top={"val": "nil"}, bottom={"val": "nil"}, + left={"val": "nil"}, right={"val": "nil"}) + +page_break(doc) + + +# ===================================================================== +# MẪU SỐ 02 – ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN +# ===================================================================== +# Header block (institution | CHXHCN VN) +hdr = doc.add_table(rows=1, cols=2) +p = set_cell_text(hdr.rows[0].cells[0], "ĐẠI HỌC Y DƯỢC\nTHÀNH PHỐ HỒ CHÍ MINH", bold=True, align="center", size=11) +set_paragraph_indent(p, left=-150, right=0) +p = add_cell_para(hdr.rows[0].cells[0], "{{ mau_02.don_vi|upper }}", bold=False, align="center", size=11) +set_paragraph_indent(p, left=-150, right=0) +p = set_cell_text(hdr.rows[0].cells[1], "CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM", bold=True, align="left", size=10.7) +set_paragraph_indent(p, left=-150, right=0) +add_cell_para(hdr.rows[0].cells[1], "Độc lập - Tự do - Hạnh phúc", bold=True, align="center", size=12) +set_cell_margins(hdr.rows[0].cells[1], top=0, right=0, bottom=0, left=0) +for row in hdr.rows: + for cell in row.cells: + set_cell_border(cell, top={"val": "nil"}, bottom={"val": "nil"}, left={"val": "nil"}, right={"val": "nil"}) + +add_para(doc, "Mẫu số 02", italic=True, align="right", size=12, before=12) +add_para(doc, "ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN", bold=True, align="center", size=16, before=6, after=6) +add_para(doc, "Kính gửi: Hội đồng sáng kiến Đại học Y Dược TP. Hồ Chí Minh", italic=True, align="center") + +add_para(doc, "Tên tôi (chúng tôi) là:", before=12) + +# Tác giả table (3-row pattern for {%tr %} loop) +t = doc.add_table(rows=4, cols=7) +t.style = "Table Grid" +headers = ["STT", "Họ và tên", "Ngày tháng năm sinh", "Nơi công tác", "Chức danh", "Trình độ chuyên môn", "Tỷ lệ (%) đóng góp"] +for i, h in enumerate(headers): + set_cell_text(t.rows[0].cells[i], h, bold=True, align="center", size=11) +set_cell_text(t.rows[1].cells[0], "{%tr for item in mau_02.danh_sach_tac_gia %}", size=11) +r = t.rows[2].cells +set_cell_text(r[0], "{{ item.stt }}", align="center", size=11) +set_cell_text(r[1], "{{ item.ho_ten }}", size=11) +set_cell_text(r[2], "{{ item.ngay_sinh }}", align="center", size=11) +set_cell_text(r[3], "{{ item.noi_cong_tac }}", size=11) +set_cell_text(r[4], "{{ item.chuc_danh }}", size=11) +set_cell_text(r[5], "{{ item.trinh_do }}", size=11) +set_cell_text(r[6], "{{ item.ty_le }}", align="center", size=11) +set_cell_text(t.rows[3].cells[0], "{%tr endfor %}", size=11) + +# Fields +p = add_para(doc, "", before=12) +add_run(p, "- Là tác giả (nhóm tác giả) đề nghị xét công nhận sáng kiến: ") +add_run(p, "\u201C{{ mau_02.ten_sang_kien }}\u201D", bold=True) + +p = add_para(doc, "") +add_run(p, "- Chủ đầu tư tạo ra sáng kiến: ") +add_run(p, "{{ mau_02.chu_dau_tu }}") + +p = add_para(doc, "") +add_run(p, "- Lĩnh vực áp dụng sáng kiến: ") +add_run(p, "{{ mau_02.linh_vuc_ap_dung }}") + +p = add_para(doc, "") +add_run(p, "- Ngày sáng kiến được áp dụng: ") +add_run(p, "{{ mau_02.ngay_ap_dung }}") + +add_para(doc, "- Nội dung của sáng kiến:", bold=True) +add_para(doc, "{{ mau_02.noi_dung }}", align="justify") + +add_para(doc, "- Sáng kiến này là:", bold=True, before=6) +add_para(doc, "{% if mau_02.phan_loai.giai_phap_ky_thuat %}☑{% else %}☐{% endif %} Giải pháp kỹ thuật, quản lý, tác nghiệp, ứng dụng tiến bộ kỹ thuật áp dụng cho Đại học Y Dược TP.HCM") +add_para(doc, "{% if mau_02.phan_loai.sang_kien_tu_nckh %}☑{% else %}☐{% endif %} Sáng kiến – cải tiến kỹ thuật từ các nghiên cứu khoa học có kết quả được đăng tải trên các tạp chí, hội nghị trong nước và quốc tế") +add_para(doc, "{% if mau_02.phan_loai.sang_kien_tu_sach %}☑{% else %}☐{% endif %} Sáng kiến – cải tiến kỹ thuật từ sách, giáo trình, tài liệu tham khảo") + +p = add_para(doc, "", before=6) +add_run(p, "- Những thông tin cần được bảo mật (nếu có): ", bold=True) +add_run(p, "{{ mau_02.thong_tin_bao_mat }}") + +add_para(doc, "- Các điều kiện cần thiết để áp dụng sáng kiến:", bold=True) +add_para(doc, "{{ mau_02.dieu_kien_ap_dung }}", align="justify") + +add_para(doc, "- Đánh giá lợi ích thu được hoặc dự kiến có thể thu được do áp dụng sáng kiến theo ý kiến của tác giả:", bold=True) +add_para(doc, "{{ mau_02.danh_gia_tac_gia }}", align="justify") + +add_para(doc, "- Đánh giá lợi ích thu được hoặc dự kiến có thể thu được do áp dụng sáng kiến theo ý kiến của tổ chức, cá nhân đã tham gia áp dụng sáng kiến lần đầu, kể cả áp dụng thử (nếu có):", bold=True) +add_para(doc, "{{ mau_02.danh_gia_to_chuc }}", align="justify") + +add_para(doc, "Danh sách những người đã tham gia áp dụng thử hoặc áp dụng sáng kiến lần đầu (nếu có):", bold=True, before=6) + +t2 = doc.add_table(rows=4, cols=7) +t2.style = "Table Grid" +headers2 = ["Số TT", "Họ và tên", "Ngày tháng năm sinh", "Nơi công tác", "Chức danh", "Trình độ chuyên môn", "Nội dung công việc hỗ trợ"] +for i, h in enumerate(headers2): + set_cell_text(t2.rows[0].cells[i], h, bold=True, align="center", size=11) +set_cell_text(t2.rows[1].cells[0], "{%tr for item in mau_02.danh_sach_tham_gia %}", size=11) +r2 = t2.rows[2].cells +set_cell_text(r2[0], "{{ item.stt }}", align="center", size=11) +set_cell_text(r2[1], "{{ item.ho_ten }}", size=11) +set_cell_text(r2[2], "{{ item.ngay_sinh }}", align="center", size=11) +set_cell_text(r2[3], "{{ item.noi_cong_tac }}", size=11) +set_cell_text(r2[4], "{{ item.chuc_danh }}", size=11) +set_cell_text(r2[5], "{{ item.trinh_do }}", size=11) +set_cell_text(r2[6], "{{ item.noi_dung_ho_tro }}", size=11) +set_cell_text(t2.rows[3].cells[0], "{%tr endfor %}", size=11) + +add_para(doc, "Tôi xin cam đoan mọi thông tin nêu trong đơn là trung thực, đúng sự thật và hoàn toàn chịu trách nhiệm trước pháp luật./.", before=12, align="justify") + +add_para(doc, "TP. Hồ Chí Minh, ngày {{ mau_02.ngay_ky.ngay }} tháng {{ mau_02.ngay_ky.thang }} năm {{ mau_02.ngay_ky.nam }}", italic=True, align="right", before=12) + +# signature table +sig2 = doc.add_table(rows=1, cols=2) +l2, r2c = sig2.rows[0].cells +set_cell_text(l2, "Xác nhận của lãnh đạo", bold=True, align="center") +add_cell_para(l2, "{{ mau_02.don_vi|upper }}", bold=False, align="center", size=11) +add_cell_para(l2, "") +add_cell_para(l2, "") +add_cell_para(l2, "{{ mau_02.lanh_dao_don_vi }}", bold=True, align="center") + +set_cell_text(r2c, "Tác giả chính / Đại diện nhóm tác giả sáng kiến", bold=True, align="center") +add_cell_para(r2c, "(chữ ký và ghi rõ họ tên)", italic=True, align="center") +add_cell_para(r2c, "") +add_cell_para(r2c, "") +add_cell_para(r2c, "{{ mau_02.tac_gia_sang_kien }}", bold=True, align="center") +for row in sig2.rows: + for cell in row.cells: + set_cell_border(cell, top={"val": "nil"}, bottom={"val": "nil"}, left={"val": "nil"}, right={"val": "nil"}) + +page_break(doc) + + +# ===================================================================== +# MẪU SỐ 03 – BẢN XÁC NHẬN TỶ LỆ (%) ĐÓNG GÓP +# ===================================================================== +hdr3 = doc.add_table(rows=1, cols=2) +set_cell_text(hdr3.rows[0].cells[0], "BỘ Y TẾ", bold=False, align="center", size=12) +p = add_cell_para(hdr3.rows[0].cells[0], "ĐẠI HỌC Y DƯỢC\nTHÀNH PHỐ HỒ CHÍ MINH", bold=True, align="center", size=14) +set_paragraph_indent(p, left=-150, right=0) +p = set_cell_text(hdr3.rows[0].cells[1], "CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM", bold=True, align="left", size=10.7) +set_paragraph_indent(p, left=-150, right=0) +add_cell_para(hdr3.rows[0].cells[1], "Độc lập – Tự do – Hạnh phúc", bold=True, align="center", size=12) +set_cell_margins(hdr3.rows[0].cells[1], top=0, right=0, bottom=0, left=0) +for row in hdr3.rows: + for cell in row.cells: + set_cell_border(cell, top={"val": "nil"}, bottom={"val": "nil"}, left={"val": "nil"}, right={"val": "nil"}) + +add_para(doc, "Mẫu số 03", italic=True, align="right", size=12, before=12) +add_para(doc, "TP. Hồ Chí Minh, ngày {{ mau_03.ngay_ky.ngay }} tháng {{ mau_03.ngay_ky.thang }} năm {{ mau_03.ngay_ky.nam }}", italic=True, align="center") +add_para(doc, "BẢN XÁC NHẬN", bold=True, align="center", size=16, before=12) +add_para(doc, "TỶ LỆ (%) ĐÓNG GÓP VÀO VIỆC TẠO RA SÁNG KIẾN", bold=True, align="center", size=14, after=12) + +p = add_para(doc, "") +add_run(p, "1. Tên sáng kiến: ", bold=True) +add_run(p, "{{ mau_03.ten_sang_kien }}") + +p = add_para(doc, "") +add_run(p, "2. Tác giả chính / Đại diện nhóm tác giả sáng kiến: ", bold=True) +add_run(p, "{{ mau_03.tac_gia_chinh }}") + +p = add_para(doc, "") +add_run(p, "Chức vụ, đơn vị công tác: ", bold=True) +add_run(p, "{{ mau_03.chuc_vu_don_vi }}") + +add_para(doc, "Tỷ lệ đóng góp:", bold=True, before=6) + +t3 = doc.add_table(rows=4, cols=5) +t3.style = "Table Grid" +for i, h in enumerate(["STT", "Họ và tên", "Đơn vị công tác", "% đóng góp", "Chữ ký xác nhận"]): + set_cell_text(t3.rows[0].cells[i], h, bold=True, align="center") +set_cell_text(t3.rows[1].cells[0], "{%tr for item in mau_03.ty_le_dong_gop %}") +r3 = t3.rows[2].cells +set_cell_text(r3[0], "{{ item.stt }}", align="center") +set_cell_text(r3[1], "{{ item.ho_ten }}") +set_cell_text(r3[2], "{{ item.don_vi }}") +set_cell_text(r3[3], "{{ item.phan_tram }}", align="center") +set_cell_text(r3[4], "{{ item.chu_ky }}", align="center") +set_cell_text(t3.rows[3].cells[0], "{%tr endfor %}") + +# TỔNG row +row_tong = t3.add_row().cells +set_cell_text(row_tong[0], "TỔNG", bold=True, align="center") +row_tong[0].merge(row_tong[1]).merge(row_tong[2]) +set_cell_text(row_tong[3], "100", bold=True, align="center") +set_cell_text(row_tong[4], "", align="center") + +add_para(doc, "Lưu ý:", bold=True, italic=True, before=12) +add_para(doc, "- Tổng % đóng góp phải bằng 100", italic=True) +add_para(doc, "- Sinh viên trong nhóm tác giả cần ghi rõ là \u201CSinh viên khoa/trường…., Đại học Y Dược TP.HCM\u201D", italic=True) +add_para(doc, "- Tác giả là người ngoài trường cần ghi đúng đơn vị công tác hiện tại hoặc đơn vị công tác tự do nếu chưa có đơn vị công tác cụ thể.", italic=True) + +add_para(doc, "", before=20) +add_para(doc, "TÁC GIẢ CHÍNH / ĐẠI DIỆN NHÓM TÁC GIẢ SÁNG KIẾN", bold=True, align="right") +add_para(doc, "(chữ ký và ghi rõ họ tên)", italic=True, align="right") +add_para(doc, "") +add_para(doc, "") +add_para(doc, "{{ mau_03.tac_gia_chinh_ky }}", bold=True, align="right") + +page_break(doc) + + +# ===================================================================== +# BẢN CAM KẾT +# ===================================================================== +add_para(doc, "CỘNG HOÀ XÃ HỘI CHỦ NGHĨA VIỆT NAM", bold=True, align="center") +add_para(doc, "Độc lập - Tự do - Hạnh phúc", bold=True, align="center") +add_para(doc, "TP. Hồ Chí Minh, ngày {{ ban_cam_ket.ngay_ky.ngay }} tháng {{ ban_cam_ket.ngay_ky.thang }} năm {{ ban_cam_ket.ngay_ky.nam }}", italic=True, align="center", before=6) + +add_para(doc, "BẢN CAM KẾT", bold=True, align="center", size=16, before=12) +add_para(doc, "(Áp dụng đối với cá nhân đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại Đại học Y Dược TP. Hồ Chí Minh năm {{ ban_cam_ket.nam_xet }} là tác giả của bài báo khoa học)", italic=True, align="center") + +add_para(doc, "I. THÔNG TIN CHỦ THỂ CAM KẾT:", bold=True, before=12) + +p = add_para(doc, "") +add_run(p, "Tác giả đăng ký sáng kiến: ") +add_run(p, "{{ ban_cam_ket.tac_gia_dang_ky }}") + +p = add_para(doc, "") +add_run(p, "CCCD/Hộ chiếu số: ") +add_run(p, "{{ ban_cam_ket.cccd }}") + +p = add_para(doc, "") +add_run(p, "Đơn vị: ") +add_run(p, "{{ ban_cam_ket.don_vi }}") + +p = add_para(doc, "") +add_run(p, "Tên Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH được đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD TP.HCM năm ") +add_run(p, "{{ ban_cam_ket.nam_xet }}", bold=True) +add_run(p, ": ") +add_run(p, "{{ ban_cam_ket.ten_bai_bao }}") + +p = add_para(doc, "", before=6) +add_run(p, "Với vai trò đối với bài báo ", italic=False) +add_run(p, "(☑ vào ô tương ứng)", italic=True) +add_run(p, ":") +add_para(doc, "{% if ban_cam_ket.vai_tro.tac_gia_chinh %}☑{% else %}☐{% endif %} Tác giả chính Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH.") +add_para(doc, "{% if ban_cam_ket.vai_tro.dong_tac_gia %}☑{% else %}☐{% endif %} Đồng tác giả Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH.") + +add_para(doc, "II. CAM KẾT NỘI DUNG (☑ vào ô tương ứng):", bold=True, before=12) + +add_para(doc, "- Quyền sở hữu đối với bài báo trong nước/quốc tế", bold=True, before=6) +add_para(doc, "{% if ban_cam_ket.cam_ket.quyen_so_huu_1 %}☑{% else %}☐{% endif %} Tôi là chủ sở hữu hợp pháp của bài báo hoặc được chủ sở hữu/đồng chủ sở hữu đồng ý cho sử dụng bài báo có tên nêu trên làm sản phẩm đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD.", align="justify") +add_para(doc, "{% if ban_cam_ket.cam_ket.quyen_so_huu_2 %}☑{% else %}☐{% endif %} Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH: chủ sở hữu bài báo (cơ quan) đồng ý cho tác giả/nhóm tác giả sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD.", align="justify") + +add_para(doc, "- Đồng thuận của đồng tác giả bài báo trong nước/quốc tế", bold=True, before=6) +add_para(doc, "{% if ban_cam_ket.cam_ket.dong_thuan %}☑{% else %}☐{% endif %} Tất cả đồng tác giả đã biết, đồng ý và ký xác nhận cho phép Tác giả đăng ký sáng kiến được sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD.", align="justify") + +add_para(doc, "- Cam kết bài báo trong nước/quốc tế uy tín", bold=True, before=6) +add_para(doc, "{% if ban_cam_ket.cam_ket.bai_bao_uy_tin %}☑{% else %}☐{% endif %} Cá nhân đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD đối với bài báo trong nước/quốc tế cam kết bài báo không thuộc \u201CTạp chí săn mồi\u201D. Tôi xin chịu trách nhiệm kiểm tra, đối chiếu và cung cấp bằng chứng khi được yêu cầu.", align="justify") + +add_para(doc, "- Tuân thủ pháp luật sở hữu trí tuệ", bold=True, before=6) +add_para(doc, "{% if ban_cam_ket.cam_ket.tuan_thu_phap_luat %}☑{% else %}☐{% endif %} Tôi cam kết rằng việc sử dụng bài báo đăng ký xét công nhận sáng kiến tại ĐHYD sẽ không gây tranh chấp về: quyền tác giả/quyền liên quan, quyền sở hữu công nghiệp, tiết lộ bí mật kinh doanh, vi phạm bảo mật dữ liệu của bất kỳ bên thứ ba nào. Tôi chịu trách nhiệm trước pháp luật về tính trung thực, hợp pháp của hồ sơ.", align="justify") + +add_para(doc, "III. HẬU QUẢ PHÁP LÝ KHI THÔNG TIN KHÔNG TRUNG THỰC", bold=True, before=12) +add_para(doc, "Tôi xin cam kết chịu trách nhiệm đối với các thông tin kê khai nêu trên. Nếu thông tin được khai trong bản cam kết này không đúng thì tôi chấp nhận:", align="justify") +add_para(doc, "- Hủy kết quả công nhận sáng kiến đã được xét (nếu có);") +add_para(doc, "- Thu hồi, hủy các danh hiệu thi đua, khen thưởng, hoặc các quyền lợi phát sinh có sử dụng sáng kiến này để xét;") +add_para(doc, "- Xử lý theo quy định pháp luật hiện hành và theo quy chế/quy định của ĐHYD.") +add_para(doc, "Cam kết này có hiệu lực kể từ ngày ký và ràng buộc đối với cá nhân cam kết trong suốt thời gian xét công nhận sáng kiến và sau khi kết thúc 02 năm.", align="justify") + +add_para(doc, "", before=12) +add_para(doc, "NGƯỜI CAM KẾT", bold=True, align="right") +add_para(doc, "(Ký tên, ghi rõ họ tên)", italic=True, align="right") +add_para(doc, "") +add_para(doc, "") +add_para(doc, "{{ ban_cam_ket.nguoi_cam_ket }}", bold=True, align="right") + +doc.save(OUTPUT) +print(f"✓ Template saved to: {OUTPUT}") diff --git a/be0/src/be01/data_blank.json b/be0/src/be01/data_blank.json new file mode 100644 index 0000000..7d79600 --- /dev/null +++ b/be0/src/be01/data_blank.json @@ -0,0 +1,133 @@ +{ + "trang_bia": { + "ten_sang_kien": "", + "tac_gia": "", + "don_vi": "", + "thong_tin_lien_he": "", + "nam": "" + }, + "mau_01": { + "mo_dau": "", + "ten_sang_kien": "", + "linh_vuc_ap_dung": "", + "tinh_trang_da_biet": "", + "muc_dich": "", + "cac_buoc_thuc_hien": "", + "dieu_kien_ap_dung": "", + "linh_vuc_ap_dung_2": "", + "ket_qua_thu_duoc": "", + "danh_sach_ap_dung": [ + {"tt": "1", "ten_to_chuc": "", "dia_chi": "", "linh_vuc": ""} + ], + "tinh_moi": "", + "tinh_hieu_qua": { + "loi_ich_kinh_te": "", + "hieu_qua_giang_day": "", + "tang_nang_suat": "", + "nang_cao_hieu_qua": "", + "nang_cao_chat_luong": "", + "giam_chi_phi": "", + "cai_thien_moi_truong": "", + "bao_ve_suc_khoe": "", + "an_toan_lao_dong": "", + "nang_cao_nhan_thuc": "" + }, + "thong_tin_bao_mat": "", + "ngay_ky": {"ngay": "", "thang": "", "nam": ""}, + "lanh_dao_don_vi": "", + "tac_gia_sang_kien": "" + }, + "mau_02": { + "don_vi": "", + "danh_sach_tac_gia": [ + {"stt": "1", "ho_ten": "", "ngay_sinh": "", "noi_cong_tac": "", "chuc_danh": "", "trinh_do": "", "ty_le": ""} + ], + "ten_sang_kien": "", + "chu_dau_tu": "", + "linh_vuc_ap_dung": "", + "ngay_ap_dung": "", + "noi_dung": "", + "phan_loai": { + "giai_phap_ky_thuat": false, + "sang_kien_tu_nckh": false, + "sang_kien_tu_sach": false + }, + "thong_tin_bao_mat": "", + "dieu_kien_ap_dung": "", + "danh_gia_tac_gia": "", + "danh_gia_to_chuc": "", + "danh_sach_tham_gia": [ + {"stt": "1", "ho_ten": "", "ngay_sinh": "", "noi_cong_tac": "", "chuc_danh": "", "trinh_do": "", "noi_dung_ho_tro": ""} + ], + "ngay_ky": {"ngay": "", "thang": "", "nam": ""}, + "lanh_dao_don_vi": "", + "tac_gia_sang_kien": "" + }, + "mau_03": { + "ngay_ky": {"ngay": "", "thang": "", "nam": ""}, + "ten_sang_kien": "", + "tac_gia_chinh": "", + "chuc_vu_don_vi": "", + "ty_le_dong_gop": [ + {"stt": "1", "ho_ten": "", "don_vi": "", "phan_tram": "", "chu_ky": ""} + ], + "tac_gia_chinh_ky": "" + }, + "mau_04": { + "ten_sang_kien": "", + "tac_gia": "", + "chuc_vu_don_vi": "", + "tinh_moi": {"nhan_xet": "", "diem": ""}, + "tinh_hieu_qua": {"nhan_xet": "", "diem": ""}, + "tong_cong": "", + "ket_luan": "", + "ngay_ky": {"ngay": "", "thang": "", "nam": ""}, + "thanh_vien_hoi_dong": "" + }, + "ban_cam_ket": { + "ngay_ky": {"ngay": "", "thang": "", "nam": ""}, + "tac_gia_dang_ky": "", + "cccd": "", + "don_vi": "", + "ten_bai_bao": "", + "nam_xet": "", + "vai_tro": {"tac_gia_chinh": false, "dong_tac_gia": false}, + "cam_ket": { + "quyen_so_huu_1": false, + "quyen_so_huu_2": false, + "dong_thuan": false, + "bai_bao_uy_tin": false, + "tuan_thu_phap_luat": false + }, + "nguoi_cam_ket": "" + }, + "reference_material_honesty": { + "ngay_ky": {"ngay": "", "thang": "", "nam": ""}, + "tac_gia_dang_ky": "", + "cccd": "", + "don_vi": "", + "ten_tai_lieu": "", + "nam_xet": "", + "cam_ket": { + "thong_tin_trung_thuc": false, + "trach_nhiem_phap_luat": false, + "bo_sung_khi_yeu_cau": false + }, + "nguoi_cam_ket": "" + }, + "research_domestic_honesty": { + "ngay_ky": {"ngay": "", "thang": "", "nam": ""}, + "tieu_de_phu": "", + "tac_gia_dang_ky": "", + "cccd": "", + "don_vi": "", + "ten_bai_bao": "", + "nam_xet": "", + "cam_ket": { + "thong_tin_trung_thuc": false, + "trach_nhiem_phap_luat": false, + "bo_sung_khi_yeu_cau": false + }, + "nguoi_cam_ket": "" + } +} diff --git a/be0/src/be01/data_sop.json b/be0/src/be01/data_sop.json new file mode 100644 index 0000000..b939d08 --- /dev/null +++ b/be0/src/be01/data_sop.json @@ -0,0 +1,123 @@ +{ + "trang_bia": { + "ten_sang_kien": "Quy trình xét duyệt Đạo đức trong nghiên cứu trên động vật Đại học Y Dược thành phố Hồ Chí Minh", + "tac_gia": "Trần Hùng, Đỗ Thị Hồng Tươi, Trần Mạnh Hùng, Lê Thị Lan Phương, Trịnh Túy An, Võ Minh Tuấn, Trần Ngọc Đăng, Đỗ Quốc Vũ", + "don_vi": "Phòng Khoa học Công nghệ - Đại học Y Dược Thành phố Hồ Chí Minh", + "thong_tin_lien_he": "Đỗ Quốc Vũ – SĐT 0377318854; Email: doquocvu@ump.edu.vn", + "nam": "2025" + }, + "mau_01": { + "mo_dau": "Trong lĩnh vực nghiên cứu khoa học, việc sử dụng động vật có xương sống cho mục đích nghiên cứu, giảng dạy và dịch vụ khoa học công nghệ đã trở nên phổ biến và đóng vai trò quan trọng trong việc phát triển tri thức và ứng dụng thực tiễn. Tuy nhiên, việc thực hiện các nghiên cứu trên động vật cũng đặt ra nhiều vấn đề liên quan đến đạo đức và phúc lợi động vật.\nTại Đại học Y Dược Thành phố Hồ Chí Minh, hoạt động nghiên cứu trên động vật ngày càng phát triển nhưng vẫn còn tồn tại nhiều khó khăn, bất cập trong công tác xét duyệt đạo đức...\nDo đó, việc xây dựng và triển khai một sáng kiến về quy trình xét duyệt đạo đức trong nghiên cứu trên động vật đồng bộ, minh bạch, chuẩn hóa là rất cần thiết.", + "ten_sang_kien": "Quy trình xét duyệt Đạo đức trong nghiên cứu trên động vật Đại học Y Dược thành phố Hồ Chí Minh", + "linh_vuc_ap_dung": "Cải cách hành chính", + "tinh_trang_da_biet": "Trước khi ban hành và áp dụng quy trình xét duyệt đạo đức trong nghiên cứu trên động vật được chuẩn hóa, công tác xét duyệt tại Đại học Y Dược Thành phố Hồ Chí Minh còn tồn tại nhiều hạn chế và khó khăn cụ thể như: hiện trạng thủ công và thiếu đồng bộ; thiếu tiêu chí, chỉ tiêu đánh giá rõ ràng; thời gian xử lý kéo dài; quản lý lưu trữ hồ sơ chưa hiệu quả; nhận thức và trách nhiệm chưa đồng đều.", + "muc_dich": "Xây dựng và triển khai quy trình xét duyệt đạo đức trong nghiên cứu trên động vật đồng bộ, chuẩn hóa, minh bạch nhằm: Chuẩn hoá và tối ưu hoá quy trình xét duyệt hồ sơ; Đảm bảo tính minh bạch, tiêu chí đánh giá rõ ràng, tránh kéo dài thời gian xử lý hồ sơ; Đảm bảo chất lượng và nâng cao hiệu quả quản lý cũng như trách nhiệm tuân thủ đạo đức của các bên liên quan tại Đại học Y Dược TP. Hồ Chí Minh.", + "cac_buoc_thuc_hien": "Quy trình xét duyệt hồ sơ đạo đức trong nghiên cứu trên động vật tại Đại học Y Dược TP. Hồ Chí Minh được xây dựng và hoàn thiện qua 05 bước:\nBước 1. Thu thập cơ sở pháp lý: Tìm hiểu các văn bản, quy định của Bộ Y tế, trường và thông tin từ các phòng ban chức năng liên quan.\nBước 2. Soạn thảo quy trình: Xây dựng dự thảo quy trình xét duyệt đạo đức, sau đó gửi đến các đơn vị liên quan.\nBước 3. Lấy ý kiến và thống nhất: Tổ chức cuộc họp với lãnh đạo các đơn vị liên quan và Ban Giám hiệu.\nBước 4. Thẩm định: Điều chỉnh, hoàn thiện và gửi hồ sơ về Hội đồng thẩm định.\nBước 5. Hoàn thiện và ban hành: Điều chỉnh theo góp ý của Hội đồng thẩm định và ban hành Quy trình.", + "dieu_kien_ap_dung": "Đối tượng áp dụng: Tất cả các nghiên cứu trên động vật với đối tượng nghiên cứu là động vật có xương sống sử dụng cho mục đích nghiên cứu khoa học, giảng dạy hoặc hợp tác, dịch vụ khoa học công nghệ.\nPhạm vi áp dụng: Áp dụng đối với Hội đồng Đạo đức trong nghiên cứu trên động vật của Đại học Y Dược Thành phố Hồ Chí Minh, các cơ quan, đơn vị, tổ chức, cá nhân có triển khai hoạt động sử dụng động vật, nghiên cứu trên động vật.", + "linh_vuc_ap_dung_2": "Cải cách hành chính", + "ket_qua_thu_duoc": "Ngày 14/4/2025, Quy trình xét duyệt đạo đức trong nghiên cứu trên động vật chính thức được ban hành và triển khai toàn diện tại Đại học Y Dược TP. Hồ Chí Minh. Thời gian xét duyệt hồ sơ được rút ngắn rõ rệt; Tăng cường sự minh bạch và rõ ràng trong toàn bộ quá trình xét duyệt; Nâng cao hiệu quả quản lý và hỗ trợ của các đơn vị chức năng.", + "danh_sach_ap_dung": [ + { + "tt": "1", + "ten_to_chuc": "Đại học Y Dược TP. Hồ Chí Minh", + "dia_chi": "217 Hồng Bàng, P.11, Q.5, TP.HCM", + "linh_vuc": "Cải cách hành chính" + } + ], + "tinh_moi": "Sáng kiến lần đầu tiên xây dựng và chuẩn hóa quy trình xét duyệt đạo đức trong nghiên cứu trên động vật tại Đại học Y Dược TP.HCM: Quy trình được thiết lập theo hướng chuẩn hóa, hệ thống hóa và liên thông chặt chẽ giữa các đơn vị trong nhà trường; xác định rõ luồng xử lý từ người nộp hồ sơ → phòng Khoa học Công nghệ → Hội đồng đạo đức trên động vật → Ban Giám hiệu; áp dụng tiêu chí đánh giá minh bạch, khách quan.", + "tinh_hieu_qua": { + "loi_ich_kinh_te": "Sáng kiến giúp giảm thiểu các sai sót và việc chỉnh sửa hồ sơ nhiều lần, từ đó tiết kiệm chi phí nhân lực và vật liệu trong công tác xét duyệt.", + "hieu_qua_giang_day": "", + "tang_nang_suat": "Quy trình chuẩn hóa giúp cán bộ và nhà nghiên cứu dễ dàng chuẩn bị, nộp hồ sơ theo mẫu sẵn có và theo dõi tiến độ xử lý một cách hiệu quả, tiết kiệm thời gian và công sức.", + "nang_cao_hieu_qua": "Quy trình minh bạch và có tiêu chí đánh giá rõ ràng giúp nâng cao chất lượng xét duyệt, đồng thời tạo điều kiện thuận lợi và hỗ trợ tốt hơn cho các nhà nghiên cứu trong quá trình chuẩn bị hồ sơ.", + "nang_cao_chat_luong": "", + "giam_chi_phi": "", + "cai_thien_moi_truong": "Tạo điều kiện cho người nộp hồ sơ chủ động trong việc nộp hồ sơ và thành viên hội đồng có các biểu mẫu rõ ràng trong việc đánh giá hồ sơ.", + "bao_ve_suc_khoe": "", + "an_toan_lao_dong": "", + "nang_cao_nhan_thuc": "Tăng tính chủ động, chuyên nghiệp, nhận thức tầm quan trọng trong việc chuẩn bị hồ sơ của người nộp. Tăng sự phối hợp chặt chẽ với Hội đồng xét duyệt, nhà nghiên cứu trong việc tuân thủ quy trình và các quy định đạo đức trong nghiên cứu trên động vật." + }, + "thong_tin_bao_mat": "Không", + "ngay_ky": {"ngay": "", "thang": "", "nam": ""}, + "lanh_dao_don_vi": "Trần Ngọc Đăng", + "tac_gia_sang_kien": "Đỗ Quốc Vũ" + }, + "mau_02": { + "don_vi": "Phòng Khoa học Công nghệ", + "danh_sach_tac_gia": [ + {"stt": "1", "ho_ten": "PGS.TS Trần Hùng", "ngay_sinh": "", "noi_cong_tac": "BM Dược Liệu - Khoa Dược", "chuc_danh": "Chủ tịch HĐĐ trên động vật, Giảng viên cao cấp", "trinh_do": "Tiến sĩ", "ty_le": "15%"}, + {"stt": "2", "ho_ten": "PGS.TS Đỗ Thị Hồng Tươi", "ngay_sinh": "", "noi_cong_tac": "Phòng TCCB BM Dược Lý – Khoa Dược", "chuc_danh": "Trưởng phòng, Giảng viên cao cấp", "trinh_do": "Tiến sĩ", "ty_le": "15%"}, + {"stt": "3", "ho_ten": "PGS.TS Trần Mạnh Hùng", "ngay_sinh": "", "noi_cong_tac": "BM Dược Lý – Khoa Dược", "chuc_danh": "Trưởng bộ môn", "trinh_do": "Tiến sĩ", "ty_le": "10%"}, + {"stt": "4", "ho_ten": "TS. Lê Thị Lan Phương", "ngay_sinh": "", "noi_cong_tac": "Khoa YHCT", "chuc_danh": "Phó trưởng khoa", "trinh_do": "Tiến sĩ", "ty_le": "10%"}, + {"stt": "5", "ho_ten": "TS. Trịnh Túy An", "ngay_sinh": "", "noi_cong_tac": "Trung tâm Sapharcen", "chuc_danh": "Nghiên cứu viên", "trinh_do": "Tiến sĩ", "ty_le": "15%"}, + {"stt": "6", "ho_ten": "GS.TS Võ Minh Tuấn", "ngay_sinh": "", "noi_cong_tac": "Bộ môn Sản, Khoa Y", "chuc_danh": "Phó trưởng Bộ môn", "trinh_do": "Tiến sĩ", "ty_le": "10%"}, + {"stt": "7", "ho_ten": "PGS.TS Trần Ngọc Đăng", "ngay_sinh": "", "noi_cong_tac": "Phòng KHCN", "chuc_danh": "Phó trưởng phòng", "trinh_do": "Tiến sĩ", "ty_le": "10%"}, + {"stt": "8", "ho_ten": "CN. Đỗ Quốc Vũ", "ngay_sinh": "14/09/1996", "noi_cong_tac": "Phòng KHCN", "chuc_danh": "Chuyên viên", "trinh_do": "Cử nhân", "ty_le": "15%"} + ], + "ten_sang_kien": "Quy trình xét duyệt Đạo đức trong nghiên cứu trên động vật Đại học Y Dược thành phố Hồ Chí Minh", + "chu_dau_tu": "Đại học Y Dược thành phố Hồ Chí Minh", + "linh_vuc_ap_dung": "Cải cách hành chính", + "ngay_ap_dung": "14/4/2025", + "noi_dung": "Quy trình xét duyệt hồ sơ đạo đức trong nghiên cứu trên động vật tại Đại học Y Dược TP. Hồ Chí Minh được xây dựng và hoàn thiện qua 05 bước. Ngày 14/4/2025 quy trình chính thức được ban hành, giúp rút ngắn thời gian xét duyệt, tăng cường minh bạch, nâng cao hiệu quả quản lý.", + "phan_loai": { + "giai_phap_ky_thuat": true, + "sang_kien_tu_nckh": false, + "sang_kien_tu_sach": false + }, + "thong_tin_bao_mat": "Không", + "dieu_kien_ap_dung": "Đối tượng áp dụng: Tất cả các nghiên cứu trên động vật với đối tượng nghiên cứu là động vật có xương sống. Phạm vi áp dụng: Hội đồng Đạo đức trong nghiên cứu trên động vật của ĐHYD TP.HCM và các đơn vị liên quan.", + "danh_gia_tac_gia": "Sáng kiến giúp tạo sự rõ ràng, minh bạch về các hồ sơ cần nộp để xét duyệt, giúp người nộp chuẩn bị hồ sơ đầy đủ, chính xác, tránh sai sót. Quy trình xác định rõ thời gian xét duyệt, từ đó rút ngắn thời gian xử lý hồ sơ. Sáng kiến còn giúp giảm chi phí và nhân lực, tạo điều kiện thuận lợi cho viên chức, người lao động và người học. Sự minh bạch và chuẩn hóa quy trình cũng nâng cao nhận thức, trách nhiệm và hiệu quả phối hợp giữa các đơn vị.", + "danh_gia_to_chuc": "", + "danh_sach_tham_gia": [ + {"stt": "1", "ho_ten": "", "ngay_sinh": "", "noi_cong_tac": "", "chuc_danh": "", "trinh_do": "", "noi_dung_ho_tro": ""} + ], + "ngay_ky": {"ngay": "", "thang": "", "nam": "2025"}, + "lanh_dao_don_vi": "Trần Ngọc Đăng", + "tac_gia_sang_kien": "Đỗ Quốc Vũ" + }, + "mau_03": { + "ngay_ky": {"ngay": "", "thang": "", "nam": "2025"}, + "ten_sang_kien": "Quy trình xét duyệt Đạo đức trong nghiên cứu trên động vật Đại học Y Dược thành phố Hồ Chí Minh", + "tac_gia_chinh": "Đỗ Quốc Vũ", + "chuc_vu_don_vi": "Chuyên viên, Phòng Khoa học Công nghệ - Đại học Y Dược TP.HCM", + "ty_le_dong_gop": [ + {"stt": "1", "ho_ten": "PGS.TS Trần Hùng", "don_vi": "BM Dược Liệu - Khoa Dược", "phan_tram": "15", "chu_ky": ""}, + {"stt": "2", "ho_ten": "PGS.TS Đỗ Thị Hồng Tươi", "don_vi": "Phòng TCCB - BM Dược Lý, Khoa Dược", "phan_tram": "15", "chu_ky": ""}, + {"stt": "3", "ho_ten": "PGS.TS Trần Mạnh Hùng", "don_vi": "BM Dược Lý - Khoa Dược", "phan_tram": "10", "chu_ky": ""}, + {"stt": "4", "ho_ten": "TS. Lê Thị Lan Phương", "don_vi": "Khoa YHCT", "phan_tram": "10", "chu_ky": ""}, + {"stt": "5", "ho_ten": "TS. Trịnh Túy An", "don_vi": "Trung tâm Sapharcen", "phan_tram": "15", "chu_ky": ""}, + {"stt": "6", "ho_ten": "GS.TS Võ Minh Tuấn", "don_vi": "Bộ môn Sản, Khoa Y", "phan_tram": "10", "chu_ky": ""}, + {"stt": "7", "ho_ten": "PGS.TS Trần Ngọc Đăng", "don_vi": "Phòng KHCN", "phan_tram": "10", "chu_ky": ""}, + {"stt": "8", "ho_ten": "CN. Đỗ Quốc Vũ", "don_vi": "Phòng KHCN", "phan_tram": "15", "chu_ky": ""} + ], + "tac_gia_chinh_ky": "Đỗ Quốc Vũ" + }, + "mau_04": { + "ten_sang_kien": "Quy trình xét duyệt Đạo đức trong nghiên cứu trên động vật Đại học Y Dược thành phố Hồ Chí Minh", + "tac_gia": "Đỗ Quốc Vũ và nhóm tác giả", + "chuc_vu_don_vi": "Chuyên viên, Phòng Khoa học Công nghệ - Đại học Y Dược TP.HCM", + "tinh_moi": {"nhan_xet": "", "diem": ""}, + "tinh_hieu_qua": {"nhan_xet": "", "diem": ""}, + "tong_cong": "", + "ket_luan": "", + "ngay_ky": {"ngay": "", "thang": "", "nam": "2025"}, + "thanh_vien_hoi_dong": "" + }, + "ban_cam_ket": { + "ngay_ky": {"ngay": "", "thang": "", "nam": "2026"}, + "tac_gia_dang_ky": "", + "cccd": "", + "don_vi": "", + "ten_bai_bao": "", + "nam_xet": "2026", + "vai_tro": {"tac_gia_chinh": false, "dong_tac_gia": false}, + "cam_ket": { + "quyen_so_huu_1": false, + "quyen_so_huu_2": false, + "dong_thuan": false, + "bai_bao_uy_tin": false, + "tuan_thu_phap_luat": false + }, + "nguoi_cam_ket": "" + } +} diff --git a/be0/src/be01/docx_normalize.py b/be0/src/be01/docx_normalize.py new file mode 100644 index 0000000..fd93781 --- /dev/null +++ b/be0/src/be01/docx_normalize.py @@ -0,0 +1,1405 @@ +"""Normalize OOXML in generated .docx bytes for safer layout in browsers (docx-preview) and print.""" + +from __future__ import annotations + +import io +import re +import zipfile +import xml.etree.ElementTree as ET +from copy import deepcopy + +# Self-closing trHeight, e.g. +_TR_HEIGHT_RE = re.compile(r"]*/>", re.IGNORECASE) + +# Optional paired form (unusual for trHeight but defensive) +_TR_HEIGHT_BLOCK_RE = re.compile( + r"]*>.*?", + re.IGNORECASE | re.DOTALL, +) + +_PARAGRAPH_RE = re.compile(r"]*>.*?", re.IGNORECASE | re.DOTALL) +_RUN_RE = re.compile(r"]*>.*?", re.IGNORECASE | re.DOTALL) +_RUN_SZ_RE = re.compile(r'(]*\bw:val=")(\d+)(")', re.IGNORECASE) +_RUN_SZCS_RE = re.compile(r'(]*\bw:val=")(\d+)(")', re.IGNORECASE) + +_SHRINK_TARGET_PHRASES = ( + "ĐẠI HỌC Y DƯỢC THÀNH PHỐ HỒ CHÍ MINH", + "ĐẠI HỌC Y DƯỢCTHÀNH PHỐ HỒ CHÍ MINH", + "CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM", + "Phòng Khoa học Công nghệ", +) +_LEFT_SHIFT_TARGETS = ( + "ĐẠI HỌC Y DƯỢC", + "THÀNH PHỐ HỒ CHÍ MINH", + "PHÒNG KHOA HỌC CÔNG NGHỆ", + "{{ mau_02.don_vi|upper }}", +) + +_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +_NS = {"w": _W_NS} + + +def _paragraph_plain_text(p: ET.Element) -> str: + return "".join((t.text or "") for t in p.findall(".//w:t", _NS)) + + +def _compact_no_inner_spaces(text: str) -> str: + """Collapse whitespace incl. NBSP / figure space so « BỘ Y TẾ » matches reliably.""" + t = ( + text.replace("\u00a0", " ") + .replace("\u2007", " ") + .replace("\u202f", " ") + .replace("\u3000", " ") + ) + return "".join(t.split()) + + +def _ensure_paragraph_centered_no_indent(p: ET.Element) -> None: + p_pr = p.find("w:pPr", _NS) + if p_pr is None: + p_pr = ET.SubElement(p, f"{{{_W_NS}}}pPr") + + ind = p_pr.find("w:ind", _NS) + if ind is not None: + p_pr.remove(ind) + + jc = p_pr.find("w:jc", _NS) + if jc is None: + jc = ET.SubElement(p_pr, f"{{{_W_NS}}}jc") + jc.set(f"{{{_W_NS}}}val", "center") + + +def _ensure_run_times_new_roman(r_pr: ET.Element) -> None: + fonts = r_pr.find("w:rFonts", _NS) + if fonts is None: + fonts = ET.SubElement(r_pr, f"{{{_W_NS}}}rFonts") + tnr = "Times New Roman" + fonts.set(f"{{{_W_NS}}}ascii", tnr) + fonts.set(f"{{{_W_NS}}}hAnsi", tnr) + fonts.set(f"{{{_W_NS}}}cs", tnr) + fonts.set(f"{{{_W_NS}}}eastAsia", tnr) + + +def _ensure_run_bold(r_pr: ET.Element) -> None: + for tag in ("b", "bCs"): + el = r_pr.find(f"w:{tag}", _NS) + if el is None: + el = ET.SubElement(r_pr, f"{{{_W_NS}}}{tag}") + el.set(f"{{{_W_NS}}}val", "1") + + +def _ensure_run_not_bold(r_pr: ET.Element) -> None: + """Strip bold so the run renders regular weight. Removes the tag entirely (rather than + setting val="0") because the cover ministry line never inherits bold from the styles we + emit, and tests pin the post-normalization XML to having no at all.""" + for tag in ("b", "bCs"): + el = r_pr.find(f"w:{tag}", _NS) + if el is not None: + r_pr.remove(el) + + +def _ensure_run_not_italic(r_pr: ET.Element) -> None: + """Force upright text with val="0" rather than removing the tag, so it overrides any + italic inherited from paragraph / character styles.""" + for tag in ("i", "iCs"): + el = r_pr.find(f"w:{tag}", _NS) + if el is None: + el = ET.SubElement(r_pr, f"{{{_W_NS}}}{tag}") + el.set(f"{{{_W_NS}}}val", "0") + + +def _run_has_text_content(run: ET.Element) -> bool: + for t in run.findall(".//w:t", _NS): + if (t.text or "").strip(): + return True + return False + + +def _local_tag(elem: ET.Element) -> str: + tag = elem.tag + return tag.split("}", 1)[-1] if "}" in tag else tag + + +# Letterhead: one paragraph with a soft break, or two stacked paragraphs. +_UNI_LINE_DHYD_COMPACT = "".join("ĐẠI HỌC Y DƯỢC".split()) +_UNI_LINE_TPHCM_COMPACT = "".join("THÀNH PHỐ HỒ CHÍ MINH".split()) + + +def _canonicalize_dhyd_typo(s: str) -> str: + """Map the official template's misspelled « HỘC » (Ộ = U+1ED8, Ô + dot below) onto the + correct « HỌC » (Ọ = U+1ECC, O + dot below) so all letterhead comparisons can use a + single canonical compact form regardless of which spelling the source DOCX carries.""" + return s.replace("\u1ed8", "\u1ecc").replace("\u1ed9", "\u1ecd") + + +def _is_university_letterhead_paragraph(para_text: str) -> bool: + n = ( + para_text.replace("\u00a0", " ") + .replace("\u2007", " ") + .replace("\u202f", " ") + .replace("\u3000", " ") + ) + canonical = _canonicalize_dhyd_typo(n) + if "ĐẠI HỌC Y DƯỢC" in canonical and "THÀNH PHỐ HỒ CHÍ MINH" in canonical: + return True + compact = "".join(canonical.split()) + return compact in (_UNI_LINE_DHYD_COMPACT, _UNI_LINE_TPHCM_COMPACT) + + +def _canonical_compact(text: str) -> str: + """Compact (drop all whitespace) and canonicalize the dhyd typo in one step.""" + return _canonicalize_dhyd_typo(_compact_no_inner_spaces(text)) + + +def _university_paragraph_has_soft_break(p: ET.Element) -> bool: + """True when a non-page already separates the two letterhead phrases in + document order inside this paragraph (handles a soft break inside one run, or a + break between two runs).""" + type_attr = f"{{{_W_NS}}}type" + dhyd = _UNI_LINE_DHYD_COMPACT + tphcm = _UNI_LINE_TPHCM_COMPACT + seen_dhyd = False + for elem in p.iter(): + local = _local_tag(elem) + if local == "t": + compact = _canonical_compact(elem.text or "") + if not seen_dhyd and dhyd in compact: + seen_dhyd = True + tphcm_at = compact.find(tphcm) + if tphcm_at != -1 and tphcm_at > compact.find(dhyd): + # Both phrases sit in the same with nothing between them. + return False + elif seen_dhyd and tphcm in compact: + return False + elif local == "br" and seen_dhyd: + if elem.attrib.get(type_attr) != "page": + return True + return False + + +def _ensure_university_letterhead_visual_split(p: ET.Element) -> None: + """Insert a soft immediately before the city-line phrase when both letterhead + phrases share a paragraph on one visual line. + + Idempotent: skips paragraphs that only carry one of the phrases (two-paragraph layout + is handled by the caller) and paragraphs already broken with a soft . + + Handles three shapes: + - both phrases inside a single -> split that + - phrases in two adjacent in the same -> insert between them + - phrases in elements that live in different -> insert inside + the run carrying the city line, right before its + """ + full_compact = _canonical_compact(_paragraph_plain_text(p)) + if _UNI_LINE_DHYD_COMPACT not in full_compact: + return + if _UNI_LINE_TPHCM_COMPACT not in full_compact: + return + if _university_paragraph_has_soft_break(p): + return + + target_compact = _UNI_LINE_TPHCM_COMPACT + head_char = target_compact[0] + + for run in p.findall("w:r", _NS): + for child_idx, child in enumerate(list(run)): + if _local_tag(child) != "t": + continue + text = child.text or "" + if target_compact not in _canonical_compact(text): + continue + + split_at = -1 + for i, ch in enumerate(text): + if ch == head_char and _canonical_compact(text[i:]).startswith(target_compact): + split_at = i + break + if split_at < 0: + continue + + br = ET.Element(f"{{{_W_NS}}}br") + if split_at == 0: + run.insert(child_idx, br) + else: + before = text[:split_at].rstrip() + after = text[split_at:] + child.text = before + new_t = ET.Element(f"{{{_W_NS}}}t") + new_t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve") + new_t.text = after + run.insert(child_idx + 1, br) + run.insert(child_idx + 2, new_t) + return + + +def _paragraphs_document_order(root: ET.Element) -> list[ET.Element]: + """All ``w:p`` in serialization (depth-first) order.""" + return root.findall(".//w:p", _NS) + + +def _first_hard_page_break_paragraph_index(paragraphs: list[ET.Element]) -> int | None: + """Index of the first paragraph that contains ``w:br`` with ``type`` = ``page``.""" + type_attr = f"{{{_W_NS}}}type" + for i, p in enumerate(paragraphs): + for br in p.findall(".//w:br", _NS): + if br.attrib.get(type_attr) == "page": + return i + return None + + +def _paragraph_ids_first_physical_page(paragraphs: list[ET.Element]) -> set[int]: + """ + Paragraph object ids on the first printed page: everything up to and including the first + paragraph with a hard page break. If there is no hard break (rare), only the first chunks + of the body are considered so duplicate letterheads in later tables keep template styling. + """ + brk = _first_hard_page_break_paragraph_index(paragraphs) + if brk is not None: + return {id(paragraphs[i]) for i in range(brk + 1)} + cap = min(len(paragraphs), 24) + return {id(paragraphs[i]) for i in range(cap)} + + +def strip_table_row_height_rules_from_docx(data: bytes) -> bytes: + """ + Remove `` from `word/document.xml` so rows use automatic height in Word OOXML. + + Fixed / atLeast heights are often mapped incorrectly by docx-preview to CSS `height`, which + makes wrapped Vietnamese text overlap inside table cells when exporting to PDF in the + browser. LibreOffice also lays out more predictably without contradictory height hints. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + text = raw.decode("utf-8") + text, n1 = _TR_HEIGHT_RE.subn("", text) + text, n2 = _TR_HEIGHT_BLOCK_RE.subn("", text) + if n1 or n2: + raw = text.encode("utf-8") + zout.writestr(info, raw) + return out.getvalue() + + +def _should_rewrite_distribute_jc_in_aux_word_part(filename: str) -> bool: + """Parts besides ``document.xml`` that may carry ```` (styles, notes, headers).""" + if filename == "word/document.xml": + return False + if not filename.startswith("word/") or not filename.endswith(".xml"): + return False + if filename in ( + "word/styles.xml", + "word/footnotes.xml", + "word/endnotes.xml", + "word/comments.xml", + ): + return True + base = filename.split("/")[-1] + if base.startswith("header") and base.endswith(".xml"): + return True + if base.startswith("footer") and base.endswith(".xml"): + return True + return False + + +def _patch_settings_add_do_not_expand_shift_return(raw: bytes) -> bytes: + """Ensure ``w:doNotExpandShiftReturn`` so lines ending in soft breaks are not stretched when justified.""" + val_attr = f"{{{_W_NS}}}val" + root = ET.fromstring(raw) + compat = root.find("w:compat", _NS) + if compat is None: + compat = ET.SubElement(root, f"{{{_W_NS}}}compat") + dnsr = None + for child in compat: + if _local_tag(child) == "doNotExpandShiftReturn": + dnsr = child + break + if dnsr is None: + dnsr = ET.SubElement(compat, f"{{{_W_NS}}}doNotExpandShiftReturn") + dnsr.set(val_attr, "1") + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def relax_justified_softbreak_paragraphs_in_docx(data: bytes) -> bytes: + """ + Cap word-spacing growth in justified paragraphs so word gaps stay closer to normal + word space (target « not more than a few spaces » between words on short lines). + + Transforms: + + 1. ```` -> ```` everywhere it appears + (``document.xml``, ``styles.xml``, headers/footers, footnotes…). ``distribute`` also + adds inter-*character* pitch on every line and is the worst case for gappy Vietnamese. + 2. In ``word/document.xml`` only: justified (``both``) paragraphs that contain a non-page + soft ```` are split into one paragraph per soft-break segment so each visual + line before a soft break becomes a true paragraph last line and is not stretched. + 3. ``word/settings.xml``: set compatibility ``w:doNotExpandShiftReturn`` so consumers that + honor it also skip stretching lines that end in a soft line break. + + The XML structure is preserved otherwise (runs, run properties, text content, page + breaks all survive unchanged); only soft ```` elements are consumed during the + paragraph split. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + _convert_distribute_to_both(root) + _split_justified_paragraphs_at_softbreaks(root) + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + elif _should_rewrite_distribute_jc_in_aux_word_part(info.filename): + root = ET.fromstring(raw) + _convert_distribute_to_both(root) + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + elif info.filename == "word/settings.xml": + raw = _patch_settings_add_do_not_expand_shift_return(raw) + zout.writestr(info, raw) + return out.getvalue() + + +def _convert_distribute_to_both(root: ET.Element) -> None: + """Rewrite every ```` to ``both`` so the last line of each + paragraph stops being stretched to fill the column width.""" + val_attr = f"{{{_W_NS}}}val" + for jc in root.findall(".//w:jc", _NS): + if jc.attrib.get(val_attr) == "distribute": + jc.attrib[val_attr] = "both" + + +def _paragraph_is_justified_both(p: ET.Element) -> bool: + val_attr = f"{{{_W_NS}}}val" + p_pr = p.find("w:pPr", _NS) + if p_pr is None: + return False + jc = p_pr.find("w:jc", _NS) + if jc is None: + return False + return jc.attrib.get(val_attr) == "both" + + +def _paragraph_has_soft_break(p: ET.Element) -> bool: + type_attr = f"{{{_W_NS}}}type" + for br in p.findall(".//w:br", _NS): + if br.attrib.get(type_attr) != "page": + return True + return False + + +def _clone_paragraph_shell(p: ET.Element) -> ET.Element: + """Create an empty ```` element that copies ``p``'s attributes and a deep copy of + its ```` (so paragraph alignment, indent, spacing and run-default properties + travel into the split fragments). Body content is not copied.""" + new_p = ET.Element(p.tag, dict(p.attrib)) + p_pr = p.find("w:pPr", _NS) + if p_pr is not None: + new_p.append(deepcopy(p_pr)) + return new_p + + +def _split_run_at_softbreak(run: ET.Element) -> list[ET.Element]: + """Split a single ```` containing one or more non-page ```` into a list of + runs. The returned list is interleaved so callers can detect break points: each + consecutive pair represents content separated by a soft break. Runs without a soft + break return a single-element list containing the original run.""" + type_attr = f"{{{_W_NS}}}type" + rPr = run.find("w:rPr", _NS) + body_children = [child for child in run if _local_tag(child) != "rPr"] + has_soft_break = any( + _local_tag(c) == "br" and c.attrib.get(type_attr) != "page" for c in body_children + ) + if not has_soft_break: + return [run] + + fragments: list[ET.Element] = [] + current = ET.Element(run.tag, dict(run.attrib)) + if rPr is not None: + current.append(deepcopy(rPr)) + + def _flush() -> None: + nonlocal current + fragments.append(current) + current = ET.Element(run.tag, dict(run.attrib)) + if rPr is not None: + current.append(deepcopy(rPr)) + + for child in body_children: + if _local_tag(child) == "br" and child.attrib.get(type_attr) != "page": + _flush() + continue + current.append(deepcopy(child)) + fragments.append(current) + return fragments + + +def _split_justified_paragraphs_at_softbreaks(root: ET.Element) -> None: + """For each paragraph with ```` that contains a non-page soft break, + replace the paragraph with N paragraphs, splitting at each soft break. + + Walks each ````'s direct children in document order. Soft breaks inside runs are + handled by :func:`_split_run_at_softbreak`; soft breaks that appear as direct paragraph + children (rare but valid) trigger a paragraph boundary directly. Paragraph property + elements (````) are deep-copied into every fragment so alignment / indent / + spacing survive. + """ + type_attr = f"{{{_W_NS}}}type" + parent_map = {child: parent for parent in root.iter() for child in parent} + + for p in list(root.findall(".//w:p", _NS)): + if not _paragraph_is_justified_both(p): + continue + if not _paragraph_has_soft_break(p): + continue + + fragments: list[ET.Element] = [_clone_paragraph_shell(p)] + + def _start_new_fragment() -> None: + fragments.append(_clone_paragraph_shell(p)) + + for child in list(p): + local = _local_tag(child) + if local == "pPr": + continue + if local == "br" and child.attrib.get(type_attr) != "page": + _start_new_fragment() + continue + if local == "r": + run_parts = _split_run_at_softbreak(child) + if len(run_parts) == 1: + fragments[-1].append(run_parts[0]) + continue + for idx, part in enumerate(run_parts): + if idx > 0: + _start_new_fragment() + fragments[-1].append(part) + continue + fragments[-1].append(deepcopy(child)) + + if len(fragments) < 2: + continue + + parent = parent_map.get(p) + if parent is None: + continue + siblings = list(parent) + try: + idx = siblings.index(p) + except ValueError: + continue + parent.remove(p) + for offset, frag in enumerate(fragments): + parent.insert(idx + offset, frag) + + +def shrink_overflow_sensitive_text_half_point(data: bytes) -> bytes: + """ + Reduce font size by 0.5pt (1 half-point in OOXML) for specific long phrases + that frequently wrap inside narrow header/table cells. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + text = raw.decode("utf-8") + + def _patch_paragraph(match: re.Match[str]) -> str: + para = match.group(0) + para_text = "".join(re.findall(r"]*>(.*?)", para, re.IGNORECASE | re.DOTALL)) + if not any(t in para_text for t in _SHRINK_TARGET_PHRASES): + return para + + def _patch_run(run_match: re.Match[str]) -> str: + run = run_match.group(0) + + def _dec_sz(m: re.Match[str]) -> str: + next_val = max(2, int(m.group(2)) - 1) + return f"{m.group(1)}{next_val}{m.group(3)}" + + run = _RUN_SZ_RE.sub(_dec_sz, run) + run = _RUN_SZCS_RE.sub(_dec_sz, run) + return run + + return _RUN_RE.sub(_patch_run, para) + + text = _PARAGRAPH_RE.sub(_patch_paragraph, text) + raw = text.encode("utf-8") + zout.writestr(info, raw) + return out.getvalue() + + +def style_national_header_line(data: bytes) -> bytes: + """ + Force `CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM` runs to: + - 13pt + - bold + - slightly condensed character spacing + """ + target = "CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM" + + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + for p in root.findall(".//w:p", _NS): + para_text = "".join((t.text or "") for t in p.findall(".//w:t", _NS)) + if target not in para_text: + continue + p_pr = p.find("w:pPr", _NS) + if p_pr is None: + p_pr = ET.SubElement(p, f"{{{_W_NS}}}pPr") + + jc = p_pr.find("w:jc", _NS) + if jc is None: + jc = ET.SubElement(p_pr, f"{{{_W_NS}}}jc") + jc.set(f"{{{_W_NS}}}val", "left") + + ind = p_pr.find("w:ind", _NS) + if ind is None: + ind = ET.SubElement(p_pr, f"{{{_W_NS}}}ind") + # Shift further left by ~10px (150 twips) from previous layout. + ind.set(f"{{{_W_NS}}}left", "-270") + ind.set(f"{{{_W_NS}}}right", "0") + + for r in p.findall("w:r", _NS): + rPr = r.find("w:rPr", _NS) + if rPr is None: + rPr = ET.SubElement(r, f"{{{_W_NS}}}rPr") + + sz = rPr.find("w:sz", _NS) + if sz is None: + sz = ET.SubElement(rPr, f"{{{_W_NS}}}sz") + sz.set(f"{{{_W_NS}}}val", "21") # 10.5pt (closest OOXML half-point to 10.7) + + sz_cs = rPr.find("w:szCs", _NS) + if sz_cs is None: + sz_cs = ET.SubElement(rPr, f"{{{_W_NS}}}szCs") + sz_cs.set(f"{{{_W_NS}}}val", "21") + + b = rPr.find("w:b", _NS) + if b is None: + b = ET.SubElement(rPr, f"{{{_W_NS}}}b") + b.set(f"{{{_W_NS}}}val", "1") + + b_cs = rPr.find("w:bCs", _NS) + if b_cs is None: + b_cs = ET.SubElement(rPr, f"{{{_W_NS}}}bCs") + b_cs.set(f"{{{_W_NS}}}val", "1") + + spacing = rPr.find("w:spacing", _NS) + if spacing is None: + spacing = ET.SubElement(rPr, f"{{{_W_NS}}}spacing") + spacing.set(f"{{{_W_NS}}}val", "-6") + + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() + + +def shift_selected_header_lines_left(data: bytes) -> bytes: + """Shift selected header/unit lines left by ~10px (150 twips).""" + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + for p in root.findall(".//w:p", _NS): + para_text = "".join((t.text or "") for t in p.findall(".//w:t", _NS)) + if not any(tok in para_text for tok in _LEFT_SHIFT_TARGETS): + continue + p_pr = p.find("w:pPr", _NS) + if p_pr is None: + p_pr = ET.SubElement(p, f"{{{_W_NS}}}pPr") + ind = p_pr.find("w:ind", _NS) + if ind is None: + ind = ET.SubElement(p_pr, f"{{{_W_NS}}}ind") + ind.set(f"{{{_W_NS}}}left", "-150") + ind.set(f"{{{_W_NS}}}right", "0") + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() + + +def normalize_bo_y_te_header_lines(data: bytes) -> bytes: + """ + First-page letterhead only: + + - Ministry line « BỘ Y TẾ »: centered, regular weight (bold tag stripped), upright. + - University block « ĐẠI HỘC Y DƯỢC » + « THÀNH PHỐ HỒ CHÍ MINH »: centered, bold, upright. + When the two phrases live in one paragraph on a single visual line, a soft is + inserted before the city line so the cover renders the phrases on two stacked lines. + + Later pages / tables that repeat the ministry block are left unchanged. + + Run after `shift_selected_header_lines_left` so negative indents do not offset these blocks. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + paragraphs = _paragraphs_document_order(root) + first_page_ids = _paragraph_ids_first_physical_page(paragraphs) + for p in paragraphs: + if id(p) not in first_page_ids: + continue + para_text = _paragraph_plain_text(p) + compact = _compact_no_inner_spaces(para_text) + + if compact == "BỘYTẾ": + _ensure_paragraph_centered_no_indent(p) + for r in p.findall("w:r", _NS): + r_pr = r.find("w:rPr", _NS) + if r_pr is None: + r_pr = ET.SubElement(r, f"{{{_W_NS}}}rPr") + _ensure_run_not_bold(r_pr) + _ensure_run_not_italic(r_pr) + _ensure_run_times_new_roman(r_pr) + continue + + if _is_university_letterhead_paragraph(para_text): + _ensure_paragraph_centered_no_indent(p) + _ensure_university_letterhead_visual_split(p) + for r in p.findall("w:r", _NS): + if not _run_has_text_content(r): + continue + r_pr = r.find("w:rPr", _NS) + if r_pr is None: + r_pr = ET.SubElement(r, f"{{{_W_NS}}}rPr") + _ensure_run_bold(r_pr) + _ensure_run_not_italic(r_pr) + _ensure_run_times_new_roman(r_pr) + + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() + + +_DON_VI_PREFIX = "ĐƠN VỊ:" +_DON_VI_SIGNATURE_PREFIX = "Đơn vị " +_SIGNATURE_LEADER_PHRASE = "Xác nhận của lãnh đạo" +_NATIONAL_HEADER_PHRASE = "CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM" + + +def _table_plain_text(tbl: ET.Element) -> str: + return "".join((t.text or "") for t in tbl.findall(".//w:t", _NS)) + + +def _strip_text_prefix_from_paragraph(p: ET.Element, prefix: str) -> bool: + """Remove the first occurrence of ``prefix`` from a in this paragraph. Returns + True when the prefix was found and stripped. The trailing remainder is left-stripped + so a trailing whitespace token does not turn the line into a leading-space artifact.""" + for t in p.findall(".//w:t", _NS): + text = t.text or "" + idx = text.find(prefix) + if idx < 0: + continue + remainder = text[idx + len(prefix):].lstrip() + t.text = (text[:idx] + remainder).lstrip() + t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve") + return True + return False + + +def _strip_don_vi_prefix_from_paragraph(p: ET.Element) -> None: + """Remove the literal « ĐƠN VỊ: » prefix from the first that contains it; keep + the rest of the paragraph untouched (Jinja placeholder, trailing whitespace, etc.).""" + _strip_text_prefix_from_paragraph(p, _DON_VI_PREFIX) + + +def normalize_mau_02_letterhead_table(data: bytes) -> bytes: + """Tighten the Mẫu số 02 letterhead table so the four header lines align cleanly: + + - Right cell « CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM »: shrink font so the phrase fits + on a single line within the narrow right column, override « left » alignment set + by :func:`style_national_header_line` back to centered, and clear its negative + indent (which only made sense for the full-width header on other pages). + - Left cell « ĐẠI HỌC Y DƯỢC THÀNH PHỐ HỒ CHÍ MINH »: insert a soft before + « THÀNH PHỐ HỒ CHÍ MINH » so the phrase wraps cleanly into two stacked lines, and + strip bold from every run. + - Left cell « ĐƠN VỊ: {{ mau_02.don_vi }} »: drop the « ĐƠN VỊ: » literal prefix so + only the resolved unit name remains; force bold and strip italic on every run. + + Scoped to the Mẫu số 02 table by requiring both « ĐƠN VỊ: » and the national-header + phrase in the same , so the duplicate letterhead blocks on Mẫu số 03 / Bản + cam kết (which have « BỘ Y TẾ » instead of « ĐƠN VỊ ») are left untouched. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + for tbl in root.findall(".//w:tbl", _NS): + all_text = _table_plain_text(tbl) + if _DON_VI_PREFIX not in all_text: + continue + if _NATIONAL_HEADER_PHRASE not in all_text: + continue + for p in tbl.findall(".//w:p", _NS): + para_text = _paragraph_plain_text(p) + + if _NATIONAL_HEADER_PHRASE in para_text: + p_pr = p.find("w:pPr", _NS) + if p_pr is None: + p_pr = ET.SubElement(p, f"{{{_W_NS}}}pPr") + ind = p_pr.find("w:ind", _NS) + if ind is not None: + p_pr.remove(ind) + jc = p_pr.find("w:jc", _NS) + if jc is None: + jc = ET.SubElement(p_pr, f"{{{_W_NS}}}jc") + jc.set(f"{{{_W_NS}}}val", "center") + for r in p.findall("w:r", _NS): + r_pr = r.find("w:rPr", _NS) + if r_pr is None: + r_pr = ET.SubElement(r, f"{{{_W_NS}}}rPr") + sz = r_pr.find("w:sz", _NS) + if sz is None: + sz = ET.SubElement(r_pr, f"{{{_W_NS}}}sz") + sz.set(f"{{{_W_NS}}}val", "19") # 9.5pt + sz_cs = r_pr.find("w:szCs", _NS) + if sz_cs is None: + sz_cs = ET.SubElement(r_pr, f"{{{_W_NS}}}szCs") + sz_cs.set(f"{{{_W_NS}}}val", "19") + spacing = r_pr.find("w:spacing", _NS) + if spacing is None: + spacing = ET.SubElement(r_pr, f"{{{_W_NS}}}spacing") + spacing.set(f"{{{_W_NS}}}val", "-8") + continue + + if _is_university_letterhead_paragraph(para_text): + _ensure_paragraph_centered_no_indent(p) + _ensure_university_letterhead_visual_split(p) + for r in p.findall("w:r", _NS): + if not _run_has_text_content(r): + continue + r_pr = r.find("w:rPr", _NS) + if r_pr is None: + r_pr = ET.SubElement(r, f"{{{_W_NS}}}rPr") + _ensure_run_not_bold(r_pr) + _ensure_run_not_italic(r_pr) + _ensure_run_times_new_roman(r_pr) + continue + + if _DON_VI_PREFIX in para_text: + _strip_don_vi_prefix_from_paragraph(p) + _ensure_paragraph_centered_no_indent(p) + for r in p.findall("w:r", _NS): + r_pr = r.find("w:rPr", _NS) + if r_pr is None: + r_pr = ET.SubElement(r, f"{{{_W_NS}}}rPr") + _ensure_run_bold(r_pr) + _ensure_run_not_italic(r_pr) + _ensure_run_times_new_roman(r_pr) + + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() + + +_BO_Y_TE_COMPACT = "BỘYTẾ" + + +_MAU_04_HEADER_COMPACT = "Mẫusố04" + + +def _paragraph_has_page_break(p: ET.Element) -> bool: + """True when ``p`` contains at least one ````.""" + type_attr = f"{{{_W_NS}}}type" + for br in p.findall(".//w:br", _NS): + if br.attrib.get(type_attr) == "page": + return True + return False + + +def _paragraph_is_empty_page_break(p: ET.Element) -> bool: + """True when the paragraph contains a page break and no visible text — i.e. its + only purpose is to force a page break. Such paragraphs render as a blank page in + docx-preview when followed by a table, even though LibreOffice/Word collapse them. + """ + if not _paragraph_has_page_break(p): + return False + for t in p.findall(".//w:t", _NS): + if (t.text or "").strip(): + return False + return True + + +def _ensure_pPr(p: ET.Element) -> ET.Element: + p_pr = p.find("w:pPr", _NS) + if p_pr is None: + p_pr = ET.Element(f"{{{_W_NS}}}pPr") + p.insert(0, p_pr) + return p_pr + + +def _ensure_page_break_before(p: ET.Element) -> None: + """Add ```` to the paragraph's ```` (after ``pStyle`` if + present, to satisfy the OOXML element order). Idempotent.""" + p_pr = _ensure_pPr(p) + if p_pr.find("w:pageBreakBefore", _NS) is not None: + return + pbb = ET.Element(f"{{{_W_NS}}}pageBreakBefore") + p_style = p_pr.find("w:pStyle", _NS) + if p_style is None: + p_pr.insert(0, pbb) + else: + children = list(p_pr) + idx = children.index(p_style) + p_pr.insert(idx + 1, pbb) + + +def _first_paragraph_inside_table(tbl: ET.Element) -> ET.Element | None: + """Return the first ```` inside the first cell of the first row of ``tbl``, or + ``None`` if the table has no paragraph (rare; tables always have a cell paragraph).""" + first_row = tbl.find("w:tr", _NS) + if first_row is None: + return None + first_cell = first_row.find("w:tc", _NS) + if first_cell is None: + return None + return first_cell.find("w:p", _NS) + + +def collapse_empty_page_break_paragraphs_in_docx(data: bytes) -> bytes: + """Eliminate blank pages introduced by empty paragraphs whose only purpose is to + host a ````. The browser-side docx-preview renderer treats such + paragraphs as occupying their own page when followed by a table (see body indices + where the rendered docx shows e.g. « page 4 / 10 » as a blank sheet between Mẫu số + 01 sig table and Mẫu số 02 letterhead table), even though LibreOffice/Word collapse + them onto the surrounding pages. + + Transform pattern (per empty page-break paragraph ``P_break``): + + 1. Look at the *next* body sibling of ``P_break``: + - ````: add ```` to its ```` (idempotent), then + remove ``P_break`` from the body. The next paragraph now starts on a new page. + - ````: add ```` to the ```` of the first + paragraph inside the first cell of the first row of the table, then remove + ``P_break``. The table is then anchored to a new page via the cell paragraph. + - Anything else / no next sibling: leave ``P_break`` untouched so the original + page break is preserved as a fallback. + + Idempotent: once a paragraph already carries ````, a second pass + does not double-register it; once the empty break paragraph is removed, there is + nothing left to collapse. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + body = root.find("w:body", _NS) + if body is not None: + _collapse_empty_page_break_paragraphs(body) + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() + + +def _collapse_empty_page_break_paragraphs(body: ET.Element) -> None: + """Body-direct-children scan: do not recurse into tables (page breaks inside table + cells are out of scope here).""" + children = list(body) + for i, child in enumerate(children): + if _local_tag(child) != "p": + continue + if not _paragraph_is_empty_page_break(child): + continue + nxt = children[i + 1] if i + 1 < len(children) else None + if nxt is None: + continue + + nxt_local = _local_tag(nxt) + if nxt_local == "p": + _ensure_page_break_before(nxt) + body.remove(child) + elif nxt_local == "tbl": + anchor = _first_paragraph_inside_table(nxt) + if anchor is None: + continue + _ensure_page_break_before(anchor) + body.remove(child) + + +def strip_mau_04_evaluation_section_in_docx(data: bytes) -> bytes: + """Remove the « Mẫu số 04 — PHIẾU ĐÁNH GIÁ SÁNG KIẾN » section from the applicant + template. The council evaluation form is filled in the dedicated council UI + (``fe0/src/components/council/evaluation/InitiativeEvaluationForm.tsx``); the + applicant's submission package must not embed an empty council scorecard. + + Section boundaries inside ``word/document.xml``: + + - Locate the ```` whose plain text is « Mẫu số 04 » (compact match handles any + stray NBSP / figure-space variants). + - Walk backwards through the body children until we hit the empty ```` that + contains a ```` — that page break ends the previous section's + page and starts the Mẫu số 04 page. Everything from that page-break paragraph up + to (and including) the Mẫu số 04 header is removed. + - Walk forwards from the Mẫu số 04 header through the body children until we hit + either the next ```` with a page break or a ````. We remove + everything strictly before that boundary, so the page break leading into the next + section (« Bản cam kết ») is preserved and the section keeps its own page. + + Idempotent: a second pass finds no « Mẫu số 04 » paragraph and is a no-op. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + body = root.find("w:body", _NS) + if body is not None: + _strip_mau_04_section_from_body(body) + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() + + +def _strip_mau_04_section_from_body(body: ET.Element) -> None: + """Locate the Mẫu số 04 header paragraph and remove the surrounding section bounded + by the page break before it and the page break (or ````) after it.""" + children = list(body) + + target_idx: int | None = None + for i, child in enumerate(children): + if _local_tag(child) != "p": + continue + compact = _compact_no_inner_spaces(_paragraph_plain_text(child)).strip() + if compact == _MAU_04_HEADER_COMPACT: + target_idx = i + break + + if target_idx is None: + return + + # Walk backwards to find the page-break paragraph that opens the Mẫu số 04 page. + # Bail out (do nothing) if no page break is found, so we never strip into the + # previous section by mistake. + start_idx: int | None = None + for j in range(target_idx - 1, -1, -1): + child = children[j] + if _local_tag(child) == "p" and _paragraph_has_page_break(child): + start_idx = j + break + if start_idx is None: + return + + # Walk forwards from the Mẫu số 04 header until we hit either the next page break + # paragraph (separator into the next section) or a . Stop one element + # before that boundary so the boundary marker itself survives. + end_idx: int = len(children) - 1 + for j in range(target_idx + 1, len(children)): + child = children[j] + if _local_tag(child) == "sectPr": + end_idx = j - 1 + break + if _local_tag(child) == "p" and _paragraph_has_page_break(child): + end_idx = j - 1 + break + + for child in children[start_idx : end_idx + 1]: + body.remove(child) + + +def normalize_subsequent_letterhead_tables(data: bytes) -> bytes: + """Two letterhead tables on later pages (Mẫu số 03 + Bản cam kết) carry the same + three-line block « BỘ Y TẾ », « ĐẠI HỌC Y DƯỢC THÀNH PHỐ HỒ CHÍ MINH » and + « CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM ». First-page-only + :func:`normalize_bo_y_te_header_lines` skips them, so this pass mirrors that + treatment inside the table cells: + + - « BỘ Y TẾ »: strip bold, center. + - University block: insert a soft between the two phrases so the cell + wraps cleanly into two stacked lines; keep bold; force upright and Times New + Roman; center. + - « CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM »: shrink to 9.5pt with condensed + character spacing and center so the phrase fits on a single line within the + narrow right cell (the same trick used for the Mẫu số 02 letterhead table). + + Scoped to tables containing both « BỘ Y TẾ » and the national-header phrase, so + the Mẫu số 02 letterhead table (which uses « ĐƠN VỊ: » instead of « BỘ Y TẾ ») + and unrelated tables are left untouched. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + for tbl in root.findall(".//w:tbl", _NS): + all_text = _table_plain_text(tbl) + if _NATIONAL_HEADER_PHRASE not in all_text: + continue + if _compact_no_inner_spaces(all_text).find(_BO_Y_TE_COMPACT) < 0: + continue + for p in tbl.findall(".//w:p", _NS): + para_text = _paragraph_plain_text(p) + compact = _compact_no_inner_spaces(para_text) + + if compact == _BO_Y_TE_COMPACT: + _ensure_paragraph_centered_no_indent(p) + for r in p.findall("w:r", _NS): + r_pr = r.find("w:rPr", _NS) + if r_pr is None: + r_pr = ET.SubElement(r, f"{{{_W_NS}}}rPr") + _ensure_run_not_bold(r_pr) + _ensure_run_not_italic(r_pr) + _ensure_run_times_new_roman(r_pr) + continue + + if _is_university_letterhead_paragraph(para_text): + _ensure_paragraph_centered_no_indent(p) + _ensure_university_letterhead_visual_split(p) + for r in p.findall("w:r", _NS): + if not _run_has_text_content(r): + continue + r_pr = r.find("w:rPr", _NS) + if r_pr is None: + r_pr = ET.SubElement(r, f"{{{_W_NS}}}rPr") + _ensure_run_bold(r_pr) + _ensure_run_not_italic(r_pr) + _ensure_run_times_new_roman(r_pr) + continue + + if _NATIONAL_HEADER_PHRASE in para_text: + p_pr = p.find("w:pPr", _NS) + if p_pr is None: + p_pr = ET.SubElement(p, f"{{{_W_NS}}}pPr") + ind = p_pr.find("w:ind", _NS) + if ind is not None: + p_pr.remove(ind) + jc = p_pr.find("w:jc", _NS) + if jc is None: + jc = ET.SubElement(p_pr, f"{{{_W_NS}}}jc") + jc.set(f"{{{_W_NS}}}val", "center") + for r in p.findall("w:r", _NS): + r_pr = r.find("w:rPr", _NS) + if r_pr is None: + r_pr = ET.SubElement(r, f"{{{_W_NS}}}rPr") + sz = r_pr.find("w:sz", _NS) + if sz is None: + sz = ET.SubElement(r_pr, f"{{{_W_NS}}}sz") + sz.set(f"{{{_W_NS}}}val", "19") # 9.5pt + sz_cs = r_pr.find("w:szCs", _NS) + if sz_cs is None: + sz_cs = ET.SubElement(r_pr, f"{{{_W_NS}}}szCs") + sz_cs.set(f"{{{_W_NS}}}val", "19") + spacing = r_pr.find("w:spacing", _NS) + if spacing is None: + spacing = ET.SubElement(r_pr, f"{{{_W_NS}}}spacing") + spacing.set(f"{{{_W_NS}}}val", "-8") + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() + + +def normalize_mau_02_signature_unit_prefix(data: bytes) -> bytes: + """In the Mẫu số 02 sign-off table (« Xác nhận của lãnh đạo / Tác giả sáng kiến »), + drop the literal « Đơn vị » word that the template prints before + ``{{ mau_02.don_vi }}`` so the rendered cell shows only the resolved unit name. + Forces the unit-name run bold and non-italic. + + Scoped to tables containing « Xác nhận của lãnh đạo », so the Mẫu số 03 column + header « Đơn vị công tác » and the trang bìa label « Đơn vị công tác: » remain + untouched. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + for tbl in root.findall(".//w:tbl", _NS): + all_text = _table_plain_text(tbl) + if _SIGNATURE_LEADER_PHRASE not in all_text: + continue + if _DON_VI_SIGNATURE_PREFIX not in all_text: + continue + for p in tbl.findall(".//w:p", _NS): + para_text = _paragraph_plain_text(p) + if not para_text.lstrip().startswith(_DON_VI_SIGNATURE_PREFIX): + continue + # Skip the header-style label « Đơn vị công tác » used elsewhere. + if "công tác" in para_text: + continue + if not _strip_text_prefix_from_paragraph(p, _DON_VI_SIGNATURE_PREFIX): + continue + for r in p.findall("w:r", _NS): + if not _run_has_text_content(r): + continue + r_pr = r.find("w:rPr", _NS) + if r_pr is None: + r_pr = ET.SubElement(r, f"{{{_W_NS}}}rPr") + _ensure_run_bold(r_pr) + _ensure_run_not_italic(r_pr) + _ensure_run_times_new_roman(r_pr) + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() + + +def normalize_mau_02_body_alignment_spacing(data: bytes) -> bytes: + """Normalize top-level paragraph alignment/spacing across the whole Mẫu số 02 section. + + The reference style is the compact, single-spaced body layout used in the sample + screenshot: headings keep their visual alignment, while regular body lines share + the same no-gap spacing so the page density remains consistent. + + Scope: + - Only `word/document.xml` + - Only top-level body paragraphs between `Mẫu số 02` and `Mẫu số 03` + - Table cell paragraphs are intentionally untouched + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + body = root.find("w:body", _NS) + if body is not None: + children = list(body) + start_idx = None + end_idx = None + for idx, child in enumerate(children): + if _local_tag(child) != "p": + continue + text = _paragraph_plain_text(child).strip() + if start_idx is None and "Mẫu số 02" in text: + start_idx = idx + continue + if start_idx is not None and "Mẫu số 03" in text: + end_idx = idx + break + if start_idx is not None: + if end_idx is None: + end_idx = len(children) + for child in children[start_idx:end_idx]: + if _local_tag(child) != "p": + continue + p = child + text = _paragraph_plain_text(p).strip() + if not text: + continue + + p_pr = p.find("w:pPr", _NS) + if p_pr is None: + p_pr = ET.SubElement(p, f"{{{_W_NS}}}pPr") + + # Remove manual paragraph indentation so each line follows + # one consistent body column. + ind = p_pr.find("w:ind", _NS) + if ind is not None: + p_pr.remove(ind) + + spacing = p_pr.find("w:spacing", _NS) + if spacing is None: + spacing = ET.SubElement(p_pr, f"{{{_W_NS}}}spacing") + spacing.set(f"{{{_W_NS}}}before", "0") + spacing.set(f"{{{_W_NS}}}after", "0") + spacing.set(f"{{{_W_NS}}}line", "240") + spacing.set(f"{{{_W_NS}}}lineRule", "auto") + + jc = p_pr.find("w:jc", _NS) + if jc is None: + jc = ET.SubElement(p_pr, f"{{{_W_NS}}}jc") + + if "Mẫu số 02" in text: + jc.set(f"{{{_W_NS}}}val", "right") + spacing.set(f"{{{_W_NS}}}before", "120") + elif "ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN" in text: + jc.set(f"{{{_W_NS}}}val", "center") + spacing.set(f"{{{_W_NS}}}before", "80") + spacing.set(f"{{{_W_NS}}}after", "80") + elif text.startswith("Kính gửi:"): + jc.set(f"{{{_W_NS}}}val", "center") + elif text.startswith("TP. Hồ Chí Minh, ngày"): + jc.set(f"{{{_W_NS}}}val", "right") + spacing.set(f"{{{_W_NS}}}before", "80") + else: + jc.set(f"{{{_W_NS}}}val", "left") + + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() + + +_SIGNATURE_DATE_PREFIX = "Tp. Hồ Chí Minh, ngày" + + +def _signature_table_date_already_lifted(tbl: ET.Element, col_count: int) -> bool: + """True when the table already starts with a single-cell gridSpan row hosting the date.""" + type_val_attr = f"{{{_W_NS}}}val" + for row in tbl.findall("w:tr", _NS): + cells = row.findall("w:tc", _NS) + if len(cells) != 1: + return False + tc_pr = cells[0].find("w:tcPr", _NS) + if tc_pr is None: + return False + grid_span = tc_pr.find("w:gridSpan", _NS) + if grid_span is None or grid_span.attrib.get(type_val_attr) != str(col_count): + return False + for p in cells[0].findall("w:p", _NS): + if _SIGNATURE_DATE_PREFIX in _paragraph_plain_text(p): + return True + return False + return False + + +def move_signature_date_to_top_row(data: bytes) -> bytes: + """Reflow each signature table so its «Tp. Hồ Chí Minh, ngày … tháng … năm …» paragraph + sits in its own row at the top of the table, hosted in a single cell that spans every + column. + + Two effects observable in the rendered PDF: + + - The date paragraph occupies a cell as wide as the table, so it fits on one visual line + (it was wrapping at half-cell width). + - The original row's other content rises one line, putting the signing-role labels + « LÃNH ĐẠO ĐƠN VỊ » and « Tác giả sáng kiến » on the same visual line. + + Idempotent: a second pass detects the new top row and is a no-op. Tables that do not + contain the date prefix are left untouched. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/document.xml": + root = ET.fromstring(raw) + for tbl in root.findall(".//w:tbl", _NS): + grid = tbl.find("w:tblGrid", _NS) + if grid is None: + continue + col_count = len(grid.findall("w:gridCol", _NS)) + if col_count < 2: + continue + if _signature_table_date_already_lifted(tbl, col_count): + continue + + hit = None + for cell in tbl.findall(".//w:tc", _NS): + for p in cell.findall("w:p", _NS): + if _SIGNATURE_DATE_PREFIX in _paragraph_plain_text(p): + hit = (cell, p) + break + if hit: + break + if not hit: + continue + + cell, p = hit + cell.remove(p) + if cell.find("w:p", _NS) is None: + ET.SubElement(cell, f"{{{_W_NS}}}p") + + # Right-align the lifted paragraph and drop any negative indent so + # the full row width is available. + p_pr = p.find("w:pPr", _NS) + if p_pr is None: + p_pr = ET.Element(f"{{{_W_NS}}}pPr") + p.insert(0, p_pr) + ind = p_pr.find("w:ind", _NS) + if ind is not None: + p_pr.remove(ind) + jc = p_pr.find("w:jc", _NS) + if jc is None: + jc = ET.SubElement(p_pr, f"{{{_W_NS}}}jc") + jc.set(f"{{{_W_NS}}}val", "right") + + new_row = ET.Element(f"{{{_W_NS}}}tr") + new_cell = ET.SubElement(new_row, f"{{{_W_NS}}}tc") + new_tc_pr = ET.SubElement(new_cell, f"{{{_W_NS}}}tcPr") + grid_span = ET.SubElement(new_tc_pr, f"{{{_W_NS}}}gridSpan") + grid_span.set(f"{{{_W_NS}}}val", str(col_count)) + # Preserve the borderless look of the surrounding signature cells. + tc_borders = ET.SubElement(new_tc_pr, f"{{{_W_NS}}}tcBorders") + for side in ("top", "left", "bottom", "right"): + b = ET.SubElement(tc_borders, f"{{{_W_NS}}}{side}") + b.set(f"{{{_W_NS}}}val", "nil") + new_cell.append(p) + + first_row_index = None + for idx, child in enumerate(list(tbl)): + if _local_tag(child) == "tr": + first_row_index = idx + break + if first_row_index is None: + tbl.append(new_row) + else: + tbl.insert(first_row_index, new_row) + + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() + + +def force_times_new_roman_in_styles_docx(data: bytes) -> bytes: + """ + Built-in paragraph styles still reference Calibri in `word/styles.xml`; use Times New Roman + and set docDefaults `rFonts` so inherited runs match when exporting to PDF. + """ + inp = io.BytesIO(data) + out = io.BytesIO() + with zipfile.ZipFile(inp, "r") as zin: + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + raw = zin.read(info.filename) + if info.filename == "word/styles.xml": + root = ET.fromstring(raw) + for fonts in root.findall(".//w:rFonts", _NS): + for key in list(fonts.attrib.keys()): + if key.endswith("}ascii") or key.endswith( + "}hAnsi" + ) or key.endswith("}cs") or key.endswith("}eastAsia"): + fonts.attrib[key] = "Times New Roman" + rpr_default = root.find("./w:docDefaults/w:rPrDefault/w:rPr", _NS) + if rpr_default is not None: + fonts = rpr_default.find("w:rFonts", _NS) + if fonts is None: + fonts = ET.SubElement(rpr_default, f"{{{_W_NS}}}rFonts") + tnr = "Times New Roman" + fonts.set(f"{{{_W_NS}}}ascii", tnr) + fonts.set(f"{{{_W_NS}}}hAnsi", tnr) + fonts.set(f"{{{_W_NS}}}cs", tnr) + fonts.set(f"{{{_W_NS}}}eastAsia", tnr) + raw = ET.tostring(root, encoding="utf-8", xml_declaration=True) + zout.writestr(info, raw) + return out.getvalue() diff --git a/be0/src/be01/docx_to_pdf.py b/be0/src/be01/docx_to_pdf.py new file mode 100644 index 0000000..0d16b71 --- /dev/null +++ b/be0/src/be01/docx_to_pdf.py @@ -0,0 +1,96 @@ +"""Convert DOCX bytes to PDF using headless LibreOffice (layout matches Word export).""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from src.be01.docx_normalize import ( + relax_justified_softbreak_paragraphs_in_docx, + strip_table_row_height_rules_from_docx, +) + + +def resolve_libreoffice_soffice() -> str: + """Return path to `soffice`/`libreoffice` binary.""" + env = (os.environ.get("LIBREOFFICE_PATH") or "").strip() + if env: + p = Path(env) + if p.is_file(): + return str(p.resolve()) + w = shutil.which(env) + if w: + return w + for name in ("soffice", "libreoffice"): + found = shutil.which(name) + if found: + return found + mac = Path("/Applications/LibreOffice.app/Contents/MacOS/soffice") + if mac.is_file(): + return str(mac) + raise FileNotFoundError( + "Không tìm thấy LibreOffice (soffice). Cài đặt libreoffice-writer-nogui hoặc đặt LIBREOFFICE_PATH." + ) + + +def convert_docx_bytes_to_pdf( + docx_bytes: bytes, + *, + timeout_sec: float = 120.0, + relax_justified_softbreaks: bool = True, + strip_table_row_heights: bool = False, +) -> bytes: + """ + Write DOCX to a temp file, run headless LibreOffice, read resulting PDF. + + Uses the same rendering stack as manual « Save as PDF » in LibreOffice (≈ Word layout). + + ``relax_justified_softbreaks`` defaults to ``True`` to run + :func:`~src.be01.docx_normalize.relax_justified_softbreak_paragraphs_in_docx` + (``distribute`` → ``both`` in document/styles/headers, split soft breaks in the body, + ``doNotExpandShiftReturn`` in settings) so LibreOffice/Word-like renderers avoid absurd + inter-word gaps while keeping justified paragraphs. + """ + if not docx_bytes or len(docx_bytes) < 100: + raise ValueError("DOCX payload quá nhỏ hoặc rỗng.") + + soffice = resolve_libreoffice_soffice() + if strip_table_row_heights: + docx_bytes = strip_table_row_height_rules_from_docx(docx_bytes) + if relax_justified_softbreaks: + docx_bytes = relax_justified_softbreak_paragraphs_in_docx(docx_bytes) + + with tempfile.TemporaryDirectory(prefix="docx2pdf-") as td: + td_path = Path(td) + docx_path = td_path / "document.docx" + docx_path.write_bytes(docx_bytes) + home = td_path / ".lo_home" + home.mkdir(parents=True, exist_ok=True) + cmd = [ + soffice, + "--headless", + "--nologo", + "--nodefault", + "--nofirststartwizard", + "--convert-to", + "pdf", + "--outdir", + str(td_path), + str(docx_path), + ] + env = {**os.environ, "HOME": str(home)} + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout_sec, + env=env, + ) + pdf_path = td_path / "document.pdf" + if proc.returncode != 0 or not pdf_path.is_file(): + err = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}" + raise RuntimeError(f"LibreOffice không chuyển được DOCX sang PDF: {err}") + return pdf_path.read_bytes() diff --git a/be0/src/be01/export_applications_list_xlsx.py b/be0/src/be01/export_applications_list_xlsx.py new file mode 100644 index 0000000..ddec186 --- /dev/null +++ b/be0/src/be01/export_applications_list_xlsx.py @@ -0,0 +1,90 @@ +"""Excel export for admin danh sách sáng kiến (TT / MSSK: «YYYY-n» theo năm hồ sơ).""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime, timezone +from io import BytesIO +from typing import Any, Dict, List, Tuple + +import pandas as pd + + +def _calendar_year(row: Dict[str, Any]) -> int: + cy = row.get("calendarYear") + if isinstance(cy, int) and cy > 1900: + return cy + sd = str(row.get("submittedDate") or "") + if len(sd) >= 4 and sd[:4].isdigit(): + return int(sd[:4]) + return datetime.now(timezone.utc).year + + +def _merit_xep_loai(row: Dict[str, Any]) -> str: + """Aligned with fe0 «Đã duyệt»: 2.1.1 / 2.1.2 và Xuất sắc — Sách giáo trình → Xuất sắc; otherwise approved → Khá.""" + if str(row.get("status") or "") != "approved": + return "" + ic = row.get("initiativeClassification") + rek = str(row.get("researchEvidenceKind") or row.get("research_evidence_kind") or "") + tek = str(row.get("textbookEvidenceKind") or row.get("textbook_evidence_kind") or "") + if ic == "research" and rek in ("international", "domestic"): + return "Xuất sắc" + if ic == "textbook" and tek == "book": + return "Xuất sắc" + if ic in ("research", "textbook", "technical"): + return "Khá" + return "Khá" + + +def _authors_cell(row: Dict[str, Any], payload: Dict[str, Any]) -> str: + tabs = payload.get("tabs") if isinstance(payload.get("tabs"), dict) else {} + app_tab = tabs.get("application") if isinstance(tabs.get("application"), dict) else {} + authors = app_tab.get("authors") + if isinstance(authors, list) and authors: + names: List[str] = [] + for a in authors: + if isinstance(a, dict): + n = str(a.get("name") or "").strip() + if n: + names.append(n) + if names: + return ", ".join(names) + author = row.get("author") or {} + single = str((author.get("name") if isinstance(author, dict) else "") or "").strip() + return single or "—" + + +def _year_stt_codes(pairs: List[Tuple[Dict[str, Any], Dict[str, Any]]]) -> List[str]: + """Per calendar year, running index 1..n → «2026-1», «2026-2», …""" + by_year: Dict[int, int] = defaultdict(int) + out: List[str] = [] + for row, _ in pairs: + y = _calendar_year(row) + by_year[y] += 1 + out.append(f"{y}-{by_year[y]}") + return out + + +def build_applications_list_xlsx(pairs: List[Tuple[Dict[str, Any], Dict[str, Any]]]) -> bytes: + """ + One sheet, headers: TT, MSSK, Tên sáng kiến, Tác giả, Xếp loại. + TT and MSSK both use «YYYY-n» (n resets each calendar year, export order). + """ + codes = _year_stt_codes(pairs) + rows_out: List[Dict[str, Any]] = [] + for i, (row, payload) in enumerate(pairs): + code = codes[i] + rows_out.append( + { + "TT": code, + "MSSK": code, + "Tên sáng kiến": str(row.get("name") or ""), + "Tác giả": _authors_cell(row, payload), + "Xếp loại": _merit_xep_loai(row), + } + ) + df = pd.DataFrame(rows_out) + buf = BytesIO() + with pd.ExcelWriter(buf, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name="Danh sách") + return buf.getvalue() diff --git a/be0/src/be01/fill_application_form.py b/be0/src/be01/fill_application_form.py new file mode 100644 index 0000000..4b689d0 --- /dev/null +++ b/be0/src/be01/fill_application_form.py @@ -0,0 +1,121 @@ +"""Fill `fe0/public/assets/template_application_form.docx` (docxtpl) from be01 `data_blank` context.""" + +from __future__ import annotations + +import io +import os +import re +import zipfile +from pathlib import Path +from typing import Any, Dict + + +def get_application_template_path() -> Path: + """Resolve the Word file used by the applicant « application form » preview.""" + env = (os.environ.get("TEMPLATE_APPLICATION_FORM_DOCX") or "").strip() + if env: + p = Path(env) + if p.is_file(): + return p + # be0/src/be01/thisfile → repo root = parents[3] (be01, src, be0, workspace) + # be0/src/be01/thisfile → parents[3] = monorepo root (…/be0 → …/src → …/be0 → project root) + here = Path(__file__).resolve() + root = here.parents[3] + candidate = root / "fe0" / "public" / "assets" / "template_application_form.docx" + # In Docker, project root is often not mounted; set TEMPLATE_APPLICATION_FORM_DOCX (see docker-compose). + if candidate.is_file(): + return candidate + raise FileNotFoundError( + f"Không tìm thấy template_application_form.docx (đã tìm {candidate}. " + "Đặt biến môi trường TEMPLATE_APPLICATION_FORM_DOCX tới file .docx hợp lệ.)" + ) + + +def fill_application_form_docx(context: Dict[str, Any]) -> bytes: + """Run docxtpl render in memory; `context` is the be01 / `data_blank.json` object tree.""" + from docxtpl import DocxTemplate + from jinja2.exceptions import TemplateSyntaxError + from src.be01.docx_normalize import ( + collapse_empty_page_break_paragraphs_in_docx, + force_times_new_roman_in_styles_docx, + move_signature_date_to_top_row, + normalize_bo_y_te_header_lines, + normalize_mau_02_body_alignment_spacing, + normalize_mau_02_letterhead_table, + normalize_mau_02_signature_unit_prefix, + normalize_subsequent_letterhead_tables, + relax_justified_softbreak_paragraphs_in_docx, + shrink_overflow_sensitive_text_half_point, + shift_selected_header_lines_left, + strip_mau_04_evaluation_section_in_docx, + strip_table_row_height_rules_from_docx, + style_national_header_line, + ) + + def _rewrite_structural_docxtpl_tags(template_bytes: bytes) -> bytes: + """ + Convert docxtpl structural tags (`{%tr ... %}`, `{%tc ... %}`, etc.) to + plain Jinja tags so rendering can proceed when control tags were placed + in paragraphs instead of the expected OOXML container. + """ + marker = re.compile(r"\{%\s*(tr|tc|p|r)\s+", flags=re.IGNORECASE) + in_buf = io.BytesIO(template_bytes) + out_buf = io.BytesIO() + with zipfile.ZipFile(in_buf, "r") as zin, zipfile.ZipFile(out_buf, "w") as zout: + for info in zin.infolist(): + data = zin.read(info.filename) + if info.filename.endswith(".xml"): + text = data.decode("utf-8", errors="ignore") + text = marker.sub("{% ", text) + data = text.encode("utf-8") + zout.writestr(info, data) + return out_buf.getvalue() + + template_path = get_application_template_path() + doc = DocxTemplate(str(template_path)) + try: + doc.render(context) + except TemplateSyntaxError as exc: + msg = str(exc).lower() + if not any(f"unknown tag '{tag}'" in msg for tag in ("tr", "tc", "p", "r")): + raise + patched_template = _rewrite_structural_docxtpl_tags(template_path.read_bytes()) + doc = DocxTemplate(io.BytesIO(patched_template)) + doc.render(context) + buf = io.BytesIO() + doc.docx.save(buf) + raw = buf.getvalue() + # Mẫu số 04 (« PHIẾU ĐÁNH GIÁ SÁNG KIẾN ») is filled by council members in the + # dedicated council UI (InitiativeEvaluationForm.tsx under /council). The applicant + # submission package must not embed a blank scorecard, so strip the section out of + # the rendered DOCX before any other normalization pass touches its letterhead. + raw = strip_mau_04_evaluation_section_in_docx(raw) + # docx-preview (the browser-side viewer) renders an empty paragraph that only hosts + # a as occupying its own blank page when the next sibling is a + # table — which the rendered application form does between sections (e.g. between + # Mẫu số 01 sig table and Mẫu số 02 letterhead table). Collapse those page-break + # paragraphs into on the next content so docx-preview no longer + # allocates a blank page in between. LibreOffice/Word are unaffected since they + # treat both encodings the same way at render time. + raw = collapse_empty_page_break_paragraphs_in_docx(raw) + raw = shrink_overflow_sensitive_text_half_point(raw) + raw = style_national_header_line(raw) + raw = shift_selected_header_lines_left(raw) + raw = normalize_bo_y_te_header_lines(raw) + raw = normalize_mau_02_body_alignment_spacing(raw) + raw = normalize_mau_02_letterhead_table(raw) + raw = normalize_mau_02_signature_unit_prefix(raw) + raw = normalize_subsequent_letterhead_tables(raw) + raw = move_signature_date_to_top_row(raw) + raw = force_times_new_roman_in_styles_docx(raw) + # Justified paragraphs that contain soft line breaks (or distribute alignment) stretch + # short trailing lines to fill the column, producing gaps of many spaces between every + # word. Split such paragraphs at soft breaks (keeping justification, both -> last line + # naturally unstretched) and rewrite distribute -> both so word spacing stays compact. + raw = relax_justified_softbreak_paragraphs_in_docx(raw) + + # Strict-fidelity default: do not mutate layout-critical OOXML after render. + # This normalization is opt-in via env flag for specific browser fallback issues. + if (os.environ.get("DOCX_STRIP_TABLE_ROW_HEIGHTS") or "").strip() in {"1", "true", "TRUE", "yes", "YES"}: + raw = strip_table_row_height_rules_from_docx(raw) + return raw diff --git a/be0/src/be01/fill_template.py b/be0/src/be01/fill_template.py new file mode 100644 index 0000000..77f575a --- /dev/null +++ b/be0/src/be01/fill_template.py @@ -0,0 +1,45 @@ +""" +Fill the template DOCX with data from a JSON file. + +Usage: + python fill_template.py [] + +Example: + python fill_template.py data_sop.json output.docx + python fill_template.py data_blank.json blank_output.docx + +Requirements: + pip install docxtpl +""" +import json +import sys +from pathlib import Path +from docxtpl import DocxTemplate + +TEMPLATE = Path(__file__).parent / "template_sang_kien.docx" + + +def fill(json_path: str, output_path: str = "filled.docx"): + # Load data + with open(json_path, "r", encoding="utf-8") as f: + context = json.load(f) + + # Convert \n in strings to real line breaks that Word will display. + # docxtpl renders \n as a soft line break only if we pre-process or use its + # Listing helper. The simplest workaround: leave \n as-is — Word renders + # most of them; for safety, we replace with explicit line-break tags. + # (docxtpl 0.16+ supports newlines if passed through Listing or RichText.) + + tpl = DocxTemplate(str(TEMPLATE)) + tpl.render(context) + tpl.save(output_path) + print(f"✓ Filled document saved: {output_path}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python fill_template.py []") + sys.exit(1) + json_file = sys.argv[1] + out_file = sys.argv[2] if len(sys.argv) > 2 else "filled.docx" + fill(json_file, out_file) diff --git a/be0/src/be01/filled_sop.docx b/be0/src/be01/filled_sop.docx new file mode 100644 index 0000000000000000000000000000000000000000..3ad49aa3b62dcb51b6324423a32ea5baa8f9c323 GIT binary patch literal 45637 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 literal 0 HcmV?d00001 diff --git a/be0/src/be01/official_to_data_blank.py b/be0/src/be01/official_to_data_blank.py new file mode 100644 index 0000000..2a8942b --- /dev/null +++ b/be0/src/be01/official_to_data_blank.py @@ -0,0 +1,414 @@ +"""Convert official Vietnamese-key ReviewPanel JSON into be01 `data_blank.json` shape.""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from typing import Any, Dict + +_DATA_BLANK_PATH = Path(__file__).parent / "data_blank.json" + + +def _s(v: Any) -> str: + if v is None: + return "" + return str(v).strip() + + +def _resolve_don_vi_cong_tac(official: Dict[str, Any]) -> str: + """ + TRANG BÌA / Mẫu 02 « Đơn vị » for docxtpl (`trang_bia.don_vi`, `mau_02.don_vi`). + + Aligns with fe0 `resolveDonViCongTacDisplay`: explicit cover/Mẫu02 fields first, then + first author « Nơi công tác », then Mẫu 03 « Chức vụ, đơn vị », then first Mẫu 03 + table row. Older rows in DB may have empty « Đơn vị công tác » but filled author + workplace — this fills the Word context without re-saving from the SPA. + """ + bia = official.get("TRANG BÌA") if isinstance(official.get("TRANG BÌA"), dict) else {} + m02 = official.get("MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN") + m02 = m02 if isinstance(m02, dict) else {} + m03 = official.get("MẪU SỐ 03 - BẢN XÁC NHẬN TỶ LỆ (%) ĐÓNG GÓP VÀO VIỆC TẠO RA SÁNG KIẾN") + m03 = m03 if isinstance(m03, dict) else {} + + for val in (_s(bia.get("Đơn vị công tác")), _s(m02.get("Đơn vị"))): + if val: + return val + + dstg = m02.get("Danh sách tác giả") + if isinstance(dstg, list) and dstg: + row0 = dstg[0] + if isinstance(row0, dict): + noi = _s(row0.get("Nơi công tác")) + if noi: + return noi + + chuc = _s(m03.get("Chức vụ, đơn vị công tác")) + if chuc: + return chuc + + tyle = m03.get("Tỷ lệ đóng góp") + if isinstance(tyle, list): + for row in tyle: + if isinstance(row, dict): + dv = _s(row.get("Đơn vị công tác")) + if dv: + return dv + return "" + + +def _blank() -> Dict[str, Any]: + with open(_DATA_BLANK_PATH, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def official_to_data_blank(official: Dict[str, Any]) -> Dict[str, Any]: + out = copy.deepcopy(_blank()) + don_vi_resolved = _resolve_don_vi_cong_tac(official) + + bia = official.get("TRANG BÌA") if isinstance(official.get("TRANG BÌA"), dict) else {} + out["trang_bia"]["ten_sang_kien"] = str(bia.get("Tên sáng kiến (Tiếng Việt)") or "") + out["trang_bia"]["tac_gia"] = str(bia.get("Tác giả/nhóm tác giả sáng kiến") or "") + out["trang_bia"]["don_vi"] = don_vi_resolved + out["trang_bia"]["thong_tin_lien_he"] = str(bia.get("Thông tin liên hệ (Điện thoại, Email)") or "") + out["trang_bia"]["nam"] = str(bia.get("Năm") or "") + + m01 = official.get("MẪU SỐ 01 - BÁO CÁO MÔ TẢ SÁNG KIẾN") + if isinstance(m01, dict): + out["mau_01"]["mo_dau"] = str(m01.get("1. Mở đầu") or "") + out["mau_01"]["ten_sang_kien"] = str( + m01.get("2. Tên sáng kiến (tên quy trình, giải pháp, phương pháp)") or "" + ) + out["mau_01"]["linh_vuc_ap_dung"] = str(m01.get("3. Lĩnh vực áp dụng của sáng kiến") or "") + m4 = m01.get("4. Mô tả sáng kiến") if isinstance(m01.get("4. Mô tả sáng kiến"), dict) else {} + out["mau_01"]["tinh_trang_da_biet"] = str( + m4.get("4.1 Tình trạng giải pháp đã biết hoặc hiện trạng công tác khi chưa có sáng kiến") or "" + ) + inner = ( + m4.get("4.2 Nội dung giải pháp đề nghị công nhận là sáng kiến") + if isinstance(m4.get("4.2 Nội dung giải pháp đề nghị công nhận là sáng kiến"), dict) + else {} + ) + out["mau_01"]["muc_dich"] = str(inner.get("Mục đích của sáng kiến") or "") + nd = inner.get("Về nội dung của sáng kiến") if isinstance(inner.get("Về nội dung của sáng kiến"), dict) else {} + out["mau_01"]["cac_buoc_thuc_hien"] = str(nd.get("Các bước thực hiện giải pháp") or "") + out["mau_01"]["dieu_kien_ap_dung"] = str(nd.get("Các điều kiện cần thiết để áp dụng giải pháp") or "") + out["mau_01"]["linh_vuc_ap_dung_2"] = str(nd.get("Lĩnh vực áp dụng") or "") + out["mau_01"]["ket_qua_thu_duoc"] = str(nd.get("Kết quả thu được") or "") + ds = nd.get("Danh sách đơn vị/cá nhân đã tham gia áp dụng thử hoặc lần đầu") + if isinstance(ds, list) and ds: + out["mau_01"]["danh_sach_ap_dung"] = [ + { + "tt": str(x.get("TT") or ""), + "ten_to_chuc": str(x.get("Tên tổ chức/cá nhân") or ""), + "dia_chi": str(x.get("Địa chỉ") or ""), + "linh_vuc": str(x.get("Lĩnh vực áp dụng sáng kiến") or ""), + } + for x in ds + if isinstance(x, dict) + ] or out["mau_01"]["danh_sach_ap_dung"] + out["mau_01"]["tinh_moi"] = str(inner.get("Về tính mới của sáng kiến") or "") + thq = inner.get("Về tính hiệu quả") if isinstance(inner.get("Về tính hiệu quả"), dict) else {} + out["mau_01"]["tinh_hieu_qua"]["loi_ich_kinh_te"] = str(thq.get("Tạo ra lợi ích kinh tế") or "") + out["mau_01"]["tinh_hieu_qua"]["hieu_qua_giang_day"] = str(thq.get("Đem lại hiệu quả trong giảng dạy") or "") + out["mau_01"]["tinh_hieu_qua"]["tang_nang_suat"] = str(thq.get("Tăng năng suất lao động") or "") + out["mau_01"]["tinh_hieu_qua"]["nang_cao_hieu_qua"] = str(thq.get("Nâng cao hiệu quả công việc") or "") + out["mau_01"]["tinh_hieu_qua"]["nang_cao_chat_luong"] = str( + thq.get("Nâng cao chất lượng công việc, dịch vụ") or "" + ) + out["mau_01"]["tinh_hieu_qua"]["giam_chi_phi"] = str(thq.get("Giảm chi phí") or "") + out["mau_01"]["tinh_hieu_qua"]["cai_thien_moi_truong"] = str( + thq.get("Cải thiện môi trường, điều kiện học tập, làm việc, sống") or "" + ) + out["mau_01"]["tinh_hieu_qua"]["bao_ve_suc_khoe"] = str(thq.get("Bảo vệ sức khỏe") or "") + out["mau_01"]["tinh_hieu_qua"]["an_toan_lao_dong"] = str(thq.get("Đảm bảo an toàn lao động, PCCC") or "") + out["mau_01"]["tinh_hieu_qua"]["nang_cao_nhan_thuc"] = str( + thq.get("Nâng cao khả năng, trình độ, nhận thức, trách nhiệm") or "" + ) + out["mau_01"]["thong_tin_bao_mat"] = str(m01.get("6. Những thông tin cần được bảo mật (nếu có)") or "") + nk = m01.get("Ngày ký") if isinstance(m01.get("Ngày ký"), dict) else {} + out["mau_01"]["ngay_ky"]["ngay"] = str(nk.get("Ngày") or "") + out["mau_01"]["ngay_ky"]["thang"] = str(nk.get("Tháng") or "") + out["mau_01"]["ngay_ky"]["nam"] = str(nk.get("Năm") or "") + out["mau_01"]["lanh_dao_don_vi"] = str(m01.get("Lãnh đạo đơn vị (Ký, ghi rõ họ tên)") or "") + out["mau_01"]["tac_gia_sang_kien"] = str(m01.get("Tác giả sáng kiến (Ký, ghi rõ họ tên)") or "") + + m02 = official.get("MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN") + if isinstance(m02, dict): + out["mau_02"]["don_vi"] = don_vi_resolved + dstg = m02.get("Danh sách tác giả") + if isinstance(dstg, list) and dstg: + out["mau_02"]["danh_sach_tac_gia"] = [ + { + "stt": str(x.get("STT") or ""), + "ho_ten": str(x.get("Họ và tên") or ""), + "ngay_sinh": str(x.get("Ngày tháng năm sinh") or ""), + "noi_cong_tac": str(x.get("Nơi công tác") or ""), + "chuc_danh": str(x.get("Chức danh") or ""), + "trinh_do": str(x.get("Trình độ chuyên môn") or ""), + "ty_le": str(x.get("Tỷ lệ (%) đóng góp vào việc tạo ra sáng kiến") or ""), + } + for x in dstg + if isinstance(x, dict) + ] or out["mau_02"]["danh_sach_tac_gia"] + out["mau_02"]["ten_sang_kien"] = str(m02.get("Tên sáng kiến đề nghị xét công nhận") or "") + out["mau_02"]["chu_dau_tu"] = str(m02.get("Chủ đầu tư tạo ra sáng kiến") or "") + out["mau_02"]["linh_vuc_ap_dung"] = str(m02.get("Lĩnh vực áp dụng sáng kiến") or "") + out["mau_02"]["ngay_ap_dung"] = str(m02.get("Ngày sáng kiến được áp dụng") or "") + out["mau_02"]["noi_dung"] = str(m02.get("Nội dung của sáng kiến") or "") + pl = m02.get("Phân loại sáng kiến (đánh dấu ☑)") + if isinstance(pl, dict): + k1 = "Giải pháp kỹ thuật, quản lý, tác nghiệp, ứng dụng tiến bộ kỹ thuật áp dụng cho ĐHYD TP.HCM" + k2 = "Sáng kiến – cải tiến kỹ thuật từ các nghiên cứu khoa học có kết quả được đăng tải trên các tạp chí, hội nghị trong nước và quốc tế" + k3 = "Sáng kiến – cải tiến kỹ thuật từ sách, giáo trình, tài liệu tham khảo" + out["mau_02"]["phan_loai"]["giai_phap_ky_thuat"] = bool(pl.get(k1)) + out["mau_02"]["phan_loai"]["sang_kien_tu_nckh"] = bool(pl.get(k2)) + out["mau_02"]["phan_loai"]["sang_kien_tu_sach"] = bool(pl.get(k3)) + out["mau_02"]["thong_tin_bao_mat"] = str(m02.get("Những thông tin cần được bảo mật (nếu có)") or "") + out["mau_02"]["dieu_kien_ap_dung"] = str(m02.get("Các điều kiện cần thiết để áp dụng sáng kiến") or "") + out["mau_02"]["danh_gia_tac_gia"] = str( + m02.get("Đánh giá lợi ích theo ý kiến của tác giả") or "" + ) + out["mau_02"]["danh_gia_to_chuc"] = str( + m02.get("Đánh giá lợi ích theo ý kiến của tổ chức, cá nhân đã tham gia áp dụng sáng kiến lần đầu") + or "" + ) + dsg = m02.get("Danh sách những người đã tham gia áp dụng thử hoặc áp dụng sáng kiến lần đầu") + if isinstance(dsg, list) and dsg: + out["mau_02"]["danh_sach_tham_gia"] = [ + { + "stt": str(x.get("Số TT") or ""), + "ho_ten": str(x.get("Họ và tên") or ""), + "ngay_sinh": str(x.get("Ngày tháng năm sinh") or ""), + "noi_cong_tac": str(x.get("Nơi công tác") or ""), + "chuc_danh": str(x.get("Chức danh") or ""), + "trinh_do": str(x.get("Trình độ chuyên môn") or ""), + "noi_dung_ho_tro": str(x.get("Nội dung công việc hỗ trợ") or ""), + } + for x in dsg + if isinstance(x, dict) + ] or out["mau_02"]["danh_sach_tham_gia"] + m02nk = m02.get("Ngày ký") if isinstance(m02.get("Ngày ký"), dict) else {} + out["mau_02"]["ngay_ky"]["ngay"] = str(m02nk.get("Ngày") or "") + out["mau_02"]["ngay_ky"]["thang"] = str(m02nk.get("Tháng") or "") + out["mau_02"]["ngay_ky"]["nam"] = str(m02nk.get("Năm") or "") + out["mau_02"]["lanh_dao_don_vi"] = str(m02.get("Xác nhận của lãnh đạo Đơn vị") or "") + out["mau_02"]["tac_gia_sang_kien"] = str(m02.get("Tác giả sáng kiến (Ký, ghi rõ họ tên)") or "") + + m03 = official.get("MẪU SỐ 03 - BẢN XÁC NHẬN TỶ LỆ (%) ĐÓNG GÓP VÀO VIỆC TẠO RA SÁNG KIẾN") + if isinstance(m03, dict): + m03nk = m03.get("Ngày ký") if isinstance(m03.get("Ngày ký"), dict) else {} + out["mau_03"]["ngay_ky"]["ngay"] = str(m03nk.get("Ngày") or "") + out["mau_03"]["ngay_ky"]["thang"] = str(m03nk.get("Tháng") or "") + out["mau_03"]["ngay_ky"]["nam"] = str(m03nk.get("Năm") or "") + out["mau_03"]["ten_sang_kien"] = str(m03.get("1. Tên sáng kiến") or "") + out["mau_03"]["tac_gia_chinh"] = str( + m03.get("2. Tác giả chính/Đại diện nhóm tác giả sáng kiến") or "" + ) + out["mau_03"]["chuc_vu_don_vi"] = str(m03.get("Chức vụ, đơn vị công tác") or "") + tyle = m03.get("Tỷ lệ đóng góp") + if isinstance(tyle, list) and tyle: + out["mau_03"]["ty_le_dong_gop"] = [ + { + "stt": str(x.get("STT") or ""), + "ho_ten": str(x.get("Họ và tên") or ""), + "don_vi": str(x.get("Đơn vị công tác") or ""), + "phan_tram": str(x.get("% đóng góp") or ""), + "chu_ky": str(x.get("Chữ ký xác nhận") or ""), + } + for x in tyle + if isinstance(x, dict) + ] or out["mau_03"]["ty_le_dong_gop"] + out["mau_03"]["tac_gia_chinh_ky"] = str( + m03.get("Tác giả chính/Đại diện nhóm tác giả sáng kiến (chữ ký và ghi rõ họ tên)") + or "" + ) + + m04 = official.get("MẪU SỐ 04 - PHIẾU ĐÁNH GIÁ SÁNG KIẾN") + if isinstance(m04, dict): + out["mau_04"]["ten_sang_kien"] = str(m04.get("1. Tên sáng kiến") or "") + out["mau_04"]["tac_gia"] = str(m04.get("2. Tác giả/đồng tác giả sáng kiến") or "") + out["mau_04"]["chuc_vu_don_vi"] = str(m04.get("Chức vụ, đơn vị công tác") or "") + ndg = m04.get("3. Nội dung đánh giá") if isinstance(m04.get("3. Nội dung đánh giá"), dict) else {} + tm = ndg.get("Tính mới (Tối đa 40 điểm)") if isinstance(ndg.get("Tính mới (Tối đa 40 điểm)"), dict) else {} + th = ( + ndg.get("Tính hiệu quả (Tối đa 60 điểm)") + if isinstance(ndg.get("Tính hiệu quả (Tối đa 60 điểm)"), dict) + else {} + ) + out["mau_04"]["tinh_moi"]["nhan_xet"] = str(tm.get("Nhận xét") or "") + out["mau_04"]["tinh_moi"]["diem"] = str(tm.get("Điểm chấm") or "") + out["mau_04"]["tinh_hieu_qua"]["nhan_xet"] = str(th.get("Nhận xét") or "") + out["mau_04"]["tinh_hieu_qua"]["diem"] = str(th.get("Điểm chấm") or "") + out["mau_04"]["tong_cong"] = str(ndg.get("Tổng cộng") or "") + out["mau_04"]["ket_luan"] = str(m04.get("Kết luận") or "") + m4nk = m04.get("Ngày ký") if isinstance(m04.get("Ngày ký"), dict) else {} + out["mau_04"]["ngay_ky"]["ngay"] = str(m4nk.get("Ngày") or "") + out["mau_04"]["ngay_ky"]["thang"] = str(m4nk.get("Tháng") or "") + out["mau_04"]["ngay_ky"]["nam"] = str(m4nk.get("Năm") or "") + out["mau_04"]["thanh_vien_hoi_dong"] = str( + m04.get("Thành viên Hội đồng (Ký, ghi rõ họ tên)") or "" + ) + + bck = official.get("BẢN CAM KẾT") + if isinstance(bck, dict): + bnk = bck.get("Ngày ký") if isinstance(bck.get("Ngày ký"), dict) else {} + out["ban_cam_ket"]["ngay_ky"]["ngay"] = str(bnk.get("Ngày") or "") + out["ban_cam_ket"]["ngay_ky"]["thang"] = str(bnk.get("Tháng") or "") + out["ban_cam_ket"]["ngay_ky"]["nam"] = str(bnk.get("Năm") or "") + i1 = bck.get("I. THÔNG TIN CHỦ THỂ CAM KẾT") + if isinstance(i1, dict): + out["ban_cam_ket"]["tac_gia_dang_ky"] = str(i1.get("Tác giả đăng ký sáng kiến") or "") + out["ban_cam_ket"]["cccd"] = str(i1.get("CCCD/Hộ chiếu số") or "") + out["ban_cam_ket"]["don_vi"] = str(i1.get("Đơn vị") or "") + ten_raw = i1.get( + "Tên Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH" + ) or i1.get("Tên Bài báo trong nước/quốc tế") + out["ban_cam_ket"]["ten_bai_bao"] = str(ten_raw or "") + out["ban_cam_ket"]["nam_xet"] = str(i1.get("Năm xét công nhận sáng kiến") or "") + vt = i1.get("Vai trò đối với bài báo (☑ vào ô tương ứng)") + if isinstance(vt, dict): + out["ban_cam_ket"]["vai_tro"]["tac_gia_chinh"] = bool( + vt.get("Tác giả chính Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH") + ) + out["ban_cam_ket"]["vai_tro"]["dong_tac_gia"] = bool( + vt.get("Đồng tác giả Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH") + ) + ii = bck.get("II. CAM KẾT NỘI DUNG (☑ vào ô tương ứng)") + if isinstance(ii, dict): + # Keys must match `bieu_mau_sang_kien_template.json` (numbered subsections). Legacy unnumbered keys kept as fallback. + quyen = ii.get("1. Quyền sở hữu đối với bài báo trong nước/quốc tế") + if not isinstance(quyen, dict): + quyen = ii.get("Quyền sở hữu đối với bài báo trong nước/quốc tế") + if isinstance(quyen, dict): + kq1_full = ( + "Tôi là chủ sở hữu hợp pháp của bài báo hoặc được chủ sở hữu/đồng chủ sở hữu đồng ý cho sử dụng bài báo có tên nêu trên làm sản phẩm đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD" + ) + kq1_short = "Tôi là chủ sở hữu hợp pháp của bài báo hoặc được chủ sở hữu/đồng chủ sở hữu đồng ý cho sử dụng bài báo" + kq2_full = ( + "Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH: chủ sở hữu bài báo (cơ quan) đồng ý cho tác giả/nhóm tác giả sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD" + ) + kq2_short = "Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH: chủ sở hữu bài báo (cơ quan) đồng ý cho tác giả/nhóm tác giả sử dụng" + out["ban_cam_ket"]["cam_ket"]["quyen_so_huu_1"] = bool( + quyen.get(kq1_full) or quyen.get(kq1_short) + ) + out["ban_cam_ket"]["cam_ket"]["quyen_so_huu_2"] = bool( + quyen.get(kq2_full) or quyen.get(kq2_short) + ) + dt = ii.get("2. Đồng thuận của đồng tác giả bài báo trong nước/quốc tế") + if not isinstance(dt, dict): + dt = ii.get("Đồng thuận của đồng tác giả bài báo trong nước/quốc tế") + if isinstance(dt, dict): + kd_full = ( + "Tất cả đồng tác giả đã biết, đồng ý và ký xác nhận cho phép Tác giả đăng ký sáng kiến được sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD" + ) + kd_short = "Tất cả đồng tác giả đã biết, đồng ý và ký xác nhận cho phép Tác giả đăng ký sáng kiến" + out["ban_cam_ket"]["cam_ket"]["dong_thuan"] = bool( + dt.get(kd_full) or dt.get(kd_short) + ) + uy = ii.get("3. Cam kết bài báo trong nước/quốc tế uy tín") + if not isinstance(uy, dict): + uy = ii.get("Cam kết bài báo trong nước/quốc tế uy tín") + if isinstance(uy, dict): + ku_full = ( + "Cá nhân đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD đối với bài báo trong nước/quốc tế cam kết bài báo không thuộc 'Tạp chí săn mồi'. Tôi xin chịu trách nhiệm kiểm tra, đối chiếu và cung cấp bằng chứng khi được yêu cầu" + ) + ku_short = "Cam kết bài báo không thuộc 'Tạp chí săn mồi'" + out["ban_cam_ket"]["cam_ket"]["bai_bao_uy_tin"] = bool( + uy.get(ku_full) or uy.get(ku_short) + ) + tt = ii.get("4. Tuân thủ pháp luật sở hữu trí tuệ") + if not isinstance(tt, dict): + tt = ii.get("Tuân thủ pháp luật sở hữu trí tuệ") + if isinstance(tt, dict): + kt_full = ( + "Tôi cam kết rằng việc sử dụng bài báo đăng ký xét công nhận sáng kiến tại ĐHYD sẽ không gây tranh chấp về: quyền tác giả/quyền liên quan, quyền sở hữu công nghiệp, tiết lộ bí mật kinh doanh, vi phạm bảo mật dữ liệu của bất kỳ bên thứ ba nào. Tôi chịu trách nhiệm trước pháp luật về tính trung thực, hợp pháp của hồ sơ" + ) + kt_short = "Cam kết việc sử dụng bài báo sẽ không gây tranh chấp về quyền tác giả, sở hữu trí tuệ, bí mật kinh doanh, bảo mật dữ liệu" + out["ban_cam_ket"]["cam_ket"]["tuan_thu_phap_luat"] = bool( + tt.get(kt_full) or tt.get(kt_short) + ) + out["ban_cam_ket"]["nguoi_cam_ket"] = str( + bck.get("Người cam kết (Ký tên, ghi rõ họ tên)") or "" + ) + + bx = official.get("BẢN XÁC NHẬN TÀI LIỆU THAM KHẢO (2.2.2)") + if isinstance(bx, dict): + bnk = bx.get("Ngày ký") if isinstance(bx.get("Ngày ký"), dict) else {} + out["reference_material_honesty"]["ngay_ky"]["ngay"] = str(bnk.get("Ngày") or "") + out["reference_material_honesty"]["ngay_ky"]["thang"] = str(bnk.get("Tháng") or "") + out["reference_material_honesty"]["ngay_ky"]["nam"] = str(bnk.get("Năm") or "") + i1 = bx.get("I. THÔNG TIN ĐĂNG KÝ") + if isinstance(i1, dict): + out["reference_material_honesty"]["tac_gia_dang_ky"] = str(i1.get("Tác giả đăng ký sáng kiến") or "") + out["reference_material_honesty"]["cccd"] = str(i1.get("CCCD/Hộ chiếu số") or "") + out["reference_material_honesty"]["don_vi"] = str(i1.get("Đơn vị") or "") + out["reference_material_honesty"]["ten_tai_lieu"] = str( + i1.get("Tên tài liệu tham khảo (theo Quyết định xuất bản)") or "" + ) + out["reference_material_honesty"]["nam_xet"] = str(i1.get("Năm xét công nhận sáng kiến") or "") + ii = bx.get("II. XÁC NHẬN VÀ CAM KẾT (☑ vào ô tương ứng)") + if isinstance(ii, dict): + _RM_ONE_FULL = ( + "Tôi cam đoan các thông tin kê khai và minh chứng đính kèm đối với tài liệu tham khảo là trung thực, đúng sự thật và phù hợp với Quyết định xuất bản trong giai đoạn quy định (15/4/2025–15/4/2026)." + ) + _RM_TWO_FULL = ( + "Tôi hoàn toàn chịu trách nhiệm trước pháp luật và trước nhà trường về tính hợp pháp của tài liệu và nội dung đăng ký." + ) + _RM_THREE_FULL = "Tôi đồng ý bổ sung hoặc chỉnh sửa hồ sơ khi được yêu cầu." + s1 = ii.get("1. Trung thực thông tin và minh chứng") + if isinstance(s1, dict): + out["reference_material_honesty"]["cam_ket"]["thong_tin_trung_thuc"] = bool(s1.get(_RM_ONE_FULL)) + s2 = ii.get("2. Trách nhiệm pháp luật") + if isinstance(s2, dict): + out["reference_material_honesty"]["cam_ket"]["trach_nhiem_phap_luat"] = bool(s2.get(_RM_TWO_FULL)) + s3 = ii.get("3. Bổ sung hồ sơ khi được yêu cầu") + if isinstance(s3, dict): + out["reference_material_honesty"]["cam_ket"]["bo_sung_khi_yeu_cau"] = bool(s3.get(_RM_THREE_FULL)) + out["reference_material_honesty"]["nguoi_cam_ket"] = str( + bx.get("Người cam kết (Ký tên, ghi rõ họ tên)") or "" + ) + + dj = official.get("BẢN XÁC NHẬN BÀI BÁO TRONG NƯỚC (2.1.2)") + if isinstance(dj, dict): + dnk = dj.get("Ngày ký") if isinstance(dj.get("Ngày ký"), dict) else {} + out["research_domestic_honesty"]["ngay_ky"]["ngay"] = str(dnk.get("Ngày") or "") + out["research_domestic_honesty"]["ngay_ky"]["thang"] = str(dnk.get("Tháng") or "") + out["research_domestic_honesty"]["ngay_ky"]["nam"] = str(dnk.get("Năm") or "") + _dj_sub = ( + "Tiêu đề phụ (Áp dụng đối với cá nhân đăng ký minh chứng là bài báo tạp chí trong nước nhóm 2.1.2)" + ) + out["research_domestic_honesty"]["tieu_de_phu"] = str(dj.get(_dj_sub) or "") + i1d = dj.get("I. THÔNG TIN ĐĂNG KÝ") + if isinstance(i1d, dict): + out["research_domestic_honesty"]["tac_gia_dang_ky"] = str(i1d.get("Tác giả đăng ký sáng kiến") or "") + out["research_domestic_honesty"]["cccd"] = str(i1d.get("CCCD/Hộ chiếu số") or "") + out["research_domestic_honesty"]["don_vi"] = str(i1d.get("Đơn vị") or "") + out["research_domestic_honesty"]["ten_bai_bao"] = str( + i1d.get("Tên bài báo (tạp chí trong nước, giai đoạn xuất bản quy định)") or "" + ) + out["research_domestic_honesty"]["nam_xet"] = str(i1d.get("Năm xét công nhận sáng kiến") or "") + iid = dj.get("II. XÁC NHẬN VÀ CAM KẾT (☑ vào ô tương ứng)") + if isinstance(iid, dict): + _DJ_ONE_FULL = ( + "Tôi cam đoan các thông tin kê khai và minh chứng đính kèm đối với bài báo trên tạp chí trong nước là trung thực, đúng sự thật và phù hợp với thời điểm xuất bản trong giai đoạn quy định (15/4/2025–15/4/2026)." + ) + _DJ_TWO_FULL = ( + "Tôi hoàn toàn chịu trách nhiệm trước pháp luật và trước nhà trường về tính hợp pháp của bài báo và nội dung đăng ký." + ) + _DJ_THREE_FULL = "Tôi đồng ý bổ sung hoặc chỉnh sửa hồ sơ khi được yêu cầu." + d1 = iid.get("1. Trung thực thông tin và minh chứng") + if isinstance(d1, dict): + out["research_domestic_honesty"]["cam_ket"]["thong_tin_trung_thuc"] = bool(d1.get(_DJ_ONE_FULL)) + d2 = iid.get("2. Trách nhiệm pháp luật") + if isinstance(d2, dict): + out["research_domestic_honesty"]["cam_ket"]["trach_nhiem_phap_luat"] = bool(d2.get(_DJ_TWO_FULL)) + d3 = iid.get("3. Bổ sung hồ sơ khi được yêu cầu") + if isinstance(d3, dict): + out["research_domestic_honesty"]["cam_ket"]["bo_sung_khi_yeu_cau"] = bool(d3.get(_DJ_THREE_FULL)) + out["research_domestic_honesty"]["nguoi_cam_ket"] = str( + dj.get("Người cam kết (Ký tên, ghi rõ họ tên)") or "" + ) + + return out + diff --git a/be0/src/be01/template_sang_kien.docx b/be0/src/be01/template_sang_kien.docx new file mode 100644 index 0000000000000000000000000000000000000000..f975a0bcf607051c563988e5c3ea1fa2e62cf505 GIT binary patch literal 43209 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 z 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

dVzpjpdg?j)5^|qP>-OzK> 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 H>(~DQa-9|O literal 0 HcmV?d00001 diff --git a/be0/src/chat_assistant.py b/be0/src/chat_assistant.py new file mode 100644 index 0000000..48e5212 --- /dev/null +++ b/be0/src/chat_assistant.py @@ -0,0 +1,344 @@ +""" +Chat Assistant Module +Implements a conversational AI assistant using Ollama for policy and compliance questions. +""" + +import ollama +from typing import List, Dict, Optional, Any +from pydantic import BaseModel +from fastapi import HTTPException +from src.utils import initialize_a_logger + + +class ChatMessage(BaseModel): + """Represents a single chat message.""" + role: str # "user" or "assistant" + content: str + + +class ChatRequest(BaseModel): + """Request model for chat messages.""" + message: str + conversation_history: Optional[List[ChatMessage]] = None + context: Optional[str] = None # Additional context about policies/documents + + +class ChatResponse(BaseModel): + """Response model for chat messages.""" + message: str + model: str + tokens_used: Optional[int] = None + + +class ChatAssistant: + """ + Chat Assistant for answering policy and compliance questions. + Uses Ollama to provide intelligent responses about IT governance, compliance, and policies. + """ + + def __init__(self, model_name: str = "qwen2.5:3b", config: Optional[Dict] = None): + """ + Initialize the Chat Assistant. + + Args: + model_name: Name of the Ollama model to use + config: Optional configuration dictionary + """ + self.model_name = model_name + self.config = config or {} + self.logger = initialize_a_logger('./logs/ChatAssistant.log') + self.logger.info(f"ChatAssistant initialized with model: {model_name}") + + # Check Ollama connectivity on initialization + self._check_ollama_connection() + + # System prompt for the assistant + self.system_prompt = """You are a helpful compliance and policy assistant. Your role is to: +1. Answer questions about IT governance, compliance policies, and regulatory requirements +2. Provide guidance on ISO 27001, NIST, GDPR, and other compliance frameworks +3. Help users understand workflow processes and requirements +4. Verify content against compliance standards +5. Be accurate, helpful, and concise in your responses + +Always provide clear, actionable advice. If you're unsure about something, say so rather than guessing. +When discussing compliance requirements, cite specific standards or frameworks when possible.""" + + def _check_ollama_connection(self): + """Check if Ollama is accessible and model is available.""" + try: + models = ollama.list() + model_names = [m.get("name", "") for m in models.get("models", [])] + self.logger.info(f"Ollama connected. Available models: {model_names}") + + # Check if our model is available + if self.model_name not in model_names: + self.logger.warning( + f"Model '{self.model_name}' not found in available models. " + f"Available models: {model_names}. " + f"Trying to use it anyway - it may need to be pulled." + ) + except Exception as e: + self.logger.error(f"Failed to connect to Ollama: {e}") + self.logger.warning( + "Ollama connection check failed. " + "The service may not be available. " + "Chat functionality may not work until Ollama is running." + ) + + def _build_messages( + self, + user_message: str, + conversation_history: Optional[List[ChatMessage]] = None, + context: Optional[str] = None + ) -> List[Dict[str, str]]: + """ + Build the message list for Ollama API. + + Args: + user_message: The current user message + conversation_history: Previous messages in the conversation + context: Additional context to include + + Returns: + List of message dictionaries for Ollama + """ + messages = [] + + # Add system prompt + messages.append({ + "role": "system", + "content": self.system_prompt + }) + + # Add context if provided + if context: + messages.append({ + "role": "system", + "content": f"Additional context: {context}" + }) + + # Add conversation history + if conversation_history: + for msg in conversation_history[-10:]: # Keep last 10 messages for context + messages.append({ + "role": msg.role, + "content": msg.content + }) + + # Add current user message + messages.append({ + "role": "user", + "content": user_message + }) + + return messages + + async def chat(self, request: ChatRequest) -> ChatResponse: + """ + Process a chat message and return a response. + + Args: + request: Chat request with message and optional history + + Returns: + Chat response with assistant's message + + Raises: + HTTPException: If the chat request fails + """ + try: + self.logger.info(f"Processing chat message: {request.message[:100] if request.message else 'Empty message'}...") + + # Validate request + if not request.message or not request.message.strip(): + raise ValueError("Message cannot be empty") + + # Build messages for Ollama + messages = self._build_messages( + user_message=request.message, + conversation_history=request.conversation_history, + context=request.context + ) + + self.logger.debug(f"Sending {len(messages)} messages to Ollama model: {self.model_name}") + + # Call Ollama API + try: + response = ollama.chat( + model=self.model_name, + messages=messages, + options={ + "temperature": 0.7, # Slightly creative for conversational responses + "top_p": 0.9, + } + ) + except ConnectionError as e: + self.logger.error(f"Ollama connection error: {e}", exc_info=True) + raise HTTPException( + status_code=503, + detail="Cannot connect to Ollama service. Please ensure Ollama is running and accessible." + ) + except Exception as ollama_error: + error_str = str(ollama_error).lower() + self.logger.error(f"Ollama API error: {ollama_error}", exc_info=True) + + # Check if it's a connection error + if "connection" in error_str or "refused" in error_str or "connect" in error_str: + raise HTTPException( + status_code=503, + detail="Ollama service is not available. Please ensure Ollama is running on localhost:11434." + ) + # Check if model is not found + if "not found" in error_str or ("model" in error_str and "not" in error_str): + raise HTTPException( + status_code=404, + detail=f"Model '{self.model_name}' not found. Please ensure the model is available. Try: ollama pull {self.model_name}" + ) + # Generic Ollama error + raise HTTPException( + status_code=500, + detail=f"Ollama error: {str(ollama_error)}" + ) + + # Extract response content + assistant_message = response.get("message", {}).get("content", "") + + if not assistant_message: + self.logger.warning("Empty response from Ollama") + assistant_message = "I apologize, but I couldn't generate a response. Please try again." + + self.logger.info(f"Generated response: {assistant_message[:100]}...") + + return ChatResponse( + message=assistant_message, + model=self.model_name, + tokens_used=response.get("eval_count", 0) + ) + + except HTTPException: + # Re-raise HTTP exceptions as-is + raise + except Exception as e: + error_message = str(e) + self.logger.error(f"Error in chat: {error_message}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to generate chat response: {error_message}" + ) + + async def verify_content( + self, + field_name: str, + content: str, + verification_criteria: Optional[str] = None + ) -> ChatResponse: + """ + Verify content against compliance requirements. + + Args: + field_name: Name of the field being verified + content: Content to verify + verification_criteria: Optional specific criteria to check against + + Returns: + Chat response with verification feedback + """ + try: + self.logger.info(f"Verifying content for field: {field_name}") + + # Build verification prompt + verification_prompt = f"""Please review and verify the following content from the field "{field_name}". + +Content to verify: +"{content}" + +Please provide: +1. Whether the content meets compliance requirements +2. Any suggestions for improvement +3. Any potential issues or concerns +4. Specific recommendations + +""" + + if verification_criteria: + verification_prompt += f"\nSpecific criteria to check:\n{verification_criteria}\n" + + verification_prompt += "\nProvide a detailed, helpful verification response." + + # Create chat request + chat_request = ChatRequest( + message=verification_prompt, + context=f"Content verification for field: {field_name}" + ) + + return await self.chat(chat_request) + + except HTTPException: + raise + except Exception as e: + error_message = str(e) + self.logger.error(f"Error verifying content: {error_message}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to verify content: {error_message}" + ) + + async def answer_policy_question( + self, + question: str, + policy_context: Optional[str] = None + ) -> ChatResponse: + """ + Answer a question about policies or compliance. + + Args: + question: The user's question + policy_context: Optional context about specific policies + + Returns: + Chat response with the answer + """ + try: + self.logger.info(f"Answering policy question: {question[:100]}...") + + # Enhance question with policy context + enhanced_question = question + if policy_context: + enhanced_question = f"Context: {policy_context}\n\nQuestion: {question}" + + chat_request = ChatRequest( + message=enhanced_question, + context="Policy and compliance question answering" + ) + + return await self.chat(chat_request) + + except HTTPException: + raise + except Exception as e: + error_message = str(e) + self.logger.error(f"Error answering policy question: {error_message}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to answer question: {error_message}" + ) + + +# Global instance +_chat_assistant_instance: Optional[ChatAssistant] = None + + +def get_chat_assistant(model_name: str = "qwen2.5:3b") -> ChatAssistant: + """ + Get or create the global ChatAssistant instance. + + Args: + model_name: Name of the Ollama model to use + + Returns: + ChatAssistant instance + """ + global _chat_assistant_instance + if _chat_assistant_instance is None: + _chat_assistant_instance = ChatAssistant(model_name=model_name) + return _chat_assistant_instance diff --git a/be0/src/compliance_verifier.py b/be0/src/compliance_verifier.py new file mode 100644 index 0000000..0fef8b7 --- /dev/null +++ b/be0/src/compliance_verifier.py @@ -0,0 +1,142 @@ + +import ollama + +from pathlib import Path +import uuid +import json +import asyncio +from enum import Enum +from fastapi import HTTPException +from pydantic import BaseModel, Field, validator +from typing import List, Dict, TypedDict, Literal, Any +import numpy as np +np.random.seed(42) +from src.utils import initialize_a_logger +from src.structure_analysis import StructureAnalyzer + + +class PromptRequest(BaseModel): + prompt: str + +class ComplianceRequest(BaseModel): + external_requirements: List[str] + internal_requirements: List[str] + + +class Compliance_Verifier(object): + def __init__(self, config=None): + self.config = config + self.logger = initialize_a_logger('./logs/ComplianceVerifier.log') + self.logger.debug(f"Compliance start") + self.structure_analyzer = StructureAnalyzer() + + async def generate_text(self, req: PromptRequest): + """ + Sends a prompt to the Qwen 2.5 3B model running on the local Ollama server. + """ + model_name = "qwen2.5:3b" + + try: + response = ollama.chat( + model=model_name, + messages=[{'role': 'user', 'content': req.prompt}], + options={"temperature": 0.0}, + ) + + content = response.get("message", {}).get("content", "Error: No content found in response.") + + return {"oss_json": content} + except Exception as e: + # Raising an HTTPException will return the error to the user via FastAPI + error_message = str(e) + self.logger.error(f"Error generating text: {error_message}") + raise HTTPException(status_code=500, detail=error_message) + + async def vectorize_requirement(self, req: PromptRequest): + self.logger.debug(f"embed req : {req }") + try: + response = ollama.embeddings( + model="embeddinggemma:300m", + prompt=req + ) + + embedding = response["embedding"] + self.logger.debug(f"embedding : {embedding }") + + return { + "embedding_preview": embedding, + "total_dimensions": len(embedding), + "model": "embeddinggemma:300m" + } + + except Exception as e: + self.logger.debug(f"embedding : {embedding }") + return {"error": str(e)} + + async def structural_similarity(self, data: ComplianceRequest) -> Dict[str, Any]: + req1 = " ".join(data.external_requirements) + req2 = " ".join(data.internal_requirements) + self.logger.debug(f"req1 : {req1 }") + self.logger.debug(f"req2 : {req2}") + try: + keywords_req1 = self.structure_analyzer.extract_keywords(req1) + keywords_req2 = self.structure_analyzer.extract_keywords(req2) + + self.logger.debug(f"keywords_req1 : {keywords_req1 }") + self.logger.debug(f"keywords_req2 : {keywords_req2}") + + common = list(set(keywords_req1) & set(keywords_req2)) + self.logger.debug(f"common : {common}") + + return { + "keywords_req1": keywords_req1, + "keywords_req2": keywords_req2, + "structure_match": common + } + except Exception as e: + self.logger.info(f"Failed to log structure_match: {e}") + return { + "keywords_req1": [], + "keywords_req2": [], + "structure_match": -1 + } + + async def semantic_similarity(self, data: ComplianceRequest) -> Dict[str, Any]: + req1 = " ".join(data.external_requirements) + req2 = " ".join(data.internal_requirements) + self.logger.debug(f"req1 : {req1 }") + self.logger.debug(f"req2 : {req2}") + try: + + result1 = await self.vectorize_requirement(req1) + result2 = await self.vectorize_requirement(req2) + self.logger.debug(f"result1 : {result1 }") + self.logger.debug(f"result2 : {result2 }") + if "error" in result1 or "error" in result2: + return { + "error": "Failed to generate embeddings", + "result1": result1, + "result2": result2 + } + + emb1 = np.array(result1.get('embedding_preview', [])) + emb2 = np.array(result2.get('embedding_preview', [])) + self.logger.debug(f"emb1: {emb1.shape }") + self.logger.debug(f"emb2: {emb2.shape }") + similarity = None + if len(emb1) > 0 and len(emb2) > 0: + similarity = np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2)) +0.000000001 + self.logger.debug(f"CustomMCP: {similarity}") + self.logger.info(f"similarity_score {similarity}") + return { + "requirement1": req1, + "requirement2": req2, + "similarity_score": float(similarity) if similarity is not None else None + } + except Exception as e: + self.logger.info(f"Failed to log similarity_score: {e}") + return { + "requirement1": req1, + "requirement2": req2, + "similarity_score": -1 + } \ No newline at end of file diff --git a/be0/src/domain/__init__.py b/be0/src/domain/__init__.py new file mode 100644 index 0000000..1895df7 --- /dev/null +++ b/be0/src/domain/__init__.py @@ -0,0 +1,5 @@ +"""Domain layer — pure business model + rules, organized by bounded context. + +No imports of FastAPI, SQLAlchemy, aioboto3, jwt, argon2, or ``os.getenv``. The +domain expresses *what* is true; adapters in ``infrastructure`` provide *how*. +""" diff --git a/be0/src/domain/identity/__init__.py b/be0/src/domain/identity/__init__.py new file mode 100644 index 0000000..51d29ee --- /dev/null +++ b/be0/src/domain/identity/__init__.py @@ -0,0 +1,6 @@ +"""Identity bounded context — Users, roles, credentials, authentication rules. + +Reference slice for the Clean Architecture refactor. The rules here are extracted +verbatim (behavior-preserving) from ``src/auth_api.py`` so the live monolith and the +layered version agree until each endpoint is cut over. +""" diff --git a/be0/src/domain/identity/entities.py b/be0/src/domain/identity/entities.py new file mode 100644 index 0000000..9cba7b1 --- /dev/null +++ b/be0/src/domain/identity/entities.py @@ -0,0 +1,41 @@ +"""The User aggregate — identity, credentials, roles, verification state.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field + +from src.shared_kernel.entity import AggregateRoot + + +@dataclass(eq=False) +class User(AggregateRoot): + """User aggregate root. + + Mutable entity (``eq=False`` so identity-based equality from ``AggregateRoot`` + is kept). Holds the persisted ``password_hash``; verifying a plaintext password + is delegated to a ``PasswordHasher`` port in the application layer (the domain + never imports argon2). + """ + + id: uuid.UUID + email: str + full_name: str + password_hash: str + email_verified: bool + is_active: bool + credential_version: int + roles: tuple[str, ...] = () + phone: str | None = None + unit_id: uuid.UUID | None = field(default=None) + + def can_authenticate(self) -> bool: + """Account must be active to authenticate at all.""" + return self.is_active + + def requires_email_verification(self) -> bool: + return not self.email_verified + + def bump_credential_version(self) -> None: + """Invalidate all previously issued JWTs (password change/reset).""" + self.credential_version = int(self.credential_version or 0) + 1 diff --git a/be0/src/domain/identity/errors.py b/be0/src/domain/identity/errors.py new file mode 100644 index 0000000..4cdb831 --- /dev/null +++ b/be0/src/domain/identity/errors.py @@ -0,0 +1,25 @@ +"""Identity-context domain errors (subclasses carry the exact Vietnamese messages).""" + +from __future__ import annotations + +from src.shared_kernel.errors import ( + AuthenticationError, + AuthorizationError, + ValidationError, +) + + +class InvalidInstitutionalEmail(ValidationError): + """Email is not a valid @ump.edu.vn / @umc.edu.vn address.""" + + +class WeakPassword(ValidationError): + """Password failed the strength policy.""" + + +class InvalidCredentials(AuthenticationError): + """Email/password did not match an active account.""" + + +class EmailNotVerified(AuthorizationError): + """Account exists but the email has not been verified yet.""" diff --git a/be0/src/domain/identity/repository.py b/be0/src/domain/identity/repository.py new file mode 100644 index 0000000..7d193ef --- /dev/null +++ b/be0/src/domain/identity/repository.py @@ -0,0 +1,28 @@ +"""UserRepository PORT — the domain's contract; implemented in ``infrastructure``. + +The application layer depends on this Protocol, never on SQLAlchemy. The concrete +``SqlAlchemyUserRepository`` lives in ``infrastructure/identity`` and maps the ORM +``User`` row to the domain ``User`` aggregate. +""" + +from __future__ import annotations + +import uuid +from typing import Protocol, runtime_checkable + +from src.domain.identity.entities import User + + +@runtime_checkable +class UserRepository(Protocol): + async def get_by_email(self, email: str) -> User | None: + """Return the active user for ``email`` (already normalized) or None.""" + ... + + async def get_by_id(self, user_id: uuid.UUID) -> User | None: + """Return the active user by id or None.""" + ... + + async def roles_after_reconcile(self, user: User) -> list[str]: + """Apply the policy-admin reconcile, then return the user's sorted roles.""" + ... diff --git a/be0/src/domain/identity/services.py b/be0/src/domain/identity/services.py new file mode 100644 index 0000000..9e9e2c0 --- /dev/null +++ b/be0/src/domain/identity/services.py @@ -0,0 +1,94 @@ +"""Pure domain services for Identity — role policy + access-token claims. + +These are decisions, not side effects: they take plain data and return plain data +or an action enum. Persisting the decision (DB writes) and signing the token live in +infrastructure. Mirrors ``auth_api._policy_admin_emails``, ``_reconcile_policy_admin`` +and ``_issue_token``. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta +from enum import Enum, auto +from typing import Any + +# Default policy admins when AUTH_ADMIN_EMAILS is unset +# (must stay in sync with migration 007 cleanup list + auth_api._DEFAULT_POLICY_ADMIN_EMAILS). +DEFAULT_POLICY_ADMIN_EMAILS: frozenset[str] = frozenset( + { + "thaontt@ump.edu.vn", + "nltanh@ump.edu.vn", + "ldbaochau@ump.edu.vn", + "htchuong@ump.edu.vn", + } +) + + +def policy_admin_emails(auth_admin_emails_env: str | None) -> frozenset[str]: + """Emails that receive ``admin`` from institutional policy. + + If ``AUTH_ADMIN_EMAILS`` is set, ONLY that comma-separated list applies + (lowercased). If unset, the built-in UMP allow-list applies. Pure — the env + value is passed in by the caller (composition layer), not read here. + """ + raw = (auth_admin_emails_env or "").strip() + if raw: + return frozenset(part.strip().lower() for part in raw.split(",") if part.strip()) + return DEFAULT_POLICY_ADMIN_EMAILS + + +class AdminReconcileAction(Enum): + """What to do with a user's ``admin`` role row given email policy.""" + + none = auto() + add_admin = auto() # policy admin, no row → INSERT (admin_from_email_policy=True) + mark_policy = auto() # policy admin, row exists → ensure admin_from_email_policy=True + remove_admin = auto() # not policy admin, policy-granted row exists → DELETE + + +def reconcile_admin_action( + email: str, + policy_admins: frozenset[str], + has_admin_row: bool, + admin_from_policy: bool, +) -> AdminReconcileAction: + """Pure decision mirroring ``auth_api._reconcile_policy_admin``. + + Manual admin rows (``admin_from_email_policy=False``) are preserved when the + email is not allow-listed. + """ + email_norm = email.strip().lower() + if email_norm in policy_admins: + return ( + AdminReconcileAction.mark_policy + if has_admin_row + else AdminReconcileAction.add_admin + ) + if has_admin_row and admin_from_policy: + return AdminReconcileAction.remove_admin + return AdminReconcileAction.none + + +def build_access_token_claims( + user_id: uuid.UUID, + email: str, + roles: list[str], + credential_version: int, + now: datetime, + expire_hours: int, +) -> dict[str, Any]: + """Build HS256 JWT claims (mirror of ``auth_api._issue_token``); signing is infra. + + A session-scoped token carries exactly one identity + the active roles + ``cv`` + (credential version) so a password change invalidates it. + """ + exp = now + timedelta(hours=int(expire_hours)) + return { + "sub": str(user_id), + "email": email, + "roles": roles, + "cv": int(credential_version), + "iat": int(now.timestamp()), + "exp": int(exp.timestamp()), + } diff --git a/be0/src/domain/identity/value_objects.py b/be0/src/domain/identity/value_objects.py new file mode 100644 index 0000000..c177337 --- /dev/null +++ b/be0/src/domain/identity/value_objects.py @@ -0,0 +1,74 @@ +"""Pure value objects + policy for the Identity context. + +Behavior mirrors ``src/auth_api.py`` exactly (same regex, same Vietnamese messages, +same password rules) so the layered slice and the live monolith stay in lock-step. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum + +from src.domain.identity.errors import InvalidInstitutionalEmail, WeakPassword +from src.shared_kernel.value_object import ValueObject + +# Authoritative *domain* allow-list for UMP/UMC faculty email +# (mirrors auth_api.INSTITUTIONAL_EMAIL_RE). +_INSTITUTIONAL_EMAIL_RE = re.compile( + r"^[a-zA-Z0-9._%+-]+@(ump|umc)\.edu\.vn\Z", re.IGNORECASE +) + +_INVALID_EMAIL_MSG = ( + "Email phải là địa chỉ UMP hoặc UMC hợp lệ " + "(dạng ten@ump.edu.vn hoặc ten@umc.edu.vn)." +) + +MAX_PASSWORD_INPUT_CHARS = 512 + + +class Role(str, Enum): + """The three canonical system roles (FE labels map to these 1:1).""" + + admin = "admin" # Quản trị viên + editor = "editor" # Hội đồng (council) + viewer = "viewer" # Người nộp đơn (applicant) + + +@dataclass(frozen=True) +class InstitutionalEmail(ValueObject): + """A normalized, validated UMP/UMC institutional email.""" + + value: str + + @classmethod + def parse(cls, raw: str) -> "InstitutionalEmail": + normalized = (raw or "").strip().lower() + if not _INSTITUTIONAL_EMAIL_RE.match(normalized): + raise InvalidInstitutionalEmail(_INVALID_EMAIL_MSG) + return cls(normalized) + + def __str__(self) -> str: # pragma: no cover - trivial + return self.value + + +def assert_password_policy(password: str) -> None: + """Raise :class:`WeakPassword` if ``password`` violates the policy. + + Exact mirror of ``auth_api._assert_password_policy`` — messages preserved so + API responses are identical pre/post cut-over. + """ + if len(password) < 6: + raise WeakPassword("Mật khẩu tối thiểu 6 ký tự.") + if len(password) > MAX_PASSWORD_INPUT_CHARS: + raise WeakPassword("Mật khẩu quá dài.") + if not re.search(r"[a-z]", password): + raise WeakPassword("Mật khẩu phải có ít nhất một chữ cái thường.") + if not re.search(r"[A-Z]", password): + raise WeakPassword("Mật khẩu phải có ít nhất một chữ cái hoa.") + if not re.search(r"\d", password): + raise WeakPassword("Mật khẩu phải có ít nhất một chữ số.") + if not re.search(r"[^A-Za-z0-9]", password): + raise WeakPassword( + "Mật khẩu phải có ít nhất một ký tự đặc biệt (không chỉ chữ và số)." + ) diff --git a/be0/src/imagehub_routes.py b/be0/src/imagehub_routes.py new file mode 100644 index 0000000..000719c --- /dev/null +++ b/be0/src/imagehub_routes.py @@ -0,0 +1,1981 @@ +"""ImageHub — content-addressed imaging dataset versioning (milestone 1 walking skeleton). + +A user (investigator/PI) creates a dataset, uploads imaging files, and snapshots versions. +Files are stored as content-addressed, globally deduped blobs in MinIO (one blob per distinct +sha256). The current working file set lives in ``imagehub_dataset_files``; a version freezes a +manifest snapshot. Admin sees every dataset (the clinical data repository); a non-admin sees +only their own (their research data). Every mutation writes an append-only audit row. + +Mounted under ``/api/v1`` in main.py → routes live at ``/api/v1/datasets/*``. +""" +from __future__ import annotations + +import json +import re +import unicodedata +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from fastapi import APIRouter, Body, File, Form, Header, HTTPException, Query, UploadFile +from pydantic import BaseModel, Field +from sqlalchemy import func, or_, select +from sqlalchemy.exc import IntegrityError + +from src.auth_jwt import decode_access_token_user_id, decode_bearer_token +from src.imagehub_segmentation import MaskUpload, SegmentationError, SegmentationService +from src.imagehub_task_pipeline import ( + StageInfo, + TaskPipelineError, + compute_finalize, + compute_review, + initial_transition, + validate_set_reference, +) +from src.initiative_db.engine import get_session, is_postgres_enabled +from src.initiative_db.models import ( + ImagehubBlob, + ImagehubDataset, + ImagehubDatasetAudit, + ImagehubDatasetFile, + ImagehubDatasetMember, + ImagehubDatasetStage, + ImagehubTask, + ImagehubTaskReviewEvent, + ImagehubVersion, + ResearchProject, + User, +) +from src.minio.storage import StorageError, storage + +router = APIRouter(prefix="/datasets", tags=["imagehub"]) + +_VISIBILITIES = ("private", "internal", "public") +_ROLE_ADMIN = "Quản trị viên" +_ROLE_OWNER = "Chủ sở hữu" + + +# --------------------------------------------------------------------------- # +# Auth (mirrors research_routes / the extracted admin routers) +# --------------------------------------------------------------------------- # +def _jwt_roles(authorization: str | None) -> list[str]: + p = decode_bearer_token(authorization) + if not p: + return [] + r = p.get("roles") + return [str(x) for x in r] if isinstance(r, list) else [] + + +def _require_authed_uid(authorization: str | None) -> uuid.UUID: + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.") + return uid + + +def _is_admin(authorization: str | None) -> bool: + return "admin" in _jwt_roles(authorization) + + +def _require_admin_uid(authorization: str | None) -> uuid.UUID: + uid = _require_authed_uid(authorization) + if not _is_admin(authorization): + raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.") + return uid + + +def _require_db() -> None: + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa sẵn sàng.") + + +def _role_label(authorization: str | None) -> str: + return _ROLE_ADMIN if _is_admin(authorization) else _ROLE_OWNER + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +def _slugify(name: str) -> str: + base = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode("ascii") + base = re.sub(r"[^a-zA-Z0-9]+", "-", base).strip("-").lower() + return base[:60] or "dataset" + + +def _safe_logical_path(name: Optional[str]) -> str: + """Flatten an uploaded filename to a safe logical path (basename; diacritics preserved).""" + raw = (name or "").strip().replace("\\", "/") + base = raw.rsplit("/", 1)[-1] + base = re.sub(r"\s+", "_", base) + base = "".join(ch for ch in base if unicodedata.category(ch)[0] != "C") + return base[:200] or "file" + + +def _safe_folder_path(name: Optional[str]) -> str: + """The sanitized relative directory of an uploaded path (folders kept, basename dropped). + + Mirrors _safe_logical_path but preserves slashes so a dataset can hold a real folder tree + (e.g. the nnU-Net imagesTr/labelsTr layout). Rejects traversal, leading slashes and control + characters. Returns '' when the upload carries no directory component. + """ + raw = (name or "").strip().replace("\\", "/") + head = raw.rsplit("/", 1)[0] if "/" in raw else "" + parts: list[str] = [] + for seg in head.split("/"): + seg = re.sub(r"\s+", "_", seg.strip()) + seg = "".join(ch for ch in seg if unicodedata.category(ch)[0] != "C") + if seg in ("", ".", ".."): + continue + parts.append(seg) + return "/".join(parts)[:400] + + +def _split_name_ext(name: str) -> tuple[str, str]: + """Split a filename into (stem, ext), treating .nii.gz / .tar.gz as one extension.""" + low = name.lower() + for double in (".nii.gz", ".tar.gz"): + if low.endswith(double): + return name[: -len(double)], name[-len(double):] + dot = name.rfind(".") + return (name, "") if dot <= 0 else (name[:dot], name[dot:]) + + +def _case_number(stem: str) -> int | None: + """The trailing integer of a case stem (after stripping any _NNNN channel tag), else None. + e.g. "1"->1, "100"->100, "POLYP25_00001"->1, "10_0000"->10.""" + base = re.sub(r"_\d{4}$", "", stem) + m = re.search(r"(\d+)$", base) + return int(m.group(1)) if m else None + + +def _normalized_name(logical_path: str, prefix: str, is_label: bool) -> str | None: + """Target name: image -> {prefix}_{NNNNN}_0000{ext}, label -> {prefix}_{NNNNN}{ext}, where + NNNNN is the file's case number 5-digit zero-padded (so an image and its label share a case + identifier, e.g. POLYP25_00001_0000.png + POLYP25_00001.png). Returns None when no case number + can be derived or the name is already correct (idempotent).""" + stem, ext = _split_name_ext(logical_path) + num = _case_number(stem) + if num is None: + return None + case_id = f"{prefix}_{num:05d}" + new = f"{case_id}{ext}" if is_label else f"{case_id}_0000{ext}" + return new if new != logical_path else None + + +def _coerce_tags(v: Any) -> list[str]: + if isinstance(v, list): + return [str(x).strip() for x in v if str(x).strip()] + return [] + + +def _sniff_imaging_meta(filename: Optional[str], data: bytes, media_type: str) -> dict[str, Any]: + """Best-effort, synchronous imaging metadata. Never raises — returns {} on any failure. + + Heavy extraction (full tag dump, thumbnails, conversion) is deferred to the worker tier. + """ + name = (filename or "").lower() + # DICOM: "DICM" magic at byte 128, or a .dcm extension. + if (len(data) > 132 and data[128:132] == b"DICM") or name.endswith(".dcm"): + try: + import io as _io + + import pydicom + + ds = pydicom.dcmread(_io.BytesIO(data), stop_before_pixels=True, force=True) + out: dict[str, Any] = {"format": "dicom"} + for tag, key in ( + ("Modality", "modality"), + ("Rows", "rows"), + ("Columns", "columns"), + ("BodyPartExamined", "bodyPart"), + ("StudyInstanceUID", "studyUid"), + ("SeriesInstanceUID", "seriesUid"), + ): + val = getattr(ds, tag, None) + if val is not None: + out[key] = int(val) if key in ("rows", "columns") else str(val) + return out + except Exception: + return {} + # NIfTI: .nii / .nii.gz — load via a temp file (nibabel needs a path for gz). + if name.endswith(".nii") or name.endswith(".nii.gz"): + import os as _os + import tempfile as _tempfile + + suffix = ".nii.gz" if name.endswith(".nii.gz") else ".nii" + tmp_path = "" + try: + import nibabel as nib + + with _tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(data) + tmp_path = tmp.name + img = nib.load(tmp_path) + zooms = [float(z) for z in img.header.get_zooms()] + return {"format": "nifti", "shape": [int(s) for s in img.shape], "voxelSize": zooms} + except Exception: + return {} + finally: + if tmp_path: + try: + _os.unlink(tmp_path) + except OSError: + pass + return {} + + +async def _actor_name(session, uid: uuid.UUID) -> str: + u = await session.get(User, uid) + if u is None: + return "" + return (u.full_name or u.email or "").strip() + + +async def _write_audit( + session, + dataset_id: uuid.UUID, + actor_uid: Optional[uuid.UUID], + actor_name: str, + role_label: str, + action: str, + subject: str = "", + detail: str = "", +) -> None: + session.add( + ImagehubDatasetAudit( + dataset_id=dataset_id, + actor_user_id=actor_uid, + actor_name=actor_name or "", + role_label=role_label or "", + action=action, + subject=subject or "", + detail=detail or "", + ) + ) + + +async def _load_dataset(session, dataset_id: str, uid: uuid.UUID, is_admin: bool) -> ImagehubDataset: + """Fetch a dataset enforcing owner-or-admin access (404 hides others' rows).""" + try: + did = uuid.UUID(dataset_id) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Không tìm thấy bộ dữ liệu.") + row = ( + await session.execute(select(ImagehubDataset).where(ImagehubDataset.id == did)) + ).scalar_one_or_none() + if row is None or (not is_admin and row.owner_user_id != uid): + raise HTTPException(status_code=404, detail="Không tìm thấy bộ dữ liệu.") + return row + + +# --------------------------------------------------------------------------- # +# Membership-aware access (multi-labeler). `_load_dataset` above stays owner-or-platform-admin and +# guards the management ops (create/delete/settings, upload, stage CRUD, member CRUD, generate, +# assign-to-others). The helpers below additionally admit dataset MEMBERS, for the read + task-work +# surface a labeler needs. +# --------------------------------------------------------------------------- # +_PROJECT_ADMIN_ROLES = ("owner", "admin", "project_admin") + + +async def _member_role( + session, dataset: ImagehubDataset, uid: uuid.UUID, is_admin: bool +) -> Optional[str]: + """The caller's effective role on a dataset: 'admin' (platform), 'owner', 'project_admin' or + 'member' (from the membership table), else None (no access).""" + if is_admin: + return "admin" + if dataset.owner_user_id == uid: + return "owner" + return ( + await session.execute( + select(ImagehubDatasetMember.role).where( + ImagehubDatasetMember.dataset_id == dataset.id, + ImagehubDatasetMember.user_id == uid, + ) + ) + ).scalar_one_or_none() + + +async def _load_dataset_any( + session, dataset_id: str, uid: uuid.UUID, is_admin: bool +) -> tuple[ImagehubDataset, str]: + """Fetch a dataset enforcing owner-OR-member-OR-admin access (404 hides others). Returns + (dataset, role) so callers can gate by role.""" + try: + did = uuid.UUID(dataset_id) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Không tìm thấy bộ dữ liệu.") + row = ( + await session.execute(select(ImagehubDataset).where(ImagehubDataset.id == did)) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Không tìm thấy bộ dữ liệu.") + role = await _member_role(session, row, uid, is_admin) + if role is None: + raise HTTPException(status_code=404, detail="Không tìm thấy bộ dữ liệu.") + return row, role + + +async def _load_dataset_read(session, dataset_id: str, uid: uuid.UUID, is_admin: bool) -> ImagehubDataset: + """Owner/member/admin read access — the dataset only (drops the role).""" + ds, _role = await _load_dataset_any(session, dataset_id, uid, is_admin) + return ds + + +async def _load_dataset_admin( + session, dataset_id: str, uid: uuid.UUID, is_admin: bool +) -> tuple[ImagehubDataset, str]: + """Project-management access: owner, platform-admin, or a project_admin member (403 for plain + members, 404 for non-members). Dataset-structural / destructive ops still use `_load_dataset`.""" + ds, role = await _load_dataset_any(session, dataset_id, uid, is_admin) + if role not in _PROJECT_ADMIN_ROLES: + raise HTTPException(status_code=403, detail="Chỉ quản trị dự án mới thực hiện được.") + return ds, role + + +def _can_work_task(role: str, task_assignee: Optional[uuid.UUID], uid: uuid.UUID) -> bool: + """A project admin/owner may work any task; a plain member only the tasks assigned to them.""" + if role in _PROJECT_ADMIN_ROLES: + return True + return task_assignee is not None and task_assignee == uid + + +async def _counts(session, dataset_ids: list[uuid.UUID]) -> tuple[dict, dict]: + """Grouped file + version counts for a set of datasets (avoids N+1 in the list view).""" + if not dataset_ids: + return {}, {} + file_rows = ( + await session.execute( + select(ImagehubDatasetFile.dataset_id, func.count()) + .where(ImagehubDatasetFile.dataset_id.in_(dataset_ids)) + .group_by(ImagehubDatasetFile.dataset_id) + ) + ).all() + ver_rows = ( + await session.execute( + select(ImagehubVersion.dataset_id, func.count()) + .where(ImagehubVersion.dataset_id.in_(dataset_ids)) + .group_by(ImagehubVersion.dataset_id) + ) + ).all() + return {r[0]: int(r[1]) for r in file_rows}, {r[0]: int(r[1]) for r in ver_rows} + + +# --------------------------------------------------------------------------- # +# Schemas +# --------------------------------------------------------------------------- # +class DatasetOut(BaseModel): + id: str + ownerUserId: str + ownerEmail: Optional[str] = None + name: str = "" + slug: str = "" + description: str = "" + visibility: str = "private" + modalityTags: list[str] = Field(default_factory=list) + labelMap: dict[str, str] = Field(default_factory=dict) + defaultBranch: str = "main" + researchProjectId: Optional[str] = None + fileCount: int = 0 + versionCount: int = 0 + createdAt: Optional[datetime] = None + updatedAt: Optional[datetime] = None + + +class DatasetCreateIn(BaseModel): + name: str = Field(default="", max_length=300) + description: str = Field(default="", max_length=4000) + visibility: str = "private" + modalityTags: list[str] = Field(default_factory=list) + researchProjectId: Optional[str] = None + + +class DatasetUpdateIn(BaseModel): + name: Optional[str] = Field(default=None, max_length=300) + description: Optional[str] = Field(default=None, max_length=4000) + visibility: Optional[str] = None + modalityTags: Optional[list[str]] = None + labelMap: Optional[dict[str, str]] = None + + +class VersionCreateIn(BaseModel): + message: Optional[str] = Field(default=None, max_length=2000) + + +class FileOut(BaseModel): + id: str + logicalPath: str + folderPath: str = "" + sha256: str + size: int + mediaType: str + imagingMeta: dict[str, Any] = Field(default_factory=dict) + fileKind: str = "image" + parentFileId: Optional[str] = None + organLabel: str = "" + uploadedAt: Optional[datetime] = None + downloadUrl: Optional[str] = None + + +class NormalizeIn(BaseModel): + prefix: str # lesion+year code, e.g. "POLYP25" + + +class NormalizeResultOut(BaseModel): + ok: bool + renamed: int + skipped: int + files: list[dict[str, str]] # {id, oldPath, newPath, folderPath} + + +class VersionOut(BaseModel): + id: str + seq: int + message: str = "" + fileCount: int = 0 + manifest: list[dict[str, Any]] = Field(default_factory=list) + createdAt: Optional[datetime] = None + + +class AuditOut(BaseModel): + id: int + occurredAt: Optional[datetime] = None + actorName: str = "" + roleLabel: str = "" + action: str + subject: str = "" + detail: str = "" + + +def _coerce_label_map(value: Any) -> dict[str, str]: + """Sanitize a per-dataset value->name label map: positive-int string keys, trimmed non-empty + names (<=100 chars), bounded to 512 entries. Returns {} for anything malformed so the viewer + falls back to the built-in TotalSegmentator names.""" + if not isinstance(value, dict): + return {} + out: dict[str, str] = {} + for k, v in value.items(): + ks = str(k) + if not (ks.isascii() and ks.isdigit()): # plain positive-int keys only ("1".."N") + continue + iv = int(ks) + if iv <= 0 or not isinstance(v, str): + continue + name = v.strip() + if not name: + continue + out[str(iv)] = name[:100] + if len(out) >= 512: + break + return out + + +def _ds_to_out( + row: ImagehubDataset, file_count: int = 0, version_count: int = 0, owner_email: Optional[str] = None +) -> DatasetOut: + return DatasetOut( + id=str(row.id), + ownerUserId=str(row.owner_user_id), + ownerEmail=owner_email, + name=row.name or "", + slug=row.slug or "", + description=row.description or "", + visibility=row.visibility or "private", + modalityTags=_coerce_tags(row.modality_tags), + labelMap=_coerce_label_map(row.label_map), + defaultBranch=row.default_branch or "main", + researchProjectId=str(row.research_project_id) if row.research_project_id else None, + fileCount=file_count, + versionCount=version_count, + createdAt=row.created_at, + updatedAt=row.updated_at, + ) + + +def _version_to_out(row: ImagehubVersion) -> VersionOut: + manifest = row.manifest if isinstance(row.manifest, list) else [] + return VersionOut( + id=str(row.id), + seq=row.seq, + message=row.message or "", + fileCount=len(manifest), + manifest=manifest, + createdAt=row.created_at, + ) + + +# --------------------------------------------------------------------------- # +# Endpoints — datasets +# --------------------------------------------------------------------------- # +@router.post("", response_model=DatasetOut) +async def create_dataset( + payload: Optional[DatasetCreateIn] = Body(None), + authorization: Optional[str] = Header(None), +) -> DatasetOut: + """Authed: create a dataset owned by the current user.""" + _require_db() + uid = _require_authed_uid(authorization) + p = payload or DatasetCreateIn() + name = (p.name or "").strip() + if not name: + raise HTTPException(status_code=422, detail="Cần nhập tên bộ dữ liệu.") + visibility = p.visibility if p.visibility in _VISIBILITIES else "private" + project_uuid: Optional[uuid.UUID] = None + if p.researchProjectId: + try: + project_uuid = uuid.UUID(p.researchProjectId) + except (ValueError, TypeError): + raise HTTPException(status_code=422, detail="Đề tài không hợp lệ.") + async with get_session() as session: + # If linking to a research project ("workspace"), it must exist and be owned by the + # caller (or the caller is a platform admin). Read BEFORE add to avoid an autoflush. + if project_uuid is not None: + owner = ( + await session.execute( + select(ResearchProject.owner_user_id).where(ResearchProject.id == project_uuid) + ) + ).scalar_one_or_none() + if owner is None: + raise HTTPException(status_code=422, detail="Đề tài không tồn tại.") + if owner != uid and not _is_admin(authorization): + raise HTTPException(status_code=403, detail="Bạn không sở hữu đề tài này.") + row = ImagehubDataset( + id=uuid.uuid4(), + owner_user_id=uid, + name=name, + slug=_slugify(name), + description=(p.description or "").strip(), + visibility=visibility, + modality_tags=_coerce_tags(p.modalityTags), + research_project_id=project_uuid, + ) + session.add(row) + await session.flush() + await _write_audit( + session, row.id, uid, await _actor_name(session, uid), _ROLE_OWNER, + "Tạo bộ dữ liệu", name, + ) + await session.commit() + await session.refresh(row) + return _ds_to_out(row) + + +@router.get("", response_model=list[DatasetOut]) +async def list_datasets( + scope: str = "mine", + authorization: Optional[str] = Header(None), + projectId: Optional[str] = None, +) -> list[DatasetOut]: + """List datasets. Non-admin always sees only their own; admin may pass ?scope=all (clinical repo). + ?projectId= further restricts to datasets linked to that research project ("workspace").""" + _require_db() + uid = _require_authed_uid(authorization) + is_admin = _is_admin(authorization) + async with get_session() as session: + stmt = ( + select(ImagehubDataset, User.email) + .join(User, User.id == ImagehubDataset.owner_user_id) + .order_by(ImagehubDataset.created_at.desc()) + ) + if scope != "all" or not is_admin: + member_ds = select(ImagehubDatasetMember.dataset_id).where( + ImagehubDatasetMember.user_id == uid + ) + stmt = stmt.where( + or_(ImagehubDataset.owner_user_id == uid, ImagehubDataset.id.in_(member_ds)) + ) + if projectId: + try: + stmt = stmt.where(ImagehubDataset.research_project_id == uuid.UUID(projectId)) + except (ValueError, TypeError): + raise HTTPException(status_code=422, detail="Đề tài không hợp lệ.") + rows = (await session.execute(stmt)).all() + datasets = [r[0] for r in rows] + emails = {r[0].id: r[1] for r in rows} + files, versions = await _counts(session, [d.id for d in datasets]) + return [ + _ds_to_out(d, files.get(d.id, 0), versions.get(d.id, 0), emails.get(d.id)) + for d in datasets + ] + + +@router.get("/{dataset_id}", response_model=DatasetOut) +async def get_dataset(dataset_id: str, authorization: Optional[str] = Header(None)) -> DatasetOut: + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + row = await _load_dataset_read(session, dataset_id, uid, _is_admin(authorization)) + files, versions = await _counts(session, [row.id]) + owner = await session.get(User, row.owner_user_id) + return _ds_to_out(row, files.get(row.id, 0), versions.get(row.id, 0), owner.email if owner else None) + + +@router.put("/{dataset_id}", response_model=DatasetOut) +async def update_dataset( + dataset_id: str, + payload: DatasetUpdateIn = Body(...), + authorization: Optional[str] = Header(None), +) -> DatasetOut: + """Owner or admin: update dataset metadata.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + row = await _load_dataset(session, dataset_id, uid, _is_admin(authorization)) + if payload.name is not None: + new_name = payload.name.strip() + if not new_name: + raise HTTPException(status_code=422, detail="Tên bộ dữ liệu không được để trống.") + row.name = new_name + row.slug = _slugify(new_name) + if payload.description is not None: + row.description = payload.description.strip() + if payload.visibility is not None: + if payload.visibility not in _VISIBILITIES: + raise HTTPException(status_code=422, detail="Mức hiển thị không hợp lệ.") + row.visibility = payload.visibility + if payload.modalityTags is not None: + row.modality_tags = _coerce_tags(payload.modalityTags) + if payload.labelMap is not None: + row.label_map = _coerce_label_map(payload.labelMap) + row.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, row.id, uid, await _actor_name(session, uid), _role_label(authorization), + "Cập nhật bộ dữ liệu", row.name, + ) + await session.commit() + await session.refresh(row) + files, versions = await _counts(session, [row.id]) + return _ds_to_out(row, files.get(row.id, 0), versions.get(row.id, 0)) + + +@router.delete("/{dataset_id}") +async def delete_dataset(dataset_id: str, authorization: Optional[str] = Header(None)) -> dict[str, Any]: + """Owner or admin: delete a dataset. Files/versions/audit cascade; blobs are left (no GC in v1).""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + row = await _load_dataset(session, dataset_id, uid, _is_admin(authorization)) + await session.delete(row) + await session.commit() + return {"ok": True} + + +# --------------------------------------------------------------------------- # +# Endpoints — files (content-addressed upload + browse) +# --------------------------------------------------------------------------- # +@router.post("/{dataset_id}/files") +async def upload_files( + dataset_id: str, + files: list[UploadFile] = File(...), + authorization: Optional[str] = Header(None), + paths: Optional[str] = Form(None), +) -> dict[str, Any]: + """Owner or admin: upload one or more files. Each is content-addressed + deduped; a file at an + existing (folder, logical path) is replaced. ``paths`` is an optional JSON array of relative + upload paths (index-aligned with ``files``) so a directory structure is preserved as folder_path. + Best-effort imaging metadata is sniffed synchronously.""" + _require_db() + uid = _require_authed_uid(authorization) + is_admin = _is_admin(authorization) + results: list[dict[str, Any]] = [] + async with get_session() as session: + ds = await _load_dataset(session, dataset_id, uid, is_admin) + rel_paths: list[str] = [] + if paths: + try: + parsed_paths = json.loads(paths) + if isinstance(parsed_paths, list): + rel_paths = [str(p) for p in parsed_paths] + except (ValueError, TypeError): + rel_paths = [] + for i, uf in enumerate(files): + data = await uf.read() + if not data: + continue + media_type = uf.content_type or "application/octet-stream" + try: + blob = await storage.put_blob(data, media_type) + except ValueError as exc: + raise HTTPException(status_code=413, detail=str(exc)) from exc + except StorageError as exc: + raise HTTPException(status_code=502, detail=f"Lưu trữ thất bại: {exc}") from exc + if await session.get(ImagehubBlob, blob["sha256"]) is None: + session.add( + ImagehubBlob( + sha256=blob["sha256"], + size_bytes=blob["size"], + media_type=media_type, + storage_bucket=blob["bucket"], + storage_key=blob["key"], + ) + ) + await session.flush() + logical_path = _safe_logical_path(uf.filename) + rel = rel_paths[i] if i < len(rel_paths) else (uf.filename or "") + folder_path = _safe_folder_path(rel) + meta = _sniff_imaging_meta(uf.filename, data, media_type) + file_row = ( + await session.execute( + select(ImagehubDatasetFile).where( + ImagehubDatasetFile.dataset_id == ds.id, + ImagehubDatasetFile.folder_path == folder_path, + ImagehubDatasetFile.logical_path == logical_path, + ) + ) + ).scalar_one_or_none() + if file_row is None: + file_row = ImagehubDatasetFile( + id=uuid.uuid4(), dataset_id=ds.id, folder_path=folder_path, logical_path=logical_path + ) + session.add(file_row) + file_row.blob_sha256 = blob["sha256"] + file_row.size_bytes = blob["size"] + file_row.media_type = media_type + file_row.imaging_meta = meta + file_row.uploaded_by = uid + file_row.updated_at = datetime.now(tz=timezone.utc) + results.append( + { + "path": f"{folder_path}/{logical_path}" if folder_path else logical_path, + "sha256": blob["sha256"], + "size": blob["size"], + "deduped": blob["deduped"], + "imagingMeta": meta, + } + ) + ds.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, await _actor_name(session, uid), _role_label(authorization), + "Tải tệp lên", f"{len(results)} tệp", + ) + await session.commit() + return {"ok": True, "files": results} + + +@router.get("/{dataset_id}/files", response_model=list[FileOut]) +async def list_files(dataset_id: str, authorization: Optional[str] = Header(None)) -> list[FileOut]: + """Owner, member or admin: the dataset's current working files, each with a presigned URL.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds = await _load_dataset_read(session, dataset_id, uid, _is_admin(authorization)) + rows = ( + await session.execute( + select(ImagehubDatasetFile, ImagehubBlob) + .join(ImagehubBlob, ImagehubBlob.sha256 == ImagehubDatasetFile.blob_sha256) + .where(ImagehubDatasetFile.dataset_id == ds.id) + .order_by(ImagehubDatasetFile.folder_path, ImagehubDatasetFile.logical_path) + ) + ).all() + out: list[FileOut] = [] + for f, b in rows: + try: + url = await storage.get_download_url( + b.storage_bucket, b.storage_key, filename=f.logical_path, inline=False + ) + except Exception: + url = None + out.append( + FileOut( + id=str(f.id), + logicalPath=f.logical_path, + folderPath=f.folder_path or "", + sha256=f.blob_sha256, + size=f.size_bytes, + mediaType=f.media_type, + imagingMeta=f.imaging_meta if isinstance(f.imaging_meta, dict) else {}, + fileKind=f.file_kind or "image", + parentFileId=str(f.parent_file_id) if f.parent_file_id else None, + organLabel=f.organ_label or "", + uploadedAt=f.created_at, + downloadUrl=url, + ) + ) + return out + + +@router.get("/{dataset_id}/files/{file_id}/download") +async def download_file( + dataset_id: str, file_id: str, authorization: Optional[str] = Header(None) +) -> dict[str, Any]: + """Owner, member or admin: a fresh presigned download URL for a single file.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds = await _load_dataset_read(session, dataset_id, uid, _is_admin(authorization)) + try: + fid = uuid.UUID(file_id) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Không tìm thấy tệp.") + row = ( + await session.execute( + select(ImagehubDatasetFile, ImagehubBlob) + .join(ImagehubBlob, ImagehubBlob.sha256 == ImagehubDatasetFile.blob_sha256) + .where(ImagehubDatasetFile.id == fid, ImagehubDatasetFile.dataset_id == ds.id) + ) + ).first() + if row is None: + raise HTTPException(status_code=404, detail="Không tìm thấy tệp.") + f, b = row + url = await storage.get_download_url( + b.storage_bucket, b.storage_key, filename=f.logical_path, inline=False + ) + return {"url": url, "logicalPath": f.logical_path} + + +@router.post("/{dataset_id}/files/{parent_file_id}/segmentations") +async def upload_segmentations( + dataset_id: str, + parent_file_id: str, + masks: list[UploadFile] = File(...), + organs: Optional[list[str]] = Form(None), + authorization: Optional[str] = Header(None), +) -> dict[str, Any]: + """Owner or admin: upload organ-mask files and link them to a parent image file. + + Each mask is content-addressed + stored like any file, but recorded with + ``file_kind='segmentation'`` and ``parent_file_id`` pointing at the image it + segments. ``organs[i]`` is the organ label for ``masks[i]`` (parallel arrays; + falls back to the filename). Domain rules live in ``SegmentationService`` — the + handler is just transport (read multipart → service → audit → commit).""" + _require_db() + uid = _require_authed_uid(authorization) + is_admin = _is_admin(authorization) + organ_labels = organs or [] + async with get_session() as session: + ds = await _load_dataset(session, dataset_id, uid, is_admin) + items: list[MaskUpload] = [] + for i, uf in enumerate(masks): + items.append( + MaskUpload( + filename=uf.filename or "mask.nii.gz", + data=await uf.read(), + media_type=uf.content_type or "application/octet-stream", + organ_label=organ_labels[i] if i < len(organ_labels) else "", + ) + ) + service = SegmentationService( + session, + put_blob=storage.put_blob, + sniff_meta=_sniff_imaging_meta, + safe_name=_safe_logical_path, + ) + try: + linked = await service.link_masks(ds, parent_file_id, items, uid) + except SegmentationError as exc: + raise HTTPException(status_code=exc.status, detail=str(exc)) from exc + except ValueError as exc: # storage.put_blob size cap → 413 + raise HTTPException(status_code=413, detail=str(exc)) from exc + except StorageError as exc: + raise HTTPException(status_code=502, detail=f"Lưu trữ thất bại: {exc}") from exc + ds.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, await _actor_name(session, uid), _role_label(authorization), + "Tải mặt nạ phân vùng", f"{len(linked)} mặt nạ", + ) + await session.commit() + results = [ + { + "id": str(r.id), + "logicalPath": r.logical_path, + "organLabel": r.organ_label, + "parentFileId": str(r.parent_file_id) if r.parent_file_id else None, + } + for r in linked + ] + return {"ok": True, "masks": results} + + +@router.post("/{dataset_id}/files/normalize-channels", response_model=NormalizeResultOut) +async def normalize_channels( + dataset_id: str, payload: NormalizeIn, authorization: Optional[str] = Header(None) +) -> NormalizeResultOut: + """Owner or admin: rename files to the {prefix}_{caseID}_0000.{ext} convention — images under + imagesTr/imagesTs get the _0000 channel suffix, labels under labelsTr/labelsTs stay channel-free, + and caseID is the file's number 5-digit zero-padded so an image and its label share a case id + (e.g. POLYP25_00001_0000.png + POLYP25_00001.png). Idempotent; logical rename only — no blob move.""" + _require_db() + uid = _require_authed_uid(authorization) + is_admin = _is_admin(authorization) + prefix = re.sub(r"[^A-Za-z0-9]", "", payload.prefix or "").upper() + if not prefix: + raise HTTPException(status_code=400, detail="Thiếu mã tổn thương (tiền tố).") + async with get_session() as session: + ds = await _load_dataset(session, dataset_id, uid, is_admin) + rows = ( + await session.execute( + select(ImagehubDatasetFile).where(ImagehubDatasetFile.dataset_id == ds.id) + ) + ).scalars().all() + existing = {(r.folder_path, r.logical_path) for r in rows} + # Resolve the actor + role BEFORE mutating any logical_path: a read after a mutation would + # autoflush the pending UPDATE and raise a unique-violation outside the commit try/except. + actor = await _actor_name(session, uid) + role = _role_label(authorization) + results: list[dict[str, str]] = [] + plan: list[tuple[ImagehubDatasetFile, str]] = [] + skipped = 0 + for r in rows: + seg = (r.folder_path or "").strip("/").split("/")[-1] + if r.file_kind == "segmentation": + continue + if seg in ("imagesTr", "imagesTs"): + is_label = False + elif seg in ("labelsTr", "labelsTs"): + is_label = True + else: + continue # root / other folders are left alone + new = _normalized_name(r.logical_path, prefix, is_label) + if new is None: + skipped += 1 + continue + if (r.folder_path, new) in existing: + skipped += 1 + continue + results.append( + { + "id": str(r.id), + "oldPath": r.logical_path, + "newPath": new, + "folderPath": r.folder_path or "", + } + ) + plan.append((r, new)) + renamed = len(plan) + if renamed: + for r, new in plan: + r.logical_path = new + r.updated_at = datetime.now(tz=timezone.utc) + ds.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, actor, role, + action="Chuẩn hoá tên tệp", subject=f"{renamed} tệp", + detail=f"Đổi tên sang định dạng {prefix}__0000", + ) + try: + await session.commit() + except IntegrityError: + await session.rollback() + raise HTTPException(status_code=409, detail="Tên tệp bị trùng sau khi chuẩn hoá.") + return NormalizeResultOut(ok=True, renamed=renamed, skipped=skipped, files=results) + + +# --------------------------------------------------------------------------- # +# Endpoints — versions (the snapshot spine) + audit +# --------------------------------------------------------------------------- # +@router.post("/{dataset_id}/versions", response_model=VersionOut) +async def create_version( + dataset_id: str, + payload: Optional[VersionCreateIn] = Body(None), + authorization: Optional[str] = Header(None), +) -> VersionOut: + """Owner or admin: freeze the current working files into a new version snapshot.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds = await _load_dataset(session, dataset_id, uid, _is_admin(authorization)) + files = ( + await session.execute( + select(ImagehubDatasetFile) + .where(ImagehubDatasetFile.dataset_id == ds.id) + .order_by(ImagehubDatasetFile.logical_path) + ) + ).scalars().all() + if not files: + raise HTTPException(status_code=422, detail="Bộ dữ liệu chưa có tệp nào để tạo phiên bản.") + manifest = [ + {"logicalPath": f.logical_path, "blobSha256": f.blob_sha256, + "size": f.size_bytes, "mediaType": f.media_type} + for f in files + ] + max_seq = ( + await session.execute( + select(func.coalesce(func.max(ImagehubVersion.seq), 0)).where( + ImagehubVersion.dataset_id == ds.id + ) + ) + ).scalar_one() + parent = ( + await session.execute( + select(ImagehubVersion.id) + .where(ImagehubVersion.dataset_id == ds.id) + .order_by(ImagehubVersion.seq.desc()) + .limit(1) + ) + ).scalar_one_or_none() + msg = ((payload.message if payload else None) or "").strip() + row = ImagehubVersion( + id=uuid.uuid4(), + dataset_id=ds.id, + seq=int(max_seq) + 1, + message=msg, + manifest=manifest, + parent_version_id=parent, + author_user_id=uid, + ) + session.add(row) + await session.flush() + await _write_audit( + session, ds.id, uid, await _actor_name(session, uid), _role_label(authorization), + "Tạo phiên bản", f"v{row.seq}", msg, + ) + await session.commit() + await session.refresh(row) + return _version_to_out(row) + + +@router.get("/{dataset_id}/versions", response_model=list[VersionOut]) +async def list_versions(dataset_id: str, authorization: Optional[str] = Header(None)) -> list[VersionOut]: + """Owner or admin: version history, newest first.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds = await _load_dataset(session, dataset_id, uid, _is_admin(authorization)) + rows = ( + await session.execute( + select(ImagehubVersion) + .where(ImagehubVersion.dataset_id == ds.id) + .order_by(ImagehubVersion.seq.desc()) + ) + ).scalars().all() + return [_version_to_out(r) for r in rows] + + +@router.get("/{dataset_id}/audit", response_model=list[AuditOut]) +async def list_audit(dataset_id: str, authorization: Optional[str] = Header(None)) -> list[AuditOut]: + """Owner or admin: the append-only audit trail for a dataset, newest first.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds = await _load_dataset(session, dataset_id, uid, _is_admin(authorization)) + rows = ( + await session.execute( + select(ImagehubDatasetAudit) + .where(ImagehubDatasetAudit.dataset_id == ds.id) + .order_by(ImagehubDatasetAudit.occurred_at.desc(), ImagehubDatasetAudit.id.desc()) + ) + ).scalars().all() + return [ + AuditOut( + id=r.id, + occurredAt=r.occurred_at, + actorName=r.actor_name, + roleLabel=r.role_label, + action=r.action, + subject=r.subject, + detail=r.detail, + ) + for r in rows + ] + + +# --------------------------------------------------------------------------- # +# Endpoints — labeling-pipeline stages (Label -> Review_1 -> Review_2 ...) +# --------------------------------------------------------------------------- # +_STAGE_KINDS = ("label", "review") + + +class StageOut(BaseModel): + id: str + name: str = "" + kind: str = "label" + seq: int = 0 + reviewPercent: Optional[int] = None + autoAssign: bool = True + + +class StageCreateIn(BaseModel): + name: str = Field(default="", max_length=200) + kind: str = "label" + reviewPercent: Optional[int] = Field(default=None, ge=0, le=100) + autoAssign: bool = True + + +class StageUpdateIn(BaseModel): + name: Optional[str] = Field(default=None, max_length=200) + reviewPercent: Optional[int] = Field(default=None, ge=0, le=100) + autoAssign: Optional[bool] = None + seq: Optional[int] = None + + +def _stage_to_out(row: ImagehubDatasetStage) -> StageOut: + return StageOut( + id=str(row.id), + name=row.name or "", + kind=row.kind or "label", + seq=row.seq, + reviewPercent=row.review_percent, + autoAssign=bool(row.auto_assign), + ) + + +async def _load_stage(session, dataset_id: uuid.UUID, stage_id: str) -> ImagehubDatasetStage: + try: + sid = uuid.UUID(stage_id) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Không tìm thấy giai đoạn.") + stage = ( + await session.execute( + select(ImagehubDatasetStage).where( + ImagehubDatasetStage.id == sid, ImagehubDatasetStage.dataset_id == dataset_id + ) + ) + ).scalar_one_or_none() + if stage is None: + raise HTTPException(status_code=404, detail="Không tìm thấy giai đoạn.") + return stage + + +@router.get("/{dataset_id}/stages", response_model=list[StageOut]) +async def list_stages(dataset_id: str, authorization: Optional[str] = Header(None)) -> list[StageOut]: + """Owner, member or admin: the dataset's labeling-pipeline stages, in pipeline order.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds = await _load_dataset_read(session, dataset_id, uid, _is_admin(authorization)) + rows = ( + await session.execute( + select(ImagehubDatasetStage) + .where(ImagehubDatasetStage.dataset_id == ds.id) + .order_by(ImagehubDatasetStage.seq, ImagehubDatasetStage.created_at) + ) + ).scalars().all() + return [_stage_to_out(r) for r in rows] + + +@router.post("/{dataset_id}/stages", response_model=StageOut) +async def add_stage( + dataset_id: str, + payload: StageCreateIn = Body(...), + authorization: Optional[str] = Header(None), +) -> StageOut: + """Owner or admin: append a stage to the pipeline.""" + _require_db() + uid = _require_authed_uid(authorization) + if payload.kind not in _STAGE_KINDS: + raise HTTPException(status_code=422, detail="Loại giai đoạn không hợp lệ.") + name = (payload.name or "").strip() + if not name: + raise HTTPException(status_code=422, detail="Tên giai đoạn không được để trống.") + async with get_session() as session: + ds = await _load_dataset(session, dataset_id, uid, _is_admin(authorization)) + # Resolve the actor BEFORE adding the new row: a query here would otherwise autoflush the + # pending INSERT and raise the unique-violation outside the commit() try/except below. + actor = await _actor_name(session, uid) + max_seq = ( + await session.execute( + select(func.max(ImagehubDatasetStage.seq)).where(ImagehubDatasetStage.dataset_id == ds.id) + ) + ).scalar() + stage = ImagehubDatasetStage( + dataset_id=ds.id, + name=name, + kind=payload.kind, + seq=(int(max_seq) + 1) if max_seq is not None else 0, + review_percent=payload.reviewPercent if payload.kind == "review" else None, + auto_assign=payload.autoAssign, + ) + session.add(stage) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), + "Thêm giai đoạn", name, + ) + try: + await session.commit() + except IntegrityError: + await session.rollback() + raise HTTPException(status_code=409, detail="Tên giai đoạn đã tồn tại trong bộ dữ liệu.") + await session.refresh(stage) + return _stage_to_out(stage) + + +@router.patch("/{dataset_id}/stages/{stage_id}", response_model=StageOut) +async def update_stage( + dataset_id: str, + stage_id: str, + payload: StageUpdateIn = Body(...), + authorization: Optional[str] = Header(None), +) -> StageOut: + """Owner or admin: rename a stage, set its review %, toggle Automatic Task Assignment, reorder.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds = await _load_dataset(session, dataset_id, uid, _is_admin(authorization)) + stage = await _load_stage(session, ds.id, stage_id) + # Resolve the actor before mutating the row (autoflush-before-commit guard; see add_stage). + actor = await _actor_name(session, uid) + if payload.name is not None: + nm = payload.name.strip() + if not nm: + raise HTTPException(status_code=422, detail="Tên giai đoạn không được để trống.") + stage.name = nm + if payload.reviewPercent is not None: + stage.review_percent = payload.reviewPercent + if payload.autoAssign is not None: + stage.auto_assign = payload.autoAssign + if payload.seq is not None: + stage.seq = payload.seq + stage.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), + "Cập nhật giai đoạn", stage.name, + ) + try: + await session.commit() + except IntegrityError: + await session.rollback() + raise HTTPException(status_code=409, detail="Tên giai đoạn đã tồn tại trong bộ dữ liệu.") + await session.refresh(stage) + return _stage_to_out(stage) + + +@router.delete("/{dataset_id}/stages/{stage_id}") +async def delete_stage( + dataset_id: str, stage_id: str, authorization: Optional[str] = Header(None) +) -> dict[str, Any]: + """Owner or admin: remove a stage from the pipeline.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds = await _load_dataset(session, dataset_id, uid, _is_admin(authorization)) + stage = await _load_stage(session, ds.id, stage_id) + actor = await _actor_name(session, uid) + name = stage.name + await session.delete(stage) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), + "Xóa giai đoạn", name, + ) + await session.commit() + return {"ok": True} + + +# --------------------------------------------------------------------------- # +# Endpoints — task pipeline (a file flows Label -> Review_n -> Ground Truth) +# +# Single-user MVP: a Task is one row per *image* file (imagehub_tasks), created on demand from the +# dataset's files once the pipeline has at least one stage. Access reuses the dataset owner-or-admin +# gate; multi-labeler membership/assignment is a later phase. Every write follows the +# autoflush-before-commit guard (resolve reads before add/mutate; see add_stage). +# --------------------------------------------------------------------------- # +class TaskOut(BaseModel): + id: str + name: str = "" + fileId: str + fileLogicalPath: str = "" + currentStageId: Optional[str] = None + currentStageName: Optional[str] = None + pipelineState: str = "inLabel" + queueStatus: str = "assigned" + assigneeUserId: Optional[str] = None + assigneeName: Optional[str] = None + assignmentMode: str = "auto" + priority: float = 0.0 + isReferenceStandard: bool = False + createdAt: datetime + updatedAt: datetime + + +class GenerateTasksOut(BaseModel): + created: int = 0 + total: int = 0 + + +class ReviewIn(BaseModel): + decision: str = Field(..., description="accept | acceptWithCorrections | reject") + note: Optional[str] = Field(default=None, max_length=2000) + + +class ReviewStatsOut(BaseModel): + accepted: int = 0 + acceptWithCorrections: int = 0 + rejected: int = 0 + + +class PriorityIn(BaseModel): + priority: float = Field(..., ge=0, le=1) + + +class SetReferenceIn(BaseModel): + isReferenceStandard: bool = True + + +class TaskDetailOut(TaskOut): + """A single task plus its saved annotations (the AnnotationTool's working payload).""" + + annotations: list[Any] = Field(default_factory=list) + + +class SaveIn(BaseModel): + annotations: list[Any] = Field(default_factory=list) + submit: bool = False + + +def _assignee_label(full_name: Optional[str], email: Optional[str]) -> Optional[str]: + name = (full_name or email or "").strip() + return name or None + + +def _build_task_out( + task: ImagehubTask, + file_logical_path: Optional[str], + stage_name: Optional[str], + assignee_name: Optional[str], +) -> TaskOut: + return TaskOut( + id=str(task.id), + name=task.name or "", + fileId=str(task.dataset_file_id), + fileLogicalPath=file_logical_path or "", + currentStageId=str(task.current_stage_id) if task.current_stage_id else None, + currentStageName=stage_name, + pipelineState=task.pipeline_state, + queueStatus=task.queue_status, + assigneeUserId=str(task.assignee_user_id) if task.assignee_user_id else None, + assigneeName=assignee_name, + assignmentMode=task.assignment_mode, + priority=float(task.priority or 0.0), + isReferenceStandard=bool(task.is_reference_standard), + createdAt=task.created_at, + updatedAt=task.updated_at, + ) + + +async def _stage_infos(session, dataset_id: uuid.UUID) -> list[StageInfo]: + rows = ( + await session.execute( + select(ImagehubDatasetStage.id, ImagehubDatasetStage.kind, ImagehubDatasetStage.seq).where( + ImagehubDatasetStage.dataset_id == dataset_id + ) + ) + ).all() + return [StageInfo(id=str(sid), kind=kind or "label", seq=int(seq)) for sid, kind, seq in rows] + + +async def _load_task(session, dataset_id: uuid.UUID, task_id: str) -> ImagehubTask: + try: + tid = uuid.UUID(task_id) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Không tìm thấy công việc.") + task = ( + await session.execute( + select(ImagehubTask).where(ImagehubTask.id == tid, ImagehubTask.dataset_id == dataset_id) + ) + ).scalar_one_or_none() + if task is None: + raise HTTPException(status_code=404, detail="Không tìm thấy công việc.") + return task + + +async def _task_out_after(session, task: ImagehubTask) -> TaskOut: + """Build a TaskOut for one task, looking up its file path / stage name / assignee name.""" + f = await session.get(ImagehubDatasetFile, task.dataset_file_id) + file_path = f.logical_path if f else "" + stage_name: Optional[str] = None + if task.current_stage_id: + st = await session.get(ImagehubDatasetStage, task.current_stage_id) + stage_name = st.name if st else None + assignee_name: Optional[str] = None + if task.assignee_user_id: + u = await session.get(User, task.assignee_user_id) + if u is not None: + assignee_name = _assignee_label(u.full_name, u.email) + return _build_task_out(task, file_path, stage_name, assignee_name) + + +async def _task_detail_after(session, task: ImagehubTask) -> TaskDetailOut: + """A single task as TaskDetailOut (TaskOut fields + its saved annotations).""" + base = await _task_out_after(session, task) + return TaskDetailOut(**base.model_dump(), annotations=task.annotations or []) + + +@router.post("/{dataset_id}/tasks/generate", response_model=GenerateTasksOut) +async def generate_tasks( + dataset_id: str, authorization: Optional[str] = Header(None) +) -> GenerateTasksOut: + """Owner or admin: create one task per image file that doesn't have one yet. + + Requires the dataset to have at least one pipeline stage (409 otherwise); tasks start at the + first stage. Idempotent — files that already have a task are skipped. + """ + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, _role = await _load_dataset_admin(session, dataset_id, uid, _is_admin(authorization)) + # All reads BEFORE any add (autoflush-before-commit guard; see add_stage). + stages = await _stage_infos(session, ds.id) + if not stages: + raise HTTPException( + status_code=409, + detail="Hãy thêm giai đoạn quy trình trong Cài đặt trước khi tạo công việc.", + ) + init = initial_transition(stages) + actor = await _actor_name(session, uid) + files = ( + await session.execute( + select(ImagehubDatasetFile.id, ImagehubDatasetFile.logical_path).where( + ImagehubDatasetFile.dataset_id == ds.id, + ImagehubDatasetFile.file_kind == "image", + ) + ) + ).all() + existing = set( + ( + await session.execute( + select(ImagehubTask.dataset_file_id).where(ImagehubTask.dataset_id == ds.id) + ) + ).scalars().all() + ) + start_stage = uuid.UUID(init.current_stage_id) if init.current_stage_id else None + created = 0 + for fid, lpath in files: + if fid in existing: + continue + session.add( + ImagehubTask( + dataset_id=ds.id, + dataset_file_id=fid, + name=lpath or "", + current_stage_id=start_stage, + pipeline_state=init.pipeline_state, + queue_status="assigned", + ) + ) + created += 1 + if created: + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), + "Tạo công việc", f"{created} tác vụ", + ) + await session.commit() + return GenerateTasksOut(created=created, total=len(files)) + + +@router.get("/{dataset_id}/tasks", response_model=list[TaskOut]) +async def list_tasks( + dataset_id: str, + stage: Optional[str] = Query(None), + status: Optional[str] = Query(None), + state: Optional[str] = Query(None), + reference: Optional[bool] = Query(None), + mine: Optional[bool] = Query(None), + authorization: Optional[str] = Header(None), +) -> list[TaskOut]: + """Owner, member or admin: the dataset's tasks, highest priority first. Optional filters + (mine=true → only tasks assigned to the caller).""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds = await _load_dataset_read(session, dataset_id, uid, _is_admin(authorization)) + q = ( + select( + ImagehubTask, + ImagehubDatasetFile.logical_path, + ImagehubDatasetStage.name, + User.full_name, + User.email, + ) + .join(ImagehubDatasetFile, ImagehubTask.dataset_file_id == ImagehubDatasetFile.id) + .outerjoin(ImagehubDatasetStage, ImagehubTask.current_stage_id == ImagehubDatasetStage.id) + .outerjoin(User, ImagehubTask.assignee_user_id == User.id) + .where(ImagehubTask.dataset_id == ds.id) + ) + if stage: + try: + q = q.where(ImagehubTask.current_stage_id == uuid.UUID(stage)) + except (ValueError, TypeError): + raise HTTPException(status_code=422, detail="Giai đoạn không hợp lệ.") + if status: + q = q.where(ImagehubTask.queue_status == status) + if state: + q = q.where(ImagehubTask.pipeline_state == state) + if reference is not None: + q = q.where(ImagehubTask.is_reference_standard == reference) + if mine: + q = q.where(ImagehubTask.assignee_user_id == uid) + q = q.order_by(ImagehubTask.priority.desc(), ImagehubTask.created_at.asc()) + rows = (await session.execute(q)).all() + return [ + _build_task_out(t, lpath, sname, _assignee_label(fn, em)) + for (t, lpath, sname, fn, em) in rows + ] + + +class AssignIn(BaseModel): + userId: Optional[str] = None + + +@router.post("/{dataset_id}/tasks/{task_id}/assign", response_model=TaskOut) +async def assign_task( + dataset_id: str, + task_id: str, + payload: Optional[AssignIn] = Body(None), + authorization: Optional[str] = Header(None), +) -> TaskOut: + """Assign a task. No body (or your own id) = claim it yourself; a project admin / owner may pass + another member's userId to assign it to them (the target must be the owner or a member).""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, role = await _load_dataset_any(session, dataset_id, uid, _is_admin(authorization)) + task = await _load_task(session, ds.id, task_id) + target_uid = uid + if payload and payload.userId: + try: + target_uid = uuid.UUID(payload.userId) + except (ValueError, TypeError): + raise HTTPException(status_code=422, detail="Người dùng không hợp lệ.") + if target_uid != uid and role not in _PROJECT_ADMIN_ROLES: + raise HTTPException( + status_code=403, detail="Chỉ quản trị dự án mới gán việc cho người khác." + ) + actor = await _actor_name(session, uid) + # The assignee must be the dataset owner or an existing member. + if target_uid != ds.owner_user_id: + is_member = ( + await session.execute( + select(ImagehubDatasetMember.id).where( + ImagehubDatasetMember.dataset_id == ds.id, + ImagehubDatasetMember.user_id == target_uid, + ) + ) + ).scalar_one_or_none() + if is_member is None: + raise HTTPException( + status_code=422, detail="Người được gán không phải thành viên của bộ dữ liệu." + ) + task.assignee_user_id = target_uid + task.assignment_mode = "manual" + task.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), "Gán việc", task.name, + ) + await session.commit() + await session.refresh(task) + return await _task_out_after(session, task) + + +@router.post("/{dataset_id}/tasks/{task_id}/unassign", response_model=TaskOut) +async def unassign_task( + dataset_id: str, task_id: str, authorization: Optional[str] = Header(None) +) -> TaskOut: + """Owner/admin clear any task's assignee; a member may release their own task.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, role = await _load_dataset_any(session, dataset_id, uid, _is_admin(authorization)) + task = await _load_task(session, ds.id, task_id) + if not _can_work_task(role, task.assignee_user_id, uid): + raise HTTPException(status_code=403, detail="Bạn không được phân công công việc này.") + actor = await _actor_name(session, uid) + task.assignee_user_id = None + task.assignment_mode = "auto" + task.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), "Bỏ gán việc", task.name, + ) + await session.commit() + await session.refresh(task) + return await _task_out_after(session, task) + + +@router.post("/{dataset_id}/tasks/{task_id}/finalize", response_model=TaskOut) +async def finalize_task( + dataset_id: str, task_id: str, authorization: Optional[str] = Header(None) +) -> TaskOut: + """TP1: finalize a Label task — advance it to the next stage (or Ground Truth).""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, role = await _load_dataset_any(session, dataset_id, uid, _is_admin(authorization)) + task = await _load_task(session, ds.id, task_id) + if not _can_work_task(role, task.assignee_user_id, uid): + raise HTTPException(status_code=403, detail="Bạn không được phân công công việc này.") + stages = await _stage_infos(session, ds.id) + actor = await _actor_name(session, uid) + try: + t = compute_finalize( + task.pipeline_state, + str(task.current_stage_id) if task.current_stage_id else None, + stages, + ) + except TaskPipelineError as exc: + raise HTTPException(status_code=exc.status, detail=str(exc)) + task.pipeline_state = t.pipeline_state + task.current_stage_id = uuid.UUID(t.current_stage_id) if t.current_stage_id else None + task.queue_status = t.queue_status + task.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), t.action, task.name, + ) + await session.commit() + await session.refresh(task) + return await _task_out_after(session, task) + + +@router.post("/{dataset_id}/tasks/{task_id}/review", response_model=TaskOut) +async def review_task( + dataset_id: str, + task_id: str, + payload: ReviewIn = Body(...), + authorization: Optional[str] = Header(None), +) -> TaskOut: + """TP2/TP3: accept (optionally with corrections) advances; reject returns to the first stage.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, role = await _load_dataset_any(session, dataset_id, uid, _is_admin(authorization)) + task = await _load_task(session, ds.id, task_id) + if not _can_work_task(role, task.assignee_user_id, uid): + raise HTTPException(status_code=403, detail="Bạn không được phân công công việc này.") + stages = await _stage_infos(session, ds.id) + actor = await _actor_name(session, uid) + try: + t = compute_review( + task.pipeline_state, + str(task.current_stage_id) if task.current_stage_id else None, + stages, + payload.decision, + ) + except TaskPipelineError as exc: + raise HTTPException(status_code=exc.status, detail=str(exc)) + review_stage_id = task.current_stage_id # the Review stage where the decision is made + task.pipeline_state = t.pipeline_state + task.current_stage_id = uuid.UUID(t.current_stage_id) if t.current_stage_id else None + task.queue_status = t.queue_status + task.updated_at = datetime.now(tz=timezone.utc) + # Record the structured verdict (queryable history + per-reviewer counters + reject reason). + session.add( + ImagehubTaskReviewEvent( + dataset_id=ds.id, + task_id=task.id, + stage_id=review_stage_id, + reviewer_user_id=uid, + decision=payload.decision, + note=(payload.note or "").strip(), + ) + ) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), t.action, task.name, + ) + await session.commit() + await session.refresh(task) + return await _task_out_after(session, task) + + +@router.get("/{dataset_id}/review-stats", response_model=ReviewStatsOut) +async def review_stats( + dataset_id: str, + userId: Optional[str] = None, + days: int = 30, + authorization: Optional[str] = Header(None), +) -> ReviewStatsOut: + """Accept/reject/corrections tallies over the last ``days`` (the productivity panel). Optional + ``userId`` scopes to one reviewer; omitted = dataset-wide.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, _role = await _load_dataset_any(session, dataset_id, uid, _is_admin(authorization)) + cutoff = datetime.now(tz=timezone.utc) - timedelta(days=max(1, min(days, 365))) + conds = [ + ImagehubTaskReviewEvent.dataset_id == ds.id, + ImagehubTaskReviewEvent.created_at >= cutoff, + ] + if userId: + try: + conds.append(ImagehubTaskReviewEvent.reviewer_user_id == uuid.UUID(userId)) + except (ValueError, TypeError): + raise HTTPException(status_code=422, detail="Người dùng không hợp lệ.") + rows = ( + await session.execute( + select(ImagehubTaskReviewEvent.decision, func.count()) + .where(*conds) + .group_by(ImagehubTaskReviewEvent.decision) + ) + ).all() + counts = {str(d): int(c) for d, c in rows} + return ReviewStatsOut( + accepted=counts.get("accept", 0), + acceptWithCorrections=counts.get("acceptWithCorrections", 0), + rejected=counts.get("reject", 0), + ) + + +@router.post("/{dataset_id}/tasks/{task_id}/skip", response_model=TaskOut) +async def skip_task( + dataset_id: str, task_id: str, authorization: Optional[str] = Header(None) +) -> TaskOut: + """Q2: send a task to the end of the queue (still owned).""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, role = await _load_dataset_any(session, dataset_id, uid, _is_admin(authorization)) + task = await _load_task(session, ds.id, task_id) + if not _can_work_task(role, task.assignee_user_id, uid): + raise HTTPException(status_code=403, detail="Bạn không được phân công công việc này.") + # Read the current max skip-seq BEFORE mutating this row (autoflush guard). + max_seq = ( + await session.execute( + select(func.max(ImagehubTask.skipped_seq)).where(ImagehubTask.dataset_id == ds.id) + ) + ).scalar() + actor = await _actor_name(session, uid) + task.queue_status = "skipped" + task.skipped_seq = (int(max_seq) + 1) if max_seq is not None else 0 + task.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), "Bỏ qua công việc", task.name, + ) + await session.commit() + await session.refresh(task) + return await _task_out_after(session, task) + + +@router.post("/{dataset_id}/tasks/{task_id}/priority", response_model=TaskOut) +async def set_task_priority( + dataset_id: str, + task_id: str, + payload: PriorityIn = Body(...), + authorization: Optional[str] = Header(None), +) -> TaskOut: + """PR4: set a task's priority (float 0..1; higher floats to the top of queues).""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, _role = await _load_dataset_admin(session, dataset_id, uid, _is_admin(authorization)) + task = await _load_task(session, ds.id, task_id) + actor = await _actor_name(session, uid) + task.priority = float(payload.priority) + task.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), + "Đặt độ ưu tiên", f"{task.name} = {task.priority:.2f}", + ) + await session.commit() + await session.refresh(task) + return await _task_out_after(session, task) + + +@router.post("/{dataset_id}/tasks/{task_id}/reference", response_model=TaskOut) +async def set_task_reference( + dataset_id: str, + task_id: str, + payload: SetReferenceIn = Body(...), + authorization: Optional[str] = Header(None), +) -> TaskOut: + """RS1/RS2: set or unset a Ground Truth task as a project reference standard.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, _role = await _load_dataset_admin(session, dataset_id, uid, _is_admin(authorization)) + task = await _load_task(session, ds.id, task_id) + actor = await _actor_name(session, uid) + try: + validate_set_reference(task.pipeline_state, payload.isReferenceStandard) + except TaskPipelineError as exc: + raise HTTPException(status_code=exc.status, detail=str(exc)) + task.is_reference_standard = bool(payload.isReferenceStandard) + task.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), + "Đặt chuẩn tham chiếu" if task.is_reference_standard else "Bỏ chuẩn tham chiếu", + task.name, + ) + await session.commit() + await session.refresh(task) + return await _task_out_after(session, task) + + +@router.get("/{dataset_id}/tasks/{task_id}", response_model=TaskDetailOut) +async def get_task( + dataset_id: str, task_id: str, authorization: Optional[str] = Header(None) +) -> TaskDetailOut: + """Owner, member or admin: one task with its saved annotations (for the AnnotationTool).""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds = await _load_dataset_read(session, dataset_id, uid, _is_admin(authorization)) + task = await _load_task(session, ds.id, task_id) + return await _task_detail_after(session, task) + + +@router.post("/{dataset_id}/tasks/{task_id}/save", response_model=TaskDetailOut) +async def save_task( + dataset_id: str, + task_id: str, + payload: SaveIn = Body(...), + authorization: Optional[str] = Header(None), +) -> TaskDetailOut: + """Persist the labeler's annotations. Q3 save -> 'saved'; Q1 submit-draft -> 'pendingFinalization' + (a draft stays in the queue until Finalize advances the stage).""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, role = await _load_dataset_any(session, dataset_id, uid, _is_admin(authorization)) + task = await _load_task(session, ds.id, task_id) + if not _can_work_task(role, task.assignee_user_id, uid): + raise HTTPException(status_code=403, detail="Bạn không được phân công công việc này.") + if task.pipeline_state == "groundTruth": + raise HTTPException(status_code=409, detail="Tác vụ đã hoàn tất, không thể chỉnh sửa.") + actor = await _actor_name(session, uid) + task.annotations = payload.annotations + task.queue_status = "pendingFinalization" if payload.submit else "saved" + task.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), + "Nộp bản nháp" if payload.submit else "Lưu chú thích", task.name, + ) + await session.commit() + await session.refresh(task) + return await _task_detail_after(session, task) + + +# --------------------------------------------------------------------------- # +# Endpoints — dataset membership (multi-labeler). Owner + platform-admin manage; a member gets read +# access + may work tasks assigned to them (see _load_dataset_any / _can_work_task). +# --------------------------------------------------------------------------- # +_MEMBER_ROLES = ("member", "project_admin") + + +class MemberOut(BaseModel): + userId: str + email: str = "" + fullName: str = "" + role: str = "member" + createdAt: Optional[datetime] = None + + +class MemberAddIn(BaseModel): + email: Optional[str] = None + userId: Optional[str] = None + role: str = "member" + + +@router.get("/{dataset_id}/members", response_model=list[MemberOut]) +async def list_members( + dataset_id: str, authorization: Optional[str] = Header(None) +) -> list[MemberOut]: + """Owner, admin or project-admin: the dataset's members.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, _role = await _load_dataset_admin(session, dataset_id, uid, _is_admin(authorization)) + rows = ( + await session.execute( + select(ImagehubDatasetMember, User.email, User.full_name) + .join(User, User.id == ImagehubDatasetMember.user_id) + .where(ImagehubDatasetMember.dataset_id == ds.id) + .order_by(ImagehubDatasetMember.created_at) + ) + ).all() + return [ + MemberOut( + userId=str(m.user_id), + email=email or "", + fullName=full_name or "", + role=m.role, + createdAt=m.created_at, + ) + for m, email, full_name in rows + ] + + +@router.post("/{dataset_id}/members", response_model=MemberOut) +async def add_member( + dataset_id: str, payload: MemberAddIn = Body(...), authorization: Optional[str] = Header(None) +) -> MemberOut: + """Owner / admin / project-admin: add a member by email or userId so they can be assigned tasks. + Only the owner + platform-admin may grant the project_admin role (anti-escalation).""" + _require_db() + uid = _require_authed_uid(authorization) + new_role = payload.role if payload.role in _MEMBER_ROLES else "member" + async with get_session() as session: + ds, caller_role = await _load_dataset_admin(session, dataset_id, uid, _is_admin(authorization)) + if new_role == "project_admin" and caller_role not in ("owner", "admin"): + raise HTTPException( + status_code=403, detail="Chỉ chủ sở hữu mới cấp quyền quản trị dự án." + ) + # Resolve the target user + dup-check BEFORE add (autoflush-before-commit guard). + target: Optional[User] = None + if payload.userId: + try: + target = await session.get(User, uuid.UUID(payload.userId)) + except (ValueError, TypeError): + target = None + elif payload.email: + target = ( + await session.execute( + select(User).where(func.lower(User.email) == payload.email.strip().lower()) + ) + ).scalar_one_or_none() + if target is None: + raise HTTPException(status_code=404, detail="Không tìm thấy người dùng.") + if target.id == ds.owner_user_id: + raise HTTPException(status_code=409, detail="Người dùng đã là chủ sở hữu bộ dữ liệu.") + actor = await _actor_name(session, uid) + existing = ( + await session.execute( + select(ImagehubDatasetMember).where( + ImagehubDatasetMember.dataset_id == ds.id, + ImagehubDatasetMember.user_id == target.id, + ) + ) + ).scalar_one_or_none() + if existing is not None: + raise HTTPException(status_code=409, detail="Người dùng đã là thành viên.") + member = ImagehubDatasetMember(dataset_id=ds.id, user_id=target.id, role=new_role, added_by=uid) + session.add(member) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), + "Thêm thành viên", target.email or str(target.id), + ) + try: + await session.commit() + except IntegrityError: + await session.rollback() + raise HTTPException(status_code=409, detail="Người dùng đã là thành viên.") + await session.refresh(member) + return MemberOut( + userId=str(target.id), + email=target.email or "", + fullName=target.full_name or "", + role=new_role, + createdAt=member.created_at, + ) + + +@router.delete("/{dataset_id}/members/{user_id}") +async def remove_member( + dataset_id: str, user_id: str, authorization: Optional[str] = Header(None) +) -> dict[str, Any]: + """Owner / admin / project-admin: remove a member. A project-admin may not remove another + project-admin (only the owner + platform-admin can).""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + ds, caller_role = await _load_dataset_admin(session, dataset_id, uid, _is_admin(authorization)) + try: + tid = uuid.UUID(user_id) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Không tìm thấy thành viên.") + member = ( + await session.execute( + select(ImagehubDatasetMember).where( + ImagehubDatasetMember.dataset_id == ds.id, + ImagehubDatasetMember.user_id == tid, + ) + ) + ).scalar_one_or_none() + if member is None: + raise HTTPException(status_code=404, detail="Không tìm thấy thành viên.") + if member.role == "project_admin" and caller_role not in ("owner", "admin"): + raise HTTPException( + status_code=403, detail="Chỉ chủ sở hữu mới gỡ quyền quản trị dự án." + ) + actor = await _actor_name(session, uid) + await session.delete(member) + await _write_audit( + session, ds.id, uid, actor, _role_label(authorization), "Xóa thành viên", user_id, + ) + await session.commit() + return {"ok": True} diff --git a/be0/src/imagehub_segmentation.py b/be0/src/imagehub_segmentation.py new file mode 100644 index 0000000..7de8576 --- /dev/null +++ b/be0/src/imagehub_segmentation.py @@ -0,0 +1,166 @@ +"""ImageHub — organ-segmentation linking (Phase D). + +A cohesive domain/service module for linking organ-mask files to the image they +segment. The HTTP route (``imagehub_routes.upload_segmentations``) stays a thin +transport layer; the domain rules live here, in one unit-testable place: + + * a mask may only attach to an existing *image* file in the *same* dataset; + * masks are namespaced under their parent (``.seg/``) so they + never collide with — or replace — a sibling image row under the dataset's + UNIQUE (dataset_id, logical_path); + * the link is recorded explicitly (``file_kind='segmentation'`` + ``parent_file_id`` + + ``organ_label``), not inferred. + +Infrastructure (blob storage, imaging-metadata sniffing, filename safety) is +*injected*, not imported — so this module has no FastAPI/HTTP coupling, no circular +dependency on the router, and can be unit-tested with fakes. This is the project's +pragmatic take on Clean Architecture: a cohesive service over the flat routers, not +the (unwired) domain/application/infrastructure tree. +""" +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Awaitable, Callable, Optional + +from sqlalchemy import select + +from src.initiative_db.models import ImagehubBlob, ImagehubDataset, ImagehubDatasetFile + +# Injected infrastructure contracts (kept HTTP-free + testable). +PutBlob = Callable[[bytes, Optional[str]], Awaitable[dict[str, Any]]] +SniffMeta = Callable[[Optional[str], bytes, str], dict[str, Any]] +SafeName = Callable[[Optional[str]], str] + +_IMAGING_EXTS = (".nii.gz", ".nii", ".dcm", ".dicom") + + +class SegmentationError(Exception): + """A domain rule was violated; ``status`` is the HTTP code the caller should map to.""" + + def __init__(self, message: str, status: int = 422) -> None: + super().__init__(message) + self.status = status + + +@dataclass(frozen=True) +class MaskUpload: + """One organ-mask file to be linked to a parent image.""" + + filename: str + data: bytes + media_type: str + organ_label: str + + +class SegmentationService: + """Links organ-mask files to a parent image within a single dataset.""" + + def __init__( + self, session, *, put_blob: PutBlob, sniff_meta: SniffMeta, safe_name: SafeName + ) -> None: + self._session = session + self._put_blob = put_blob + self._sniff_meta = sniff_meta + self._safe_name = safe_name + + def _mask_logical_path(self, parent_logical_path: str, mask_filename: str) -> str: + """``.seg/`` — groups masks under their parent + and keeps them out of the parent's logical-path namespace.""" + stem = parent_logical_path + low = stem.lower() + for ext in _IMAGING_EXTS: + if low.endswith(ext): + stem = stem[: -len(ext)] + break + return f"{stem}.seg/{self._safe_name(mask_filename)}" + + async def _resolve_parent( + self, dataset: ImagehubDataset, parent_file_id: str + ) -> ImagehubDatasetFile: + try: + pid = uuid.UUID(parent_file_id) + except (ValueError, TypeError): + raise SegmentationError("Không tìm thấy tệp ảnh gốc.", 404) + parent = ( + await self._session.execute( + select(ImagehubDatasetFile).where( + ImagehubDatasetFile.id == pid, + ImagehubDatasetFile.dataset_id == dataset.id, + ) + ) + ).scalar_one_or_none() + if parent is None: + raise SegmentationError("Không tìm thấy tệp ảnh gốc.", 404) + if parent.file_kind != "image": + raise SegmentationError("Chỉ có thể gắn mặt nạ vào tệp ảnh gốc.", 422) + return parent + + async def _ensure_blob(self, data: bytes, media_type: str) -> dict[str, Any]: + blob = await self._put_blob(data, media_type) + if await self._session.get(ImagehubBlob, blob["sha256"]) is None: + self._session.add( + ImagehubBlob( + sha256=blob["sha256"], + size_bytes=blob["size"], + media_type=media_type, + storage_bucket=blob["bucket"], + storage_key=blob["key"], + ) + ) + await self._session.flush() + return blob + + async def link_masks( + self, + dataset: ImagehubDataset, + parent_file_id: str, + masks: list[MaskUpload], + actor_uid: uuid.UUID, + ) -> list[ImagehubDatasetFile]: + """Store + link each mask to the parent image. Returns the created/updated rows. + + Re-linking a mask whose namespaced path already exists replaces it in place + (a corrected mask for the same organ). Raises ``SegmentationError`` on a bad + parent or an empty payload; propagates the injected ``put_blob`` errors. + """ + parent = await self._resolve_parent(dataset, parent_file_id) + if not masks: + raise SegmentationError("Chưa chọn tệp mặt nạ nào.", 422) + + linked: list[ImagehubDatasetFile] = [] + for m in masks: + if not m.data: + continue + blob = await self._ensure_blob(m.data, m.media_type) + logical_path = self._mask_logical_path(parent.logical_path, m.filename) + meta = self._sniff_meta(m.filename, m.data, m.media_type) + organ = (m.organ_label or "").strip() or self._safe_name(m.filename) + row = ( + await self._session.execute( + select(ImagehubDatasetFile).where( + ImagehubDatasetFile.dataset_id == dataset.id, + ImagehubDatasetFile.logical_path == logical_path, + ) + ) + ).scalar_one_or_none() + if row is None: + row = ImagehubDatasetFile( + id=uuid.uuid4(), dataset_id=dataset.id, logical_path=logical_path + ) + self._session.add(row) + row.blob_sha256 = blob["sha256"] + row.size_bytes = blob["size"] + row.media_type = m.media_type + row.imaging_meta = meta + row.file_kind = "segmentation" + row.parent_file_id = parent.id + row.organ_label = organ + row.uploaded_by = actor_uid + row.updated_at = datetime.now(tz=timezone.utc) + linked.append(row) + + if not linked: + raise SegmentationError("Tệp mặt nạ rỗng.", 422) + return linked diff --git a/be0/src/imagehub_task_pipeline.py b/be0/src/imagehub_task_pipeline.py new file mode 100644 index 0000000..9858188 --- /dev/null +++ b/be0/src/imagehub_task_pipeline.py @@ -0,0 +1,136 @@ +"""ImageHub — task pipeline state machine (project-workflow §3/§4, single-user MVP). + +A cohesive, HTTP-free domain module: the per-task transitions that move a task through a +dataset's ordered pipeline stages (Label -> Review_1 -> Review_n -> Ground Truth). The HTTP +routes in ``imagehub_routes`` stay a thin transport layer; the rules live here so they are +unit-testable with plain data (no DB, no FastAPI), mirroring ``imagehub_segmentation``. + +Mapped from ``docs/workflows/project-workflow-spec.md``: + * TP1 finalize advances a Label task to the next stage (or Ground Truth if none follow); + * TP2 review accept (with/without corrections) advances; TP3 reject returns to the first stage; + * §9 RS1 only a Ground Truth task may be a reference standard. + +Multi-labeler assignment, issues, comments, time and evaluation are later phases; this module is +deliberately limited to the pipeline + the bits the Data Page MVP needs. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +# State vocabularies — must match the CHECK constraints in migration 021. +PIPELINE_STATES = ("inLabel", "inReview", "groundTruth", "issue") +QUEUE_STATUSES = ("assigned", "saved", "pendingFinalization", "skipped") +REVIEW_DECISIONS = ("accept", "acceptWithCorrections", "reject") + + +class TaskPipelineError(Exception): + """A pipeline rule was violated; ``status`` is the HTTP code the caller should map to.""" + + def __init__(self, message: str, status: int = 422) -> None: + super().__init__(message) + self.status = status + + +@dataclass(frozen=True) +class StageInfo: + """The minimum a transition needs to know about a pipeline stage.""" + + id: str + kind: str # 'label' | 'review' + seq: int + + +@dataclass(frozen=True) +class Transition: + """The computed result of an event: the task's new pipeline position + an audit label.""" + + pipeline_state: str + current_stage_id: Optional[str] + queue_status: str + action: str + + +def order_stages(stages: list[StageInfo]) -> list[StageInfo]: + """Pipeline order: by ``seq`` then ``id`` (stable, deterministic).""" + return sorted(stages, key=lambda s: (s.seq, s.id)) + + +def first_stage(stages: list[StageInfo]) -> StageInfo: + ordered = order_stages(stages) + if not ordered: + raise TaskPipelineError( + "Bộ dữ liệu chưa có giai đoạn nào trong quy trình.", status=409 + ) + return ordered[0] + + +def stage_after(stages: list[StageInfo], stage_id: Optional[str]) -> Optional[StageInfo]: + """The stage following ``stage_id`` in pipeline order, or None if it is the last / unknown.""" + if stage_id is None: + return None + ordered = order_stages(stages) + for i, s in enumerate(ordered): + if s.id == stage_id: + return ordered[i + 1] if i + 1 < len(ordered) else None + return None + + +def state_for_stage(stage: StageInfo) -> str: + """A review stage holds tasks ``inReview``; any other (label/pre-label) holds them ``inLabel``.""" + return "inReview" if stage.kind == "review" else "inLabel" + + +def initial_transition(stages: list[StageInfo]) -> Transition: + """Where a brand-new task starts: the first stage, ``assigned`` and unworked.""" + s0 = first_stage(stages) + return Transition(state_for_stage(s0), s0.id, "assigned", "Tạo công việc") + + +def _advance(current_stage_id: Optional[str], stages: list[StageInfo]) -> Transition: + """Move to the next stage, or to terminal Ground Truth when none follows.""" + nxt = stage_after(stages, current_stage_id) + if nxt is None: + return Transition("groundTruth", None, "assigned", "") + return Transition(state_for_stage(nxt), nxt.id, "assigned", "") + + +def compute_finalize( + pipeline_state: str, current_stage_id: Optional[str], stages: list[StageInfo] +) -> Transition: + """TP1: finalizing a Label task advances it to the next stage (or Ground Truth).""" + if pipeline_state != "inLabel": + raise TaskPipelineError( + "Chỉ có thể hoàn tất công việc đang ở giai đoạn gán nhãn.", status=409 + ) + t = _advance(current_stage_id, stages) + action = "Hoàn tất → Ground Truth" if t.pipeline_state == "groundTruth" else "Hoàn tất gán nhãn" + return Transition(t.pipeline_state, t.current_stage_id, t.queue_status, action) + + +def compute_review( + pipeline_state: str, current_stage_id: Optional[str], stages: list[StageInfo], decision: str +) -> Transition: + """TP2 accept / TP3 reject from a Review stage.""" + if pipeline_state != "inReview": + raise TaskPipelineError( + "Chỉ có thể duyệt công việc đang ở giai đoạn rà soát.", status=409 + ) + if decision not in REVIEW_DECISIONS: + raise TaskPipelineError("Quyết định duyệt không hợp lệ.", status=422) + if decision == "reject": + s0 = first_stage(stages) + return Transition("inLabel", s0.id, "assigned", "Từ chối — trả lại để chỉnh sửa") + t = _advance(current_stage_id, stages) + label = "Chấp nhận (có chỉnh sửa)" if decision == "acceptWithCorrections" else "Chấp nhận" + if t.pipeline_state == "groundTruth": + label += " → Ground Truth" + return Transition(t.pipeline_state, t.current_stage_id, t.queue_status, label) + + +def validate_set_reference(pipeline_state: str, value: bool) -> None: + """RS1: only a Ground Truth task may be flagged as a reference standard.""" + if value and pipeline_state != "groundTruth": + raise TaskPipelineError( + "Chỉ tác vụ Ground Truth mới được đặt làm chuẩn tham chiếu.", status=409 + ) diff --git a/be0/src/infrastructure/config/__pycache__/settings.cpython-313.pyc b/be0/src/infrastructure/config/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..60b8155644e0644abe2c4ffd721d362e329ca2f7 GIT binary patch literal 3093 zcmZuzNpBm;6>gGEvPDsnWr^AsN}^cbMX^0zCXOvTvI8bS76FY`vq<&0+0A*? zlr16$1_2TwbI}|IP;S2F-w@!XQ$R3>T%wx|IS6vgd)1U|IU&H;^}VH*S6{uV^S-`> zLch2Fv8tM){6m7Sh<3l{huHXDDJh_oLZ$Gt&`ub_X=+EFMRrtBccKuLSbY}Ti9xd+~W?quXgIzNf8+OWK6;wR)k0p1q!r9+0T^WE0IlCmz)6 zwq+WgafW4m4+_NE(d}wfX(P=g;)aMrW zg!ZCNQ#5S!=&_-pn}&J7%SU6*JG1|=Hq z+OA8Kl4t~mBpQ`y42C5dmuLb~a)e2VreH*(X^Cb^vxP8>?v3rmh(b7zwA9bRxa?tG zq6L_cC?nA#OiHvQ(G8fA{VhwBg=uN~LZTIzk+z$J3Q8&W^AOB_GFD!C6(B9WiW1#| zIf+&!T7!An-?~H_uprTiz5kpD^UbN3;8u>j2T#+F+!_+za7>ndX&WArwk9;AUe_I? z#+uPv`C`5(dP#A}fFtD|i)fkcH>#qy%nq6D)``EO_Y*QV;}|33)B~4$*6N_52$fjf zOzpe2w_PaMu4&k0+`hfJbzAf}%w7LlZ-6DDvMCZ>4QC*K zDQRYo+y>-0Gl6-zSL`Tf9vDuQCA{k;Gm;}h(0N;ZfuGk!>+o3hq87y zgGm&VhG{a+bo|DTa(Hv(e`0UlT3=fi1FmfwHAAOmVxH_~;+xmZS

V6Xr1zI}Z?eO(9r`wkBs^6x#|GaS!L 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 Tp2~-ik`Eyw_@Nt+SW^E#7Pf!i literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/user_notifications.cpython-311.pyc b/be0/src/initiative_db/__pycache__/user_notifications.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..79574cf92d62847d7488996c0878780e3accbb89 GIT binary patch literal 12445 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 Dict[str, Any]: + return { + "id": str(rec.id), + "decision": rec.decision, + "feedback": rec.feedback, + "rationale": rec.rationale, + "initiativeId": str(rec.initiative_id), + } + + +def _iso_utc(dt: Optional[datetime]) -> str: + if dt is None: + return "" + v = dt.astimezone(timezone.utc).replace(microsecond=0).isoformat() + return v.replace("+00:00", "Z") + + +def _row_to_api( + initiative: Initiative, + payload: dict[str, Any], + rec: ApplicationAdminResult, + application_id: str, +) -> Dict[str, Any]: + return { + "id": str(rec.id), + "applicationId": application_id, + "initiativeId": str(initiative.id), + "decision": rec.decision, + "feedback": rec.feedback or "", + "rationale": rec.rationale, + "createdAt": _iso_utc(rec.created_at), + "updatedAt": _iso_utc(rec.updated_at), + "createdBy": str(rec.created_by) if rec.created_by else None, + "updatedBy": str(rec.updated_by) if rec.updated_by else None, + "createdByFullName": None, + "updatedByFullName": None, + "applicationName": str(_as_submission_item(initiative, payload).get("name") or ""), + } + + +async def _attach_admin_user_full_names(session: AsyncSession, row: Dict[str, Any], rec: ApplicationAdminResult) -> None: + """Mutates ``row`` in place: resolves ``users.full_name`` for created/updated audit ids.""" + ids = [uid for uid in (rec.created_by, rec.updated_by) if uid is not None] + if not ids: + return + stmt = select(User.id, User.full_name).where(User.id.in_(ids)) + fetched = (await session.execute(stmt)).all() + by_id = {uid: (fn or "").strip() or None for uid, fn in fetched} + if rec.created_by is not None: + row["createdByFullName"] = by_id.get(rec.created_by) + if rec.updated_by is not None: + row["updatedByFullName"] = by_id.get(rec.updated_by) + + +async def get_admin_result_for_application( + session: AsyncSession, + application_id: str, +) -> Optional[Dict[str, Any]]: + try: + initiative, draft = await _resolve_initiative_and_latest_draft_for_application_id(session, application_id) + except LookupError: + return None + + payload = dict(draft.payload) if isinstance(draft.payload, dict) else {} + stmt = select(ApplicationAdminResult).where(ApplicationAdminResult.initiative_id == initiative.id) + rec = (await session.execute(stmt)).scalar_one_or_none() + if rec is None: + return None + + aid = application_id.strip() + row = _row_to_api(initiative, payload, rec, aid) + await _attach_admin_user_full_names(session, row, rec) + return row + + +async def create_admin_result( + session: AsyncSession, + application_id: str, + admin_user_id: uuid.UUID, + *, + decision: str, + feedback: str, + rationale: Optional[str], +) -> Dict[str, Any]: + initiative, draft = await _resolve_initiative_and_latest_draft_for_application_id(session, application_id) + + payload = dict(draft.payload) if isinstance(draft.payload, dict) else {} + existing = ( + await session.execute( + select(ApplicationAdminResult).where(ApplicationAdminResult.initiative_id == initiative.id) + ) + ).scalar_one_or_none() + if existing is not None: + raise ValueError("result_already_exists") + + d = (decision or "").strip().lower() + if d not in ("approved", "rejected"): + raise ValueError("invalid_decision") + + rec = ApplicationAdminResult( + initiative_id=initiative.id, + decision=d, + feedback=(feedback or "").strip(), + rationale=(rationale or "").strip() or None, + created_by=admin_user_id, + updated_by=admin_user_id, + ) + session.add(rec) + + initiative.status = d + initiative.updated_at = datetime.now(timezone.utc) + + await session.flush() + await session.refresh(rec) + + row = _row_to_api(initiative, payload, rec, application_id.strip()) + await _attach_admin_user_full_names(session, row, rec) + ae, ar = await resolve_actor_fields(session, admin_user_id) + await record_audit( + session, + actor_user_id=admin_user_id, + actor_email=ae, + actor_role=ar, + action=AuditAction.create, + entity_type="application_admin_result", + entity_id=application_id.strip(), + after=_audit_admin_result_snapshot(rec), + metadata={"initiativeId": str(initiative.id)}, + ) + return row + + +async def update_admin_result( + session: AsyncSession, + application_id: str, + admin_user_id: uuid.UUID, + *, + decision: str, + feedback: str, + rationale: Optional[str], +) -> Dict[str, Any]: + initiative, draft = await _resolve_initiative_and_latest_draft_for_application_id(session, application_id) + + payload = dict(draft.payload) if isinstance(draft.payload, dict) else {} + stmt = select(ApplicationAdminResult).where(ApplicationAdminResult.initiative_id == initiative.id) + rec = (await session.execute(stmt)).scalar_one_or_none() + if rec is None: + raise LookupError("result_not_found") + + before_audit = _audit_admin_result_snapshot(rec) + + d = (decision or "").strip().lower() + if d not in ("approved", "rejected"): + raise ValueError("invalid_decision") + + rec.decision = d + rec.feedback = (feedback or "").strip() + rec.rationale = (rationale or "").strip() or None + rec.updated_by = admin_user_id + rec.updated_at = datetime.now(timezone.utc) + + initiative.status = d + initiative.updated_at = datetime.now(timezone.utc) + + await session.flush() + await session.refresh(rec) + + row = _row_to_api(initiative, payload, rec, application_id.strip()) + await _attach_admin_user_full_names(session, row, rec) + ae, ar = await resolve_actor_fields(session, admin_user_id) + await record_audit( + session, + actor_user_id=admin_user_id, + actor_email=ae, + actor_role=ar, + action=AuditAction.update, + entity_type="application_admin_result", + entity_id=application_id.strip(), + before=before_audit, + after=_audit_admin_result_snapshot(rec), + metadata={"initiativeId": str(initiative.id)}, + ) + return row + + +async def upsert_admin_result( + session: AsyncSession, + application_id: str, + admin_user_id: uuid.UUID, + *, + decision: str, + feedback: str, + rationale: Optional[str], +) -> Dict[str, Any]: + """ + Create or replace admin adjudication in one transaction (idempotent PUT semantics). + Keeps ``initiatives.status`` in sync with ``decision``. + """ + initiative, draft = await _resolve_initiative_and_latest_draft_for_application_id(session, application_id) + + payload = dict(draft.payload) if isinstance(draft.payload, dict) else {} + stmt = select(ApplicationAdminResult).where(ApplicationAdminResult.initiative_id == initiative.id) + rec = (await session.execute(stmt)).scalar_one_or_none() + + d = (decision or "").strip().lower() + if d not in ("approved", "rejected"): + raise ValueError("invalid_decision") + + fb = (feedback or "").strip() + rat = (rationale or "").strip() or None + now = datetime.now(timezone.utc) + + audit_action = AuditAction.create + before_snap: Dict[str, Any] | None = None + if rec is None: + rec = ApplicationAdminResult( + initiative_id=initiative.id, + decision=d, + feedback=fb, + rationale=rat, + created_by=admin_user_id, + updated_by=admin_user_id, + ) + session.add(rec) + else: + audit_action = AuditAction.update + before_snap = _audit_admin_result_snapshot(rec) + rec.decision = d + rec.feedback = fb + rec.rationale = rat + rec.updated_by = admin_user_id + rec.updated_at = now + + initiative.status = d + initiative.updated_at = now + + await session.flush() + await session.refresh(rec) + + row = _row_to_api(initiative, payload, rec, application_id.strip()) + await _attach_admin_user_full_names(session, row, rec) + ae, ar = await resolve_actor_fields(session, admin_user_id) + await record_audit( + session, + actor_user_id=admin_user_id, + actor_email=ae, + actor_role=ar, + action=audit_action, + entity_type="application_admin_result", + entity_id=application_id.strip(), + before=before_snap, + after=_audit_admin_result_snapshot(rec), + metadata={"initiativeId": str(initiative.id)}, + ) + return row + + +async def delete_admin_result( + session: AsyncSession, + application_id: str, + *, + actor_user_id: uuid.UUID, +) -> None: + initiative, _draft = await _resolve_initiative_and_latest_draft_for_application_id(session, application_id) + + stmt = select(ApplicationAdminResult).where(ApplicationAdminResult.initiative_id == initiative.id) + rec = (await session.execute(stmt)).scalar_one_or_none() + if rec is None: + raise LookupError("result_not_found") + + snap = _audit_admin_result_snapshot(rec) + aid = application_id.strip() + await session.delete(rec) + + initiative.status = "submitted" + initiative.updated_at = datetime.now(timezone.utc) + + await session.flush() + ae, ar = await resolve_actor_fields(session, actor_user_id) + await record_audit( + session, + actor_user_id=actor_user_id, + actor_email=ae, + actor_role=ar, + action=AuditAction.delete, + entity_type="application_admin_result", + entity_id=aid, + before=snap, + metadata={"initiativeId": str(initiative.id)}, + ) diff --git a/be0/src/initiative_db/application_backup.py b/be0/src/initiative_db/application_backup.py new file mode 100644 index 0000000..3636876 --- /dev/null +++ b/be0/src/initiative_db/application_backup.py @@ -0,0 +1,342 @@ +"""Admin streaming ZIP backup: full PDF, official DOCX/PDF, evidence — manifest + SHA-256 verify.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +from pathlib import Path +from typing import Any, Dict, Iterable, Iterator, List, Optional + +import boto3 +from botocore.config import Config as BotoConfig +from botocore.exceptions import ClientError +from zipstream import ZipStream + +from src.initiative_db.application_storage import ( + EVIDENCE_ROLE_RESEARCH, + EVIDENCE_ROLE_TECHNICAL, + EVIDENCE_ROLE_TEXTBOOK, + ROLE_OFFICIAL_FORM_DOCX, + ROLE_OFFICIAL_FORM_PDF, + STORAGE_EXTERNAL_URL, + STORAGE_FILESYSTEM, + STORAGE_MINIO_ATTACHMENTS, + STORAGE_MINIO_EXPORTS, + effective_storage_kind, +) +from src.initiative_db.backup_naming import official_form_pdf_backup_zip_path +from src.initiative_db.models import ApplicationArtifact, Initiative +from src.minio.storage import S3Settings, _sanitize_filename + +logger = logging.getLogger(__name__) + + +class BackupIntegrityError(Exception): + """SHA-256 mismatch while packing an artifact into the backup ZIP.""" + + +def _sync_s3_client(settings: S3Settings): + return boto3.client( + "s3", + endpoint_url=settings.s3_endpoint_url, + aws_access_key_id=settings.s3_access_key, + aws_secret_access_key=settings.s3_secret_key, + region_name=settings.s3_region, + config=BotoConfig(signature_version="s3v4"), + ) + + +def _iter_chunks_s3(settings: S3Settings, bucket: str, key: str, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + s3 = _sync_s3_client(settings) + try: + obj = s3.get_object(Bucket=bucket, Key=key) + except ClientError as exc: + code = exc.response.get("Error", {}).get("Code", "") + if code in ("404", "NoSuchKey"): + raise FileNotFoundError(f"s3://{bucket}/{key}") from exc + raise + body = obj["Body"] + try: + while True: + chunk = body.read(chunk_size) + if not chunk: + break + yield chunk + finally: + body.close() + + +def _submitted_initiatives_dir() -> Path: + return Path( + os.getenv( + "SUBMITTED_INITIATIVES_DIR", + str((Path(__file__).resolve().parents[3] / "fe0" / "public" / "submitted-initiatives").resolve()), + ) + ) + + +def _filesystem_path_for_uri(storage_uri: str) -> Path: + base = _submitted_initiatives_dir().resolve() + u = storage_uri.strip() + prefix = "/submitted-initiatives/" + if u.startswith(prefix): + rel = u[len(prefix) :].lstrip("/") + elif u.startswith("/"): + raw = u.lstrip("/") + if raw.startswith("submitted-initiatives/"): + rel = raw[len("submitted-initiatives/") :].lstrip("/") + else: + rel = raw + else: + rel = u.lstrip("/") + + candidate = (base / rel).resolve() + try: + candidate.relative_to(base) + except ValueError as exc: + raise ValueError(f"Invalid backup path outside submitted-initiatives: {storage_uri}") from exc + return candidate + + +def _iter_file_chunks(path: Path, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + with open(path, "rb") as handle: + while True: + chunk = handle.read(chunk_size) + if not chunk: + break + yield chunk + + +def _guarded_chunk_iter( + chunks: Iterable[bytes], + expected_sha256: Optional[str], + role: str, + arcname: str, +) -> Iterator[bytes]: + h = hashlib.sha256() + for chunk in chunks: + h.update(chunk) + yield chunk + digest = h.hexdigest() + if expected_sha256 and digest.lower() != str(expected_sha256).lower(): + raise BackupIntegrityError( + f"SHA-256 mismatch for {role} ({arcname}): stored={expected_sha256[:16]}… verified={digest[:16]}…" + ) + + +def _zip_path_for_artifact(art: ApplicationArtifact) -> str: + role = art.role + if role == "full_pdf": + return "submitted/full-package.pdf" + if role == ROLE_OFFICIAL_FORM_DOCX: + return "submitted/official-form.docx" + if role == ROLE_OFFICIAL_FORM_PDF: + return "submitted/official-form.pdf" + if role == EVIDENCE_ROLE_RESEARCH: + name = _sanitize_filename(art.original_name or "evidence") + return f"evidence/research/{name}" + if role == EVIDENCE_ROLE_TEXTBOOK: + name = _sanitize_filename(art.original_name or "evidence") + return f"evidence/textbook/{name}" + if role == EVIDENCE_ROLE_TECHNICAL: + name = _sanitize_filename(art.original_name or "evidence") + return f"evidence/technical/{name}" + safe = _sanitize_filename(art.original_name or role) + return f"other/{role}/{safe}" + + +def _synthesize_official_form_pdf_bytes(obm: Dict[str, Any]) -> bytes: + """Same pipeline as ``POST /api/v1/docx/preview-application-form-pdf`` and submit-time persist.""" + from src.be01.docx_to_pdf import convert_docx_bytes_to_pdf + from src.be01.fill_application_form import fill_application_form_docx + from src.be01.official_to_data_blank import official_to_data_blank + + ctx = official_to_data_blank(dict(obm)) + docx_bytes = fill_application_form_docx(ctx) + return convert_docx_bytes_to_pdf( + docx_bytes, + relax_justified_softbreaks=True, + strip_table_row_heights=False, + ) + + +def _iter_memory_chunks(data: bytes, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + for i in range(0, len(data), chunk_size): + yield data[i : i + chunk_size] + + +def build_backup_zipstream( + *, + settings: S3Settings, + initiative: Initiative, + application_id: str, + case_code: str, + artifacts: List[ApplicationArtifact], + review_doc_json: Optional[Dict[str, Any]], + owner_id: Optional[str], + submitted_at: Optional[str], +) -> ZipStream: + z = ZipStream() + manifest_files: List[Dict[str, Any]] = [] + + official_obm: Optional[Dict[str, Any]] = None + if isinstance(review_doc_json, dict): + raw_obm = review_doc_json.get("officialBieuMau") + if isinstance(raw_obm, dict) and len(raw_obm) > 0: + official_obm = raw_obm + + synth_pdf: Optional[bytes] = None + synth_arc: Optional[str] = None + if official_obm is not None: + try: + synth_pdf = _synthesize_official_form_pdf_bytes(official_obm) + synth_arc = official_form_pdf_backup_zip_path(official_obm) or "submitted/official-form.pdf" + except Exception: + logger.exception( + "Backup: failed to synthesize official form PDF from officialBieuMau; falling back to stored artifact if any" + ) + synth_pdf = None + synth_arc = None + + for art in sorted(artifacts, key=lambda a: _zip_path_for_artifact(a)): + role = art.role + if role == ROLE_OFFICIAL_FORM_PDF and synth_pdf is not None: + continue + uri = (art.storage_uri or "").strip() + if not uri: + continue + sk = effective_storage_kind(role, uri, art.storage_kind) + arcname = _zip_path_for_artifact(art) + if role == ROLE_OFFICIAL_FORM_PDF and official_obm is not None: + custom_pdf_path = official_form_pdf_backup_zip_path(official_obm) + if custom_pdf_path: + arcname = custom_pdf_path + + if sk == STORAGE_EXTERNAL_URL: + manifest_files.append( + { + "role": role, + "zip_path": arcname, + "original_name": art.original_name, + "mime_type": art.mime_type, + "byte_size": art.byte_size, + "stored_sha256": art.sha256, + "verified_sha256": None, + "storage_kind": sk, + "skipped": True, + "skip_reason": "external_url_not_packed", + } + ) + continue + + raw_iter: Optional[Iterator[bytes]] = None + if sk == STORAGE_FILESYSTEM: + path = _filesystem_path_for_uri(uri) + if not path.is_file(): + logger.warning("Backup skip missing filesystem object role=%s path=%s", role, path) + manifest_files.append( + { + "role": role, + "zip_path": arcname, + "original_name": art.original_name, + "mime_type": art.mime_type, + "byte_size": art.byte_size, + "stored_sha256": art.sha256, + "verified_sha256": None, + "storage_kind": sk, + "skipped": True, + "skip_reason": "filesystem_missing", + } + ) + continue + raw_iter = _iter_file_chunks(path) + elif sk in (STORAGE_MINIO_EXPORTS, STORAGE_MINIO_ATTACHMENTS): + bucket = ( + settings.s3_bucket_exports if sk == STORAGE_MINIO_EXPORTS else settings.s3_bucket_attachments + ) + key = uri + try: + raw_iter = _iter_chunks_s3(settings, bucket, key) + except FileNotFoundError: + logger.warning("Backup skip missing MinIO object role=%s bucket=%s key=%s", role, bucket, key[:64]) + manifest_files.append( + { + "role": role, + "zip_path": arcname, + "original_name": art.original_name, + "mime_type": art.mime_type, + "byte_size": art.byte_size, + "stored_sha256": art.sha256, + "verified_sha256": None, + "storage_kind": sk, + "skipped": True, + "skip_reason": "minio_missing", + } + ) + continue + else: + continue + + chunk_iter: Iterator[bytes] = ( + _guarded_chunk_iter(raw_iter, art.sha256, role, arcname) if art.sha256 else raw_iter + ) + z.add(chunk_iter, arcname) + + manifest_files.append( + { + "role": role, + "zip_path": arcname, + "original_name": art.original_name, + "mime_type": art.mime_type, + "byte_size": art.byte_size, + "stored_sha256": art.sha256, + "verified_sha256": art.sha256 if art.sha256 else None, + "integrity_note": "sha256_checked_during_zip" if art.sha256 else "no_stored_sha256", + "storage_kind": sk, + "skipped": False, + } + ) + + if synth_pdf is not None and synth_arc is not None: + z.add(_iter_memory_chunks(synth_pdf), synth_arc) + digest = hashlib.sha256(synth_pdf).hexdigest() + manifest_files.append( + { + "role": ROLE_OFFICIAL_FORM_PDF, + "zip_path": synth_arc, + "original_name": synth_arc.rsplit("/", 1)[-1], + "mime_type": "application/pdf", + "byte_size": len(synth_pdf), + "stored_sha256": None, + "verified_sha256": digest, + "integrity_note": "synthesized_for_backup_match_preview_endpoint", + "storage_kind": "synthesized", + "skipped": False, + } + ) + + missing_official = ( + not any(a.role in (ROLE_OFFICIAL_FORM_DOCX, ROLE_OFFICIAL_FORM_PDF) for a in artifacts) and synth_pdf is None + ) + + manifest: Dict[str, Any] = { + "applicationId": application_id, + "case_code": case_code, + "initiative_id": str(initiative.id), + "owner_id": owner_id, + "submitted_at": submitted_at, + "missing_official_form_artifacts": missing_official, + "files": manifest_files, + } + + if review_doc_json is not None: + z.add( + json.dumps(review_doc_json, ensure_ascii=False, indent=2).encode("utf-8"), + "metadata/application_review_documents.json", + ) + + z.add(json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"), "manifest.json") + + return z diff --git a/be0/src/initiative_db/application_storage.py b/be0/src/initiative_db/application_storage.py new file mode 100644 index 0000000..c08c378 --- /dev/null +++ b/be0/src/initiative_db/application_storage.py @@ -0,0 +1,420 @@ +"""Append-only tab snapshots, submit snapshots, taxonomy/workflow projection, artifact registry.""" + +from __future__ import annotations + +import uuid +from datetime import date, datetime, timezone +from typing import Any, Dict, List, Mapping, Optional + +from sqlalchemy import desc, func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from src.initiative_db.models import ( + ApplicationArtifact, + ApplicationSubmitSnapshot, + ApplicationTaxonomy, + ApplicationWorkflow, + DraftTabSnapshot, + Initiative, +) + +# Initiative Đơn — minh chứng (2.1 nghiên cứu / 2.2 giáo trình / kỹ thuật nhóm 1) +# Must match application_artifacts.role CHECK in 002_application_storage_extensions.sql +EVIDENCE_ROLE_RESEARCH = "research_evidence" +EVIDENCE_ROLE_TEXTBOOK = "textbook_evidence" +EVIDENCE_ROLE_TECHNICAL = "technical_evidence" + +ROLE_OFFICIAL_FORM_DOCX = "official_form_docx" +ROLE_OFFICIAL_FORM_PDF = "official_form_pdf" + +STORAGE_MINIO_EXPORTS = "minio_exports" +STORAGE_MINIO_ATTACHMENTS = "minio_attachments" +STORAGE_FILESYSTEM = "filesystem" +STORAGE_EXTERNAL_URL = "external_url" + +VALID_DRAFT_TABS = frozenset({"report", "application", "contribution"}) + + +def effective_storage_kind(role: str, storage_uri: str, declared: Optional[str]) -> str: + """Resolve ``storage_kind`` from DB column or legacy ``storage_uri`` shape.""" + if declared in ( + STORAGE_MINIO_EXPORTS, + STORAGE_MINIO_ATTACHMENTS, + STORAGE_FILESYSTEM, + STORAGE_EXTERNAL_URL, + ): + return declared + u = (storage_uri or "").strip() + if u.startswith("http://") or u.startswith("https://"): + return STORAGE_EXTERNAL_URL + if u.startswith("/submitted-initiatives/"): + return STORAGE_FILESYSTEM + if u.startswith("/") and not u.startswith("/initiatives/"): + return STORAGE_FILESYSTEM + if role in (EVIDENCE_ROLE_RESEARCH, EVIDENCE_ROLE_TEXTBOOK, EVIDENCE_ROLE_TECHNICAL): + return STORAGE_MINIO_ATTACHMENTS + return STORAGE_MINIO_EXPORTS + + +def _parse_date_only(value: Any) -> Optional[date]: + if value is None: + return None + raw = str(value).strip() + if len(raw) < 10: + return None + try: + return date.fromisoformat(raw[:10]) + except ValueError: + return None + + +async def record_tab_snapshot( + session: AsyncSession, + *, + initiative_id: uuid.UUID, + draft_id: Optional[uuid.UUID], + tab: str, + payload: Mapping[str, Any], + source: str = "autosave", +) -> None: + if tab not in VALID_DRAFT_TABS: + return + stmt = select(func.coalesce(func.max(DraftTabSnapshot.tab_version), 0)).where( + DraftTabSnapshot.initiative_id == initiative_id, + DraftTabSnapshot.tab == tab, + ) + max_v = (await session.execute(stmt)).scalar() + next_v = int(max_v or 0) + 1 + session.add( + DraftTabSnapshot( + initiative_id=initiative_id, + draft_id=draft_id, + tab=tab, + tab_version=next_v, + payload=dict(payload), + source=source, + ) + ) + + +async def record_submit_snapshot( + session: AsyncSession, + *, + initiative_id: uuid.UUID, + submission_record_id: str, + merged_tabs: Mapping[str, Any], + submit_metadata: Mapping[str, Any], + full_pdf_uri: str, +) -> None: + session.add( + ApplicationSubmitSnapshot( + initiative_id=initiative_id, + submission_record_id=submission_record_id, + merged_tabs=dict(merged_tabs), + submit_metadata=dict(submit_metadata), + full_pdf_uri=full_pdf_uri, + ) + ) + + +async def upsert_application_taxonomy( + session: AsyncSession, + *, + initiative_id: uuid.UUID, + subject_id: str, + group_id: str, + topic_type: str, +) -> None: + now = datetime.now(timezone.utc) + stmt = pg_insert(ApplicationTaxonomy).values( + initiative_id=initiative_id, + subject_id=subject_id or "", + group_id=group_id or "", + topic_type=topic_type or "", + updated_at=now, + ) + stmt = stmt.on_conflict_do_update( + index_elements=[ApplicationTaxonomy.initiative_id], + set_={ + "subject_id": stmt.excluded.subject_id, + "group_id": stmt.excluded.group_id, + "topic_type": stmt.excluded.topic_type, + "updated_at": stmt.excluded.updated_at, + }, + ) + await session.execute(stmt) + + +async def upsert_application_workflow( + session: AsyncSession, + *, + initiative_id: uuid.UUID, + submission_record: Mapping[str, Any], +) -> None: + review_status = str(submission_record.get("reviewStatus") or "not_reviewed") + review_deadline = _parse_date_only(submission_record.get("reviewDeadline")) + reviewer = submission_record.get("reviewer") + supervisor = submission_record.get("supervisor") + conference = submission_record.get("conference") + now = datetime.now(timezone.utc) + stmt = pg_insert(ApplicationWorkflow).values( + initiative_id=initiative_id, + review_status=review_status, + review_deadline=review_deadline, + reviewer=dict(reviewer) if isinstance(reviewer, dict) else None, + supervisor=dict(supervisor) if isinstance(supervisor, dict) else None, + conference=dict(conference) if isinstance(conference, dict) else None, + updated_at=now, + ) + stmt = stmt.on_conflict_do_update( + index_elements=[ApplicationWorkflow.initiative_id], + set_={ + "review_status": stmt.excluded.review_status, + "review_deadline": stmt.excluded.review_deadline, + "reviewer": stmt.excluded.reviewer, + "supervisor": stmt.excluded.supervisor, + "conference": stmt.excluded.conference, + "updated_at": stmt.excluded.updated_at, + }, + ) + await session.execute(stmt) + + +async def upsert_artifact_full_pdf( + session: AsyncSession, + *, + initiative_id: uuid.UUID, + storage_uri: str, + original_name: Optional[str], + byte_size: Optional[int], + sha256_hex: Optional[str], + uploaded_by: Optional[uuid.UUID], + storage_kind: Optional[str] = None, +) -> None: + now = datetime.now(timezone.utc) + stmt = pg_insert(ApplicationArtifact).values( + initiative_id=initiative_id, + role="full_pdf", + storage_uri=storage_uri, + storage_kind=storage_kind, + original_name=original_name, + mime_type="application/pdf", + byte_size=byte_size, + sha256=sha256_hex, + uploaded_by=uploaded_by, + uploaded_at=now, + ) + stmt = stmt.on_conflict_do_update( + index_elements=[ApplicationArtifact.initiative_id, ApplicationArtifact.role], + set_={ + "storage_uri": stmt.excluded.storage_uri, + "storage_kind": stmt.excluded.storage_kind, + "original_name": stmt.excluded.original_name, + "byte_size": stmt.excluded.byte_size, + "sha256": stmt.excluded.sha256, + "uploaded_by": stmt.excluded.uploaded_by, + "uploaded_at": stmt.excluded.uploaded_at, + }, + ) + await session.execute(stmt) + + +async def upsert_artifact_official_form( + session: AsyncSession, + *, + initiative_id: uuid.UUID, + role: str, + storage_uri: str, + original_name: Optional[str], + byte_size: Optional[int], + sha256_hex: Optional[str], + uploaded_by: Optional[uuid.UUID], + mime_type: str, + storage_kind: str = STORAGE_MINIO_EXPORTS, +) -> None: + now = datetime.now(timezone.utc) + stmt = pg_insert(ApplicationArtifact).values( + initiative_id=initiative_id, + role=role, + storage_uri=storage_uri, + storage_kind=storage_kind, + original_name=original_name, + mime_type=mime_type, + byte_size=byte_size, + sha256=sha256_hex, + uploaded_by=uploaded_by, + uploaded_at=now, + ) + stmt = stmt.on_conflict_do_update( + index_elements=[ApplicationArtifact.initiative_id, ApplicationArtifact.role], + set_={ + "storage_uri": stmt.excluded.storage_uri, + "storage_kind": stmt.excluded.storage_kind, + "original_name": stmt.excluded.original_name, + "byte_size": stmt.excluded.byte_size, + "sha256": stmt.excluded.sha256, + "uploaded_by": stmt.excluded.uploaded_by, + "uploaded_at": stmt.excluded.uploaded_at, + "mime_type": stmt.excluded.mime_type, + }, + ) + await session.execute(stmt) + + +async def upsert_evidence_artifact( + session: AsyncSession, + *, + initiative_id: uuid.UUID, + role: str, + storage_uri: str, + original_name: Optional[str], + byte_size: Optional[int], + sha256_hex: Optional[str], + uploaded_by: Optional[uuid.UUID], + mime_type: str = "application/pdf", +) -> None: + now = datetime.now(timezone.utc) + stmt = pg_insert(ApplicationArtifact).values( + initiative_id=initiative_id, + role=role, + storage_uri=storage_uri, + storage_kind=STORAGE_MINIO_ATTACHMENTS, + original_name=original_name, + mime_type=mime_type, + byte_size=byte_size, + sha256=sha256_hex, + uploaded_by=uploaded_by, + uploaded_at=now, + ) + stmt = stmt.on_conflict_do_update( + index_elements=[ApplicationArtifact.initiative_id, ApplicationArtifact.role], + set_={ + "storage_uri": stmt.excluded.storage_uri, + "storage_kind": stmt.excluded.storage_kind, + "original_name": stmt.excluded.original_name, + "byte_size": stmt.excluded.byte_size, + "sha256": stmt.excluded.sha256, + "uploaded_by": stmt.excluded.uploaded_by, + "uploaded_at": stmt.excluded.uploaded_at, + "mime_type": stmt.excluded.mime_type, + # Re-upload clears staff decision + "review_status": None, + "reviewed_by": None, + "reviewed_at": None, + }, + ) + await session.execute(stmt) + + +async def get_evidence_artifact_row( + session: AsyncSession, + *, + initiative_id: uuid.UUID, + role: str, +) -> Optional[ApplicationArtifact]: + stmt = select(ApplicationArtifact).where( + ApplicationArtifact.initiative_id == initiative_id, + ApplicationArtifact.role == role, + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def set_evidence_artifact_review( + session: AsyncSession, + *, + initiative_id: uuid.UUID, + role: str, + review_status: str, + reviewer_id: uuid.UUID, +) -> Optional[ApplicationArtifact]: + row = await get_evidence_artifact_row(session, initiative_id=initiative_id, role=role) + if row is None: + return None + now = datetime.now(timezone.utc) + row.review_status = review_status + row.reviewed_by = reviewer_id + row.reviewed_at = now + await session.flush() + return row + + +async def delete_evidence_artifact_row( + session: AsyncSession, + *, + initiative_id: uuid.UUID, + role: str, +) -> Optional[ApplicationArtifact]: + row = await get_evidence_artifact_row(session, initiative_id=initiative_id, role=role) + if row is None: + return None + await session.delete(row) + return row + + +async def list_tab_snapshots_for_case( + session: AsyncSession, + *, + case_code: str, + tab: Optional[str], + limit: int, +) -> List[Dict[str, Any]]: + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == case_code)) + ).scalar_one_or_none() + if ini is None: + return [] + stmt = select(DraftTabSnapshot).where(DraftTabSnapshot.initiative_id == ini.id) + if tab: + stmt = stmt.where(DraftTabSnapshot.tab == tab) + stmt = stmt.order_by(desc(DraftTabSnapshot.captured_at)).limit(max(1, min(limit, 200))) + rows = (await session.execute(stmt)).scalars().all() + out: List[Dict[str, Any]] = [] + for r in rows: + out.append( + { + "id": str(r.id), + "initiativeId": str(r.initiative_id), + "draftId": str(r.draft_id) if r.draft_id else None, + "tab": r.tab, + "tabVersion": r.tab_version, + "payload": r.payload, + "source": r.source, + "capturedAt": r.captured_at.isoformat() if r.captured_at else None, + } + ) + return out + + +async def list_submit_snapshots_for_case( + session: AsyncSession, + *, + case_code: str, + limit: int, +) -> List[Dict[str, Any]]: + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == case_code)) + ).scalar_one_or_none() + if ini is None: + return [] + stmt = ( + select(ApplicationSubmitSnapshot) + .where(ApplicationSubmitSnapshot.initiative_id == ini.id) + .order_by(desc(ApplicationSubmitSnapshot.captured_at)) + .limit(max(1, min(limit, 50))) + ) + rows = (await session.execute(stmt)).scalars().all() + out: List[Dict[str, Any]] = [] + for r in rows: + out.append( + { + "id": str(r.id), + "initiativeId": str(r.initiative_id), + "submissionRecordId": r.submission_record_id, + "mergedTabs": r.merged_tabs, + "submitMetadata": r.submit_metadata, + "fullPdfUri": r.full_pdf_uri, + "capturedAt": r.captured_at.isoformat() if r.captured_at else None, + } + ) + return out diff --git a/be0/src/initiative_db/backup_naming.py b/be0/src/initiative_db/backup_naming.py new file mode 100644 index 0000000..cdebcb6 --- /dev/null +++ b/be0/src/initiative_db/backup_naming.py @@ -0,0 +1,80 @@ +"""Naming helpers for admin application backup downloads (no heavy deps).""" + +from __future__ import annotations + +import re +import unicodedata +from typing import Any, Dict, Optional + +_TRANG_BIA_TEN_SK = "Tên sáng kiến (Tiếng Việt)" +_TRANG_BIA_TAC_GIA = "Tác giả/nhóm tác giả sáng kiến" +_TRANG_BIA_LIEN_HE = "Thông tin liên hệ (Điện thoại, Email)" +_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") + + +def _sanitize_filename(name: str) -> str: + """Match ``src.minio.storage._sanitize_filename`` without importing boto/S3 stack.""" + name = name.replace("/", "_").replace("\\", "_") + name = re.sub(r"\s+", "_", name.strip()) + name = "".join(ch for ch in name if unicodedata.category(ch)[0] != "C") + return name[:200] or "file" + + +def _extract_contact_email(text: str) -> str: + """Prefer first e-mail in « Thông tin liên hệ »; else whole string trimmed.""" + raw = (text or "").strip() + if not raw: + return "" + m = _EMAIL_RE.search(raw) + return m.group(0) if m else raw + + +def official_form_pdf_backup_zip_path(official_bieu_mau: Optional[Dict[str, Any]]) -> Optional[str]: + """ + Relative path inside admin backup ZIP for the official merged PDF. + + Format: ``submitted/{Tên_sáng_kiến}_{tên_tác_giả}_{email_liên_hệ}.pdf`` (segments sanitized). + Returns ``None`` when ``TRANG BÌA`` is missing so callers keep ``submitted/official-form.pdf``. + """ + if not isinstance(official_bieu_mau, dict): + return None + bia = official_bieu_mau.get("TRANG BÌA") + if not isinstance(bia, dict): + return None + ten = str(bia.get(_TRANG_BIA_TEN_SK) or "").strip() + tac = str(bia.get(_TRANG_BIA_TAC_GIA) or "").strip() + lien = str(bia.get(_TRANG_BIA_LIEN_HE) or "").strip() + email = _extract_contact_email(lien) + if not ten and not tac and not email: + return None + part_ten = _sanitize_filename(ten) if ten else "Ten_sang_kien" + part_tac = _sanitize_filename(tac) if tac else "tac_gia" + part_mail = _sanitize_filename(email) if email else "email_lien_he" + stem = f"{part_ten}_{part_tac}_{part_mail}" + if len(stem) > 180: + stem = stem[:180] + return f"submitted/{stem}.pdf" + + +def backup_zip_attachment_filename( + *, + owner_email: Optional[str], + owner_full_name: Optional[str], + public_application_id: str, +) -> str: + """ + Safe Content-Disposition name: {username}_{applicationId}.zip + Username prefers email local-part, else full name; falls back to ``applicant``. + """ + user_seg = "applicant" + email = (owner_email or "").strip() + if email and "@" in email: + local = email.split("@", 1)[0].strip() + user_seg = _sanitize_filename(local) or "applicant" + else: + name = (owner_full_name or "").strip() + if name: + user_seg = _sanitize_filename(name) or "applicant" + pid = (public_application_id or "").strip() + base = f"{user_seg}_{pid}.zip" + return _sanitize_filename(base) or base diff --git a/be0/src/initiative_db/drafts.py b/be0/src/initiative_db/drafts.py new file mode 100644 index 0000000..d724560 --- /dev/null +++ b/be0/src/initiative_db/drafts.py @@ -0,0 +1,252 @@ +"""Persist tab-based application drafts in `drafts.payload` (JSONB).""" + +from __future__ import annotations + +import logging +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from sqlalchemy import select +from sqlalchemy.exc import ProgrammingError +from sqlalchemy.ext.asyncio import AsyncSession + +from src.audit import AuditAction, record_audit, resolve_actor_fields +from src.initiative_db.application_storage import record_tab_snapshot +from src.initiative_db.models import AuditLog, Draft, Initiative + +# Seed user from migrations/001_initiative_schema.sql +SYSTEM_DRAFT_OWNER_ID = uuid.UUID("00000000-0000-4000-8000-000000000001") +logger = logging.getLogger(__name__) +OFFICIAL_FORM_LAYOUT_PAYLOAD_KEY = "officialFormPdfLayout" + + +async def save_application_draft_tab( + session: AsyncSession, + case_id: str, + tab: str, + data: Dict[str, Any], + owner_id: Optional[uuid.UUID] = None, +) -> Dict[str, Any]: + stmt = select(Initiative).where(Initiative.case_code == case_id) + initiative = (await session.execute(stmt)).scalar_one_or_none() + + effective_owner = owner_id or SYSTEM_DRAFT_OWNER_ID + + if initiative is None: + initiative = Initiative(case_code=case_id, owner_id=effective_owner) + session.add(initiative) + await session.flush() + draft = Draft( + draft_code=f"DRAFT-{case_id}", + initiative_id=initiative.id, + payload=_empty_payload(case_id), + ) + session.add(draft) + else: + stmt_d = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + draft = (await session.execute(stmt_d)).scalar_one_or_none() + if draft is None: + draft = Draft( + draft_code=f"DRAFT-{case_id}", + initiative_id=initiative.id, + payload=_empty_payload(case_id), + ) + session.add(draft) + + if owner_id and initiative.owner_id == SYSTEM_DRAFT_OWNER_ID: + initiative.owner_id = owner_id + + current = dict(draft.payload) if isinstance(draft.payload, dict) else {} + tabs = dict(current.get("tabs") or {}) + tabs[tab] = data + now_iso = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + current["caseId"] = case_id + current["updatedAt"] = now_iso + current["tabs"] = tabs + + draft.payload = current + draft.version = (draft.version or 0) + 1 + + await session.flush() + + if owner_id is not None: + ae, ar = await resolve_actor_fields(session, owner_id) + await record_audit( + session, + actor_user_id=owner_id, + actor_email=ae, + actor_role=ar, + action=AuditAction.update, + entity_type="application_draft", + entity_id=case_id, + after={"tab": tab, "version": draft.version}, + metadata={"source": "autosave_tab"}, + ) + + # Snapshot history is optional telemetry. + # Run it in a savepoint so missing table errors don't poison the main transaction. + try: + async with session.begin_nested(): + await record_tab_snapshot( + session, + initiative_id=initiative.id, + draft_id=draft.id, + tab=tab, + payload=data, + source="autosave", + ) + except ProgrammingError as exc: + if "draft_tab_snapshots" in str(exc).lower(): + logger.warning( + "draft_tab_snapshots table missing; skipping tab snapshot for case %s (tab=%s)", + case_id, + tab, + ) + else: + raise + + session.add( + AuditLog( + actor_id=SYSTEM_DRAFT_OWNER_ID, + action="update", + entity="draft", + entity_id=draft.id, + diff={"tab": tab, "version": draft.version}, + ) + ) + + return { + "caseId": case_id, + "updatedAt": now_iso, + "tabs": tabs, + "version": draft.version, + "publicUrl": f"/application-drafts/{case_id}.yml", + } + + +async def insert_initiative_draft_if_missing( + session: AsyncSession, case_id: str, payload: Dict[str, Any] +) -> None: + """ + Copy a YAML/file-shaped bundle into initiatives + drafts when Postgres was empty. + Idempotent: no-op if initiative already exists. + """ + stmt = select(Initiative).where(Initiative.case_code == case_id) + if (await session.execute(stmt)).scalar_one_or_none() is not None: + return + + merged: Dict[str, Any] = dict(payload) if isinstance(payload, dict) else {} + tabs = merged.get("tabs") + merged["tabs"] = dict(tabs) if isinstance(tabs, dict) else {} + merged["caseId"] = case_id + + initiative = Initiative(case_code=case_id, owner_id=SYSTEM_DRAFT_OWNER_ID) + session.add(initiative) + await session.flush() + draft = Draft( + draft_code=f"DRAFT-{case_id}", + initiative_id=initiative.id, + payload=merged, + ) + session.add(draft) + await session.flush() + + +async def get_application_draft_document(session: AsyncSession, case_id: str) -> Dict[str, Any]: + stmt = select(Initiative).where(Initiative.case_code == case_id) + initiative = (await session.execute(stmt)).scalar_one_or_none() + if initiative is None: + raise KeyError(case_id) + + stmt_d = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + draft = (await session.execute(stmt_d)).scalar_one_or_none() + if draft is None or not isinstance(draft.payload, dict): + raise KeyError(case_id) + return draft.payload + + +def _empty_payload(case_id: str) -> Dict[str, Any]: + return { + "caseId": case_id, + "updatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "tabs": {}, + } + + +async def get_latest_draft_for_case(session: AsyncSession, case_id: str) -> Optional[Draft]: + stmt = select(Initiative).where(Initiative.case_code == case_id) + initiative = (await session.execute(stmt)).scalar_one_or_none() + if initiative is None: + return None + stmt_d = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + return (await session.execute(stmt_d)).scalar_one_or_none() + + +async def get_official_form_layout_payload(session: AsyncSession, case_id: str) -> Optional[Dict[str, Any]]: + draft = await get_latest_draft_for_case(session, case_id) + if draft is None or not isinstance(draft.payload, dict): + return None + slot = draft.payload.get(OFFICIAL_FORM_LAYOUT_PAYLOAD_KEY) + if not isinstance(slot, dict): + return None + return dict(slot) + + +async def save_official_form_layout_payload( + session: AsyncSession, + *, + case_id: str, + payload: Dict[str, Any], + owner_id: Optional[uuid.UUID] = None, +) -> Dict[str, Any]: + stmt = select(Initiative).where(Initiative.case_code == case_id) + initiative = (await session.execute(stmt)).scalar_one_or_none() + if initiative is None: + initiative = Initiative(case_code=case_id, owner_id=owner_id or SYSTEM_DRAFT_OWNER_ID) + session.add(initiative) + await session.flush() + + stmt_d = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + draft = (await session.execute(stmt_d)).scalar_one_or_none() + if draft is None: + draft = Draft( + draft_code=f"DRAFT-{case_id}", + initiative_id=initiative.id, + payload=_empty_payload(case_id), + ) + session.add(draft) + await session.flush() + + if owner_id and initiative.owner_id == SYSTEM_DRAFT_OWNER_ID: + initiative.owner_id = owner_id + + now_iso = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + current = dict(draft.payload) if isinstance(draft.payload, dict) else _empty_payload(case_id) + current["caseId"] = case_id + current["updatedAt"] = now_iso + current[OFFICIAL_FORM_LAYOUT_PAYLOAD_KEY] = dict(payload) + draft.payload = current + draft.version = (draft.version or 0) + 1 + await session.flush() + return {"caseId": case_id, "updatedAt": now_iso, "version": draft.version} diff --git a/be0/src/initiative_db/engine.py b/be0/src/initiative_db/engine.py new file mode 100644 index 0000000..931fa3c --- /dev/null +++ b/be0/src/initiative_db/engine.py @@ -0,0 +1,115 @@ +"""Async SQLAlchemy engine for initiative PostgreSQL persistence.""" + +from __future__ import annotations + +import logging +import os +from contextlib import asynccontextmanager +from typing import AsyncGenerator, Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +logger = logging.getLogger(__name__) + +_engine: Optional[AsyncEngine] = None +_session_factory: Optional[async_sessionmaker[AsyncSession]] = None + +# Inline from migrations/014_registration_otp.sql — applied on first engine init if missing +# (avoids relying on subprocess migrate script; idempotent). +_DDL_REGISTRATION_OTP_TABLE = """ +CREATE TABLE IF NOT EXISTS registration_otp_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + otp_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + failed_attempts INT NOT NULL DEFAULT 0, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +) +""" +_DDL_REGISTRATION_OTP_INDEX = """ +CREATE INDEX IF NOT EXISTS idx_registration_otp_codes_user_pending + ON registration_otp_codes (user_id) + WHERE used_at IS NULL +""" + + +async def _ensure_registration_otp_table(conn: AsyncConnection) -> None: + row = ( + await conn.execute( + text( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'registration_otp_codes' + LIMIT 1 + """ + ) + ) + ).first() + if row is not None: + return + logger.info("initiative_db: creating registration_otp_codes (migration 014 equivalent)") + await conn.execute(text(_DDL_REGISTRATION_OTP_TABLE)) + await conn.execute(text(_DDL_REGISTRATION_OTP_INDEX)) + await conn.execute( + text( + "COMMENT ON TABLE registration_otp_codes IS " + "'Hashed 6-digit OTP for register verification; pending rows deleted when superseded by resend.'" + ) + ) + + +def get_database_url() -> Optional[str]: + return os.getenv("INITIATIVE_DATABASE_URL") or os.getenv("DATABASE_URL") + + +def is_postgres_enabled() -> bool: + url = get_database_url() + return bool(url and url.startswith("postgresql")) + + +async def init_engine() -> None: + """Create async engine and session factory. No-op if URL missing.""" + global _engine, _session_factory + if not is_postgres_enabled(): + return + if _engine is not None: + return + url = get_database_url() + assert url is not None + _engine = create_async_engine(url, echo=os.getenv("SQL_ECHO", "").lower() in ("1", "true", "yes")) + _session_factory = async_sessionmaker(_engine, expire_on_commit=False) + async with _engine.begin() as conn: + await conn.execute(text("SELECT 1")) + await _ensure_registration_otp_table(conn) + + +async def dispose_engine() -> None: + global _engine, _session_factory + if _engine is not None: + await _engine.dispose() + _engine = None + _session_factory = None + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + if _session_factory is None: + raise RuntimeError("Initiative DB not initialized; call init_engine() on startup.") + return _session_factory + + +@asynccontextmanager +async def get_session() -> AsyncGenerator[AsyncSession, None]: + if is_postgres_enabled() and _session_factory is None: + await init_engine() + factory = get_session_factory() + async with factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/be0/src/initiative_db/models.py b/be0/src/initiative_db/models.py new file mode 100644 index 0000000..984fd3b --- /dev/null +++ b/be0/src/initiative_db/models.py @@ -0,0 +1,907 @@ +"""SQLAlchemy models for initiative persistence (subset used by API; full schema in migrations).""" + +from __future__ import annotations + +import uuid +from datetime import date, datetime +from typing import Any, Optional + +from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Integer, Numeric, Text, UniqueConstraint, text +from sqlalchemy.dialects.postgresql import ENUM, JSONB, UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +submission_status_enum = ENUM( + "draft", + "submitted", + "under_review", + "approved", + "rejected", + name="submission_status", + create_type=False, +) + +recognition_tier_enum = ENUM("excellent", "good", name="recognition_tier", create_type=False) + +user_role_enum = ENUM( + "applicant", + "council_member", + "editor", + "admin", + "viewer", + name="user_role", + create_type=False, +) + +profile_verification_status_enum = ENUM( + "draft", + "pending", + "verified", + "rejected", + name="profile_verification_status", + create_type=False, +) + +audit_action_enum = ENUM( + "create", + "read", + "update", + "delete", + "login", + "logout", + "login_failed", + name="audit_action", + create_type=False, +) + + +class Unit(Base): + """Faculty / department catalog (see migrations/001_initiative_schema.sql).""" + + __tablename__ = "units" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(Text, nullable=False) + parent_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("units.id", ondelete="SET NULL"), nullable=True + ) + address: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + full_name: Mapped[str] = mapped_column(Text, nullable=False) + phone: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + unit_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("units.id", ondelete="SET NULL"), nullable=True + ) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + email_verified: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false"), default=False + ) + credential_version: Mapped[int] = mapped_column( + Integer, nullable=False, server_default=text("0"), default=0 + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + staff_profile: Mapped[Optional["UserStaffProfile"]] = relationship( + "UserStaffProfile", + back_populates="user", + uselist=False, + foreign_keys="UserStaffProfile.user_id", + ) + + +class UserRoleRow(Base): + __tablename__ = "user_roles" + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + role: Mapped[str] = mapped_column(user_role_enum, primary_key=True) + admin_from_email_policy: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) + + +class AcademicTitle(Base): + """Lookup for academic ranks / degrees (avoid Postgres ENUM drift for title list).""" + + __tablename__ = "academic_titles" + + code: Mapped[str] = mapped_column(Text, primary_key=True) + label_vi: Mapped[str] = mapped_column(Text, nullable=False) + label_en: Mapped[str] = mapped_column(Text, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + + +class UserStaffProfile(Base): + """1:1 institutional profile + verification state (HR scalars; not MinIO).""" + + __tablename__ = "user_staff_profiles" + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + employee_id: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + academic_title_code: Mapped[Optional[str]] = mapped_column( + Text, ForeignKey("academic_titles.code"), nullable=True + ) + academic_title_other: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + unit_name_freetext: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + job_title: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + profile_verification_status: Mapped[str] = mapped_column( + profile_verification_status_enum, nullable=False, server_default=text("'draft'") + ) + verification_submitted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + verified_by_user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id"), nullable=True + ) + rejection_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + version: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + user: Mapped["User"] = relationship( + "User", + back_populates="staff_profile", + foreign_keys=[user_id], + ) + + +class EmailVerificationToken(Base): + """One-time email verification link secrets (store hash only).""" + + __tablename__ = "email_verification_tokens" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=text("now()"), nullable=False + ) + + +class RegistrationOtpCode(Base): + """Hashed 6-digit OTP for registration verification (magic-link flow superseded on register).""" + + __tablename__ = "registration_otp_codes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + otp_hash: Mapped[str] = mapped_column(Text, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + failed_attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=text("now()"), nullable=False + ) + + +class PasswordResetToken(Base): + """One-time password reset secrets (store hash only).""" + + __tablename__ = "password_reset_tokens" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=text("now()"), nullable=False + ) + + +class Initiative(Base): + __tablename__ = "initiatives" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + case_code: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + status: Mapped[str] = mapped_column(submission_status_enum, nullable=False, server_default="draft") + recognition_tier: Mapped[Optional[str]] = mapped_column(recognition_tier_enum, nullable=True) + submitted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + drafts: Mapped[list["Draft"]] = relationship( + "Draft", + back_populates="initiative", + cascade="all, delete-orphan", + ) + + +class Draft(Base): + __tablename__ = "drafts" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + draft_code: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + initiative_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("initiatives.id", ondelete="CASCADE"), nullable=False + ) + payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + version: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1") + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + initiative: Mapped["Initiative"] = relationship("Initiative", back_populates="drafts") + + +class AuditLog(Base): + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + action: Mapped[str] = mapped_column(Text, nullable=False) + entity: Mapped[str] = mapped_column(Text, nullable=False) + entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + diff: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class AuditEvent(Base): + """Append-only CRUD / auth audit trail (see migrations/008_audit_events.sql).""" + + __tablename__ = "audit_events" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=text("now()"), nullable=False + ) + actor_user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + actor_email: Mapped[str] = mapped_column(Text, nullable=False) + actor_role: Mapped[str] = mapped_column(Text, nullable=False) + action: Mapped[str] = mapped_column(audit_action_enum, nullable=False) + entity_type: Mapped[str] = mapped_column(Text, nullable=False) + entity_id: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + before: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + after: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + metadata_: Mapped[dict[str, Any]] = mapped_column( + "metadata", JSONB, nullable=False, server_default=text("'{}'::jsonb") + ) + request_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True) + + +class DraftTabSnapshot(Base): + """Per-tab JSONB version history for an initiative draft (autosave / explicit save).""" + + __tablename__ = "draft_tab_snapshots" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + initiative_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("initiatives.id", ondelete="CASCADE"), nullable=False + ) + draft_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("drafts.id", ondelete="SET NULL"), nullable=True + ) + tab: Mapped[str] = mapped_column(Text, nullable=False) + tab_version: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1") + payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + source: Mapped[str] = mapped_column(Text, nullable=False, server_default="autosave") + captured_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ApplicationSubmitSnapshot(Base): + """Immutable snapshot of merged tabs + metadata at submit time.""" + + __tablename__ = "application_submit_snapshots" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + initiative_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("initiatives.id", ondelete="CASCADE"), nullable=False + ) + submission_record_id: Mapped[str] = mapped_column(Text, nullable=False) + merged_tabs: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + submit_metadata: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + full_pdf_uri: Mapped[str] = mapped_column(Text, nullable=False) + captured_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ApplicationWorkflow(Base): + """Council-facing workflow projection (review status, deadlines, assignments).""" + + __tablename__ = "application_workflow" + + initiative_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("initiatives.id", ondelete="CASCADE"), primary_key=True + ) + review_status: Mapped[str] = mapped_column(Text, nullable=False, server_default="not_reviewed") + review_deadline: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + reviewer: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + supervisor: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + conference: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ApplicationTaxonomy(Base): + """Subject / group / topic type for list views and analytics.""" + + __tablename__ = "application_taxonomy" + + initiative_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("initiatives.id", ondelete="CASCADE"), primary_key=True + ) + subject_id: Mapped[str] = mapped_column(Text, nullable=False, server_default="") + group_id: Mapped[str] = mapped_column(Text, nullable=False, server_default="") + topic_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="") + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ApplicationArtifact(Base): + """Registered deliverables (full PDF, evidence kinds, future abstract/poster).""" + + __tablename__ = "application_artifacts" + __table_args__ = (UniqueConstraint("initiative_id", "role", name="uq_application_artifacts_init_role"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + initiative_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("initiatives.id", ondelete="CASCADE"), nullable=False + ) + role: Mapped[str] = mapped_column(Text, nullable=False) + storage_kind: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + storage_uri: Mapped[str] = mapped_column(Text, nullable=False) + original_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + mime_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="application/pdf") + byte_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + sha256: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + uploaded_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + # Staff (admin / hội đồng) — minh chứng upload; applicants do not set these + review_status: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + reviewed_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + + +class ApplicationAdminResult(Base): + """Quản trị ghi nhận kết quả Duyệt/Từ chối theo initiative (một bản ghi / hồ sơ).""" + + __tablename__ = "application_admin_results" + __table_args__ = (UniqueConstraint("initiative_id", name="uq_application_admin_results_initiative"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + initiative_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("initiatives.id", ondelete="CASCADE"), nullable=False + ) + decision: Mapped[str] = mapped_column(Text, nullable=False) + feedback: Mapped[str] = mapped_column(Text, nullable=False, server_default="") + rationale: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + created_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + updated_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + +class UserNotification(Base): + """Applicant inbox row; created when admin saves adjudication (best-effort, post-commit).""" + + __tablename__ = "user_notifications" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + recipient_user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + type: Mapped[str] = mapped_column(Text, nullable=False) + title: Mapped[str] = mapped_column(Text, nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + application_id: Mapped[str] = mapped_column(Text, nullable=False) + related_initiative_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("initiatives.id", ondelete="SET NULL"), nullable=True + ) + source_admin_result_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("application_admin_results.id", ondelete="SET NULL"), nullable=True + ) + decision: Mapped[str] = mapped_column(Text, nullable=False) + merit_category_label: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + feedback_text: Mapped[str] = mapped_column(Text, nullable=False, server_default="") + rationale_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ApplicationReviewDocument(Base): + """Versioned ReviewPanel JSON bundles for DOCX/backoffice pipelines.""" + + __tablename__ = "application_review_documents" + __table_args__ = (UniqueConstraint("initiative_id", "document_version", name="uq_review_docs_init_ver"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + initiative_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("initiatives.id", ondelete="CASCADE"), nullable=False + ) + case_id: Mapped[str] = mapped_column(Text, nullable=False) + document_version: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1") + official_bieu_mau: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) + template_data: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + full_bundle: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class DocumentTemplate(Base): + """Admin-managed DOCX template (file in MinIO) + extracted Jinja placeholder fields.""" + + __tablename__ = "document_templates" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(Text, nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + storage_key: Mapped[str] = mapped_column(Text, nullable=False) + original_filename: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + content_sha256: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + fields: Mapped[list[dict[str, Any]]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'::jsonb") + ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + created_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +# --------------------------------------------------------------------------- # +# Research projects (Thuyết minh đề tài) + PI cockpit entities — migration 016. +# The proposal row IS the project across its lifecycle (draft→submitted→approved|rejected); +# child research_project_* tables hold the cockpit entities. Owner+admin authz (v1). +# --------------------------------------------------------------------------- # +class ResearchProject(Base): + """A research-project proposal that becomes a managed project on approval.""" + + __tablename__ = "research_projects" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + owner_user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'draft'")) + code: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + title: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + level: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + pi_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + period_months: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + budget_total: Mapped[Optional[float]] = mapped_column(Numeric(14, 2), nullable=True) + content: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) + submitted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + reviewed_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + review_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ResearchProjectMember(Base): + __tablename__ = "research_project_members" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("research_projects.id", ondelete="CASCADE"), nullable=False + ) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + role: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + access: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + org: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + email: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + months: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + tasks: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ResearchProjectDataset(Base): + __tablename__ = "research_project_datasets" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("research_projects.id", ondelete="CASCADE"), nullable=False + ) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + type: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + records: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + source: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + sensitivity: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + ethics: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + owner: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ResearchProjectModel(Base): + __tablename__ = "research_project_models" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("research_projects.id", ondelete="CASCADE"), nullable=False + ) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + task: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + framework: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + version: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + dataset: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + auc: Mapped[Optional[float]] = mapped_column(Numeric(6, 4), nullable=True) + sensitivity: Mapped[Optional[float]] = mapped_column(Numeric(6, 4), nullable=True) + specificity: Mapped[Optional[float]] = mapped_column(Numeric(6, 4), nullable=True) + accuracy: Mapped[Optional[float]] = mapped_column(Numeric(6, 4), nullable=True) + owner: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + notes: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ResearchProjectAsset(Base): + __tablename__ = "research_project_assets" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("research_projects.id", ondelete="CASCADE"), nullable=False + ) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + category: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + acquisition: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + value: Mapped[Optional[float]] = mapped_column(Numeric(14, 2), nullable=True) + owner: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + notes: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ResearchProjectMilestone(Base): + __tablename__ = "research_project_milestones" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("research_projects.id", ondelete="CASCADE"), nullable=False + ) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + title: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + deliverable: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + start_period: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + end_period: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + owner: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + budget: Mapped[Optional[float]] = mapped_column(Numeric(14, 2), nullable=True) + progress: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ResearchProjectAudit(Base): + """Append-only audit trail for a research project (lifecycle + entity CRUD).""" + + __tablename__ = "research_project_audit" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("research_projects.id", ondelete="CASCADE"), nullable=False + ) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=text("now()"), nullable=False + ) + actor_user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + actor_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + role_label: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + action: Mapped[str] = mapped_column(Text, nullable=False) + subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + + +# --------------------------------------------------------------------------- # +# ImageHub: content-addressed imaging dataset versioning — migration 017. +# A dataset is owned by a user; files dedupe into imagehub_blobs by sha256; an +# imagehub_version freezes a manifest snapshot of the working files. Owner+admin authz (v1). +# --------------------------------------------------------------------------- # +class ImagehubDataset(Base): + """An ImageHub dataset (a versioned collection of imaging files).""" + + __tablename__ = "imagehub_datasets" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + owner_user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + slug: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + description: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + visibility: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'private'")) + modality_tags: Mapped[Any] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb")) + # Per-dataset value->name label map for multi-label masks (migration 027), e.g. {"1":"kidney"}. + label_map: Mapped[Any] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) + default_branch: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'main'")) + # The research project (the "workspace") this dataset belongs to, if any (migration 024). + research_project_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("research_projects.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ImagehubBlob(Base): + """Globally content-addressed blob registry (dedup by sha256).""" + + __tablename__ = "imagehub_blobs" + + sha256: Mapped[str] = mapped_column(Text, primary_key=True) + size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default="0") + media_type: Mapped[str] = mapped_column( + Text, nullable=False, server_default=text("'application/octet-stream'") + ) + storage_bucket: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + storage_key: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ImagehubStorageMethod(Base): + """A verified external storage method (S3/GCS/Azure) for Cloud Import. Credentials live + encrypted in config_encrypted and are NEVER returned to the client (privacy rule SM7).""" + + __tablename__ = "imagehub_storage_methods" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + provider: Mapped[str] = mapped_column(Text, nullable=False) + access_mode: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'read'")) + bucket: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + region: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + config_encrypted: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + verification_status: Mapped[str] = mapped_column( + Text, nullable=False, server_default=text("'pending'") + ) + verification_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + verification_checked_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ImagehubDatasetFile(Base): + """Current working file set on a dataset default branch (one row per folder_path + logical_path).""" + + __tablename__ = "imagehub_dataset_files" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + dataset_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_datasets.id", ondelete="CASCADE"), nullable=False + ) + logical_path: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + # Folder path (migration 026): the relative directory of the file inside the dataset so an + # uploaded tree (e.g. nnU-Net imagesTr/labelsTr) is preserved. '' = dataset root. + folder_path: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + blob_sha256: Mapped[Optional[str]] = mapped_column( + Text, ForeignKey("imagehub_blobs.sha256", ondelete="RESTRICT"), nullable=True + ) + size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default="0") + media_type: Mapped[str] = mapped_column( + Text, nullable=False, server_default=text("'application/octet-stream'") + ) + imaging_meta: Mapped[Any] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) + # Segmentation linking (migration 018): a mask row (file_kind='segmentation') points at the + # image it segments via parent_file_id; organ_label names the organ. Regular files are 'image'. + file_kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'image'")) + parent_file_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("imagehub_dataset_files.id", ondelete="CASCADE"), + nullable=True, + ) + organ_label: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + # Cloud Import (migration 019): a file is EITHER a local content-addressed blob (blob_sha256) + # OR an external reference (storage_method_id + external_path) that streams from a verified + # storage method and is never copied to our servers (privacy rule C4). + storage_method_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("imagehub_storage_methods.id", ondelete="RESTRICT"), + nullable=True, + ) + external_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + uploaded_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ImagehubVersion(Base): + """A frozen manifest snapshot of a dataset working files (DAG-ready via parent_version_id).""" + + __tablename__ = "imagehub_versions" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + dataset_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_datasets.id", ondelete="CASCADE"), nullable=False + ) + seq: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1") + message: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + manifest: Mapped[Any] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb")) + parent_version_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_versions.id", ondelete="SET NULL"), nullable=True + ) + author_user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ImagehubDatasetStage(Base): + """A labeling-pipeline stage on a dataset (Label -> Review_1 -> Review_2 ...). `auto_assign` + is the 'Automatic Task Assignment' toggle; `review_percent` applies to review stages.""" + + __tablename__ = "imagehub_dataset_stages" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + dataset_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_datasets.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'label'")) + seq: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + review_percent: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + auto_assign: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ImagehubDatasetAudit(Base): + """Append-only audit trail for an ImageHub dataset.""" + + __tablename__ = "imagehub_dataset_audit" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + dataset_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_datasets.id", ondelete="CASCADE"), nullable=False + ) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=text("now()"), nullable=False + ) + actor_user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + actor_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + role_label: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + action: Mapped[str] = mapped_column(Text, nullable=False) + subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + + +class ImagehubTask(Base): + """A unit of labeling work: one dataset file moving through the dataset's pipeline stages. + + A NEW join row (not the file mutated in place): it carries the file's pipeline position + (``current_stage_id`` + ``pipeline_state``), the per-user queue ``queue_status``, the + ``assignee``, a ``priority`` float (0..1), and the Ground-Truth ``is_reference_standard`` flag. + Single-user MVP: task access reuses the dataset owner-or-admin gate; multi-labeler membership is + a later phase. ``UNIQUE(dataset_file_id)`` enforces one task per file (droppable later). + """ + + __tablename__ = "imagehub_tasks" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + dataset_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_datasets.id", ondelete="CASCADE"), nullable=False + ) + dataset_file_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_dataset_files.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + current_stage_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_dataset_stages.id", ondelete="SET NULL"), nullable=True + ) + # §3 pipeline state: inLabel -> inReview -> groundTruth (terminal); issue diverts (later phase). + pipeline_state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'inLabel'")) + # §4 per-user queue status within a stage. + queue_status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'assigned'")) + assignee_user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + # A2 manual assignment is sticky (never auto-reassigned); 'auto' is first-come-first-serve. + assignment_mode: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'auto'")) + priority: Mapped[float] = mapped_column(Float, nullable=False, server_default=text("0")) + is_reference_standard: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) + # The labeler's vector annotations (bbox/points/pen/brush/polygon), normalized [0..1] geometry + # per slice — persisted as JSON so the AnnotationTool can load + save a task's work (migr 022). + annotations: Mapped[Any] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb")) + # Q2: a skipped task goes to the end of the queue, ordered by this monotonic seq. + skipped_seq: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ImagehubDatasetMember(Base): + """A user (other than the owner) granted access to a dataset's labeling work (multi-labeler). + + MVP: all members are labelers — they view the dataset and work tasks assigned to them; dataset / + stage / settings management stays with the owner + platform admins. ``role`` is reserved for a + future project-admin tier. UNIQUE(dataset_id, user_id): one membership per user per dataset. + """ + + __tablename__ = "imagehub_dataset_members" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + dataset_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_datasets.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + role: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'member'")) + added_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) + + +class ImagehubTaskReviewEvent(Base): + """An append-only record of a task review decision (accept / acceptWithCorrections / reject), + capturing the reviewer, the stage reviewed, and an optional note (e.g. a reject reason). + Powers review history + per-reviewer accept/reject counters (migration 025).""" + + __tablename__ = "imagehub_task_review_events" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + dataset_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_datasets.id", ondelete="CASCADE"), nullable=False + ) + task_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_tasks.id", ondelete="CASCADE"), nullable=False + ) + stage_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("imagehub_dataset_stages.id", ondelete="SET NULL"), nullable=True + ) + reviewer_user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + decision: Mapped[str] = mapped_column(Text, nullable=False) + note: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()")) diff --git a/be0/src/initiative_db/repair_split_submission.py b/be0/src/initiative_db/repair_split_submission.py new file mode 100644 index 0000000..815ba93 --- /dev/null +++ b/be0/src/initiative_db/repair_split_submission.py @@ -0,0 +1,315 @@ +""" +One-off repair: merge a mis-linked submission (saved under DR…/wrong initiative) onto the autosave CASE-* initiative. + +Safe by default (`dry_run=True`). Does not alter application HTTP handlers — callers are scripts/operators only. + +See `scripts/repair_split_submission.py`. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from sqlalchemy import delete, select, text, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.initiative_db.application_storage import upsert_artifact_full_pdf +from src.initiative_db.models import ApplicationArtifact +from src.initiative_db.models import ApplicationReviewDocument +from src.initiative_db.models import ApplicationSubmitSnapshot +from src.initiative_db.models import ApplicationTaxonomy +from src.initiative_db.models import ApplicationWorkflow +from src.initiative_db.models import Draft, Initiative + + +def tabs_effectively_empty(tabs: Any) -> bool: + if not isinstance(tabs, dict) or len(tabs) == 0: + return True + for key in ("report", "application", "contribution"): + val = tabs.get(key) + if isinstance(val, dict) and len(val) > 0: + return False + if val: + return False + return True + + +def merge_payload_for_case_repair( + *, + target_case_code: str, + good_payload: Dict[str, Any], + bad_payload: Dict[str, Any], +) -> Dict[str, Any]: + """ + Preserve tab JSON from whichever side still has autosave (`good` wins if non-empty), + attach submission envelope from `bad` (submissionRecord, submissionFile). + """ + out = dict(good_payload) if isinstance(good_payload, dict) else {} + gp = dict(good_payload.get("tabs") or {}) if isinstance(good_payload, dict) else {} + bp = dict(bad_payload.get("tabs") or {}) if isinstance(bad_payload, dict) else {} + + if not tabs_effectively_empty(gp): + merged_tabs = {**gp} + elif not tabs_effectively_empty(bp): + merged_tabs = {**bp} + else: + merged_tabs = {**gp, **bp} + + out["tabs"] = merged_tabs + out["caseId"] = target_case_code.strip() + if isinstance(bad_payload, dict): + if isinstance(bad_payload.get("submissionRecord"), dict): + out["submissionRecord"] = dict(bad_payload["submissionRecord"]) # type: ignore[arg-type] + if isinstance(bad_payload.get("submissionFile"), dict): + out["submissionFile"] = dict(bad_payload["submissionFile"]) # type: ignore[arg-type] + + ts = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + out["updatedAt"] = ts + return out + + +@dataclass +class RepairReport: + """Human-readable audit trail for CLI / logs.""" + + dry_run: bool + submission_record_id: str + owner_id: str + good_case_code: str + bad_case_code: str + actions: list[str] = field(default_factory=list) + skipped: Optional[str] = None + + +async def _latest_draft(session: AsyncSession, initiative_id: uuid.UUID) -> Optional[Draft]: + stmt = ( + select(Draft) + .where(Draft.initiative_id == initiative_id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def find_initiative_by_submission_payload_id(session: AsyncSession, submission_record_id: str) -> tuple[Optional[Initiative], Optional[Draft]]: + """Locate initiative+draft whose `payload.submissionRecord.id` matches.""" + sid = (submission_record_id or "").strip() + if not sid or not sid.startswith("sub-"): + return None, None + + # PostgreSQL JSONB path (portable across SQLAlchemy JSON variants) + stmt = ( + select(Initiative, Draft) + .join(Draft, Draft.initiative_id == Initiative.id) + .where(text("(drafts.payload->'submissionRecord'->>'id') = :sid")) + .params(sid=sid) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + row = (await session.execute(stmt)).first() + if row is None: + return None, None + return row[0], row[1] + + +async def resolve_good_initiative( + session: AsyncSession, + *, + owner_id: uuid.UUID, + exclude_initiative_ids: tuple[uuid.UUID, ...], + explicit_good_case_code: Optional[str], +) -> Optional[Initiative]: + """Prefer explicit CASE-* code; otherwise latest CASE-* initiative for this owner (with non-empty drafts preferred).""" + from sqlalchemy import desc + + if explicit_good_case_code: + stmt = ( + select(Initiative) + .where( + Initiative.owner_id == owner_id, + Initiative.case_code == explicit_good_case_code.strip(), + ) + .limit(1) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + stmt_all = ( + select(Initiative) + .where( + Initiative.owner_id == owner_id, + Initiative.id.notin(exclude_initiative_ids), + Initiative.case_code.ilike(r"CASE-%"), + ) + .order_by(desc(Initiative.updated_at)) + ) + initiatives = (await session.execute(stmt_all)).scalars().all() + nonempty_first: Optional[Initiative] = None + for ini in initiatives: + d = await _latest_draft(session, ini.id) + if d is None: + continue + pl = dict(d.payload) if isinstance(d.payload, dict) else {} + if not tabs_effectively_empty(pl.get("tabs")): + nonempty_first = ini + break + if nonempty_first is not None: + return nonempty_first + return initiatives[0] if initiatives else None + + +async def repair_submission_cross_initiative_merge( + session: AsyncSession, + *, + submission_record_id: str, + good_case_code_explicit: Optional[str] = None, + dry_run: bool = True, +) -> RepairReport: + """ + Attach submission data sitting on « bad » initiative to « good » CASE-* initiative, then delete the bad initiative. + + Preconditions (enforced): same owner_id; submission id found on bad initiative only. + + Caller must ``commit()`` the session unless dry_run. + """ + sub_id = submission_record_id.strip() + report = RepairReport( + dry_run=dry_run, + submission_record_id=sub_id, + owner_id="", + good_case_code="", + bad_case_code="", + actions=["resolve bad initiative row by submissionRecord.id"], + ) + + bad_ini, bad_draft = await find_initiative_by_submission_payload_id(session, sub_id) + if bad_ini is None or bad_draft is None: + report.skipped = f"No initiative found with drafts.payload submissionRecord.id = {sub_id!r}" + return report + + report.bad_case_code = bad_ini.case_code or "" + report.owner_id = str(bad_ini.owner_id) + + good_ini = await resolve_good_initiative( + session, + owner_id=bad_ini.owner_id, + exclude_initiative_ids=(bad_ini.id,), + explicit_good_case_code=good_case_code_explicit, + ) + if good_ini is None: + report.skipped = "Could not resolve a target CASE-* initiative for the same owner (pass --good-case explicitly)." + return report + + if good_ini.id == bad_ini.id: + report.skipped = "Bad and good initiative are identical — nothing to repair." + return report + + report.good_case_code = good_ini.case_code or "" + + good_draft = await _latest_draft(session, good_ini.id) + if good_draft is None: + report.skipped = "Target CASE initiative has no draft row — fix data manually." + return report + + good_pl = dict(good_draft.payload) if isinstance(good_draft.payload, dict) else {} + bad_pl = dict(bad_draft.payload) if isinstance(bad_draft.payload, dict) else {} + merged = merge_payload_for_case_repair( + target_case_code=good_ini.case_code or "", + good_payload=good_pl, + bad_payload=bad_pl, + ) + + if dry_run: + report.actions.extend( + [ + f"Would update good draft id={good_draft.id} with merged tabs + submission payload", + f"Would set good initiative status=submitted submitted_at=max(bad, good)", + f"Would repoint application_submit_snapshots from bad initiative {bad_ini.id} -> good {good_ini.id}", + f"Would upsert full_pdf artifact from bad -> good", + "Would copy application_workflow / application_taxonomy rows from bad only if missing on good", + "Would delete application_review_documents on bad (good keeps its own review JSON)", + f"Would DELETE initiative id={bad_ini.id} ({report.bad_case_code}); CASCADE removes orphan drafts and rows tied to bad only", + "WARN: if evidence uploads were stored only on bad initiative, MinIO metadata rows would cascade-delete — verify in DB first", + ] + ) + return report + + # --- apply --- + good_draft.payload = merged + good_draft.version = (good_draft.version or 0) + 1 + good_ini.status = "submitted" + if bad_ini.submitted_at and (good_ini.submitted_at is None or bad_ini.submitted_at > good_ini.submitted_at): + good_ini.submitted_at = bad_ini.submitted_at + elif good_ini.submitted_at is None and bad_ini.submitted_at: + good_ini.submitted_at = bad_ini.submitted_at + elif good_ini.submitted_at is None: + good_ini.submitted_at = datetime.now(timezone.utc) + + await session.flush() + + await session.execute( + update(ApplicationSubmitSnapshot) + .where(ApplicationSubmitSnapshot.initiative_id == bad_ini.id) + .values(initiative_id=good_ini.id) + ) + + bad_pdf = ( + await session.execute( + select(ApplicationArtifact).where( + ApplicationArtifact.initiative_id == bad_ini.id, + ApplicationArtifact.role == "full_pdf", + ) + ) + ).scalar_one_or_none() + if bad_pdf is not None: + await upsert_artifact_full_pdf( + session, + initiative_id=good_ini.id, + storage_uri=bad_pdf.storage_uri, + original_name=bad_pdf.original_name, + byte_size=bad_pdf.byte_size, + sha256_hex=bad_pdf.sha256, + uploaded_by=bad_pdf.uploaded_by, + storage_kind=getattr(bad_pdf, "storage_kind", None), + ) + + bad_wf = await session.get(ApplicationWorkflow, bad_ini.id) + good_wf = await session.get(ApplicationWorkflow, good_ini.id) + if bad_wf is not None and good_wf is None: + session.add( + ApplicationWorkflow( + initiative_id=good_ini.id, + review_status=bad_wf.review_status, + review_deadline=bad_wf.review_deadline, + reviewer=bad_wf.reviewer, + supervisor=bad_wf.supervisor, + conference=bad_wf.conference, + ) + ) + bad_tx = await session.get(ApplicationTaxonomy, bad_ini.id) + good_tx = await session.get(ApplicationTaxonomy, good_ini.id) + if bad_tx is not None and good_tx is None: + session.add( + ApplicationTaxonomy( + initiative_id=good_ini.id, + subject_id=bad_tx.subject_id, + group_id=bad_tx.group_id, + topic_type=bad_tx.topic_type, + ) + ) + + await session.execute(delete(ApplicationReviewDocument).where(ApplicationReviewDocument.initiative_id == bad_ini.id)) + + await session.delete(bad_ini) + await session.flush() + + report.actions.extend( + [ + f"Updated good draft {good_draft.id}, initiative {good_ini.case_code}", + f"Repointed submit snapshots; copied full_pdf artifact; cleared review docs on bad", + f"Deleted bad initiative {report.bad_case_code}", + ] + ) + return report diff --git a/be0/src/initiative_db/submission_readiness.py b/be0/src/initiative_db/submission_readiness.py new file mode 100644 index 0000000..7f5fafe --- /dev/null +++ b/be0/src/initiative_db/submission_readiness.py @@ -0,0 +1,398 @@ +"""Server-side readiness checks before final PDF submit (aligned with fe0 applicantHonestyPrerequisites).""" + +from __future__ import annotations + +import re +import uuid +from datetime import date +from typing import Any, Dict, List, Mapping, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.initiative_db.application_storage import ( + EVIDENCE_ROLE_RESEARCH, + EVIDENCE_ROLE_TEXTBOOK, + EVIDENCE_ROLE_TECHNICAL, +) +from src.initiative_db.models import ApplicationArtifact + +FIRST_APPLY_MIN = date(2025, 4, 15) +FIRST_APPLY_MAX = date(2026, 4, 15) + + +class ApplicationSubmissionNotReadyError(Exception): + """Tabs JSON or MinIO evidence registry does not satisfy submit rules.""" + + def __init__(self, missing: List[str]) -> None: + self.missing = list(missing) + msg = "; ".join(self.missing[:10]) + if len(self.missing) > 10: + msg += " …" + super().__init__(msg) + + +def _txt(value: Any) -> str: + return str(value or "").strip() + + +def _truthy(value: Any) -> bool: + if value is True: + return True + if isinstance(value, str) and value.strip().lower() in ("true", "1", "yes"): + return True + return False + + +def _parse_dd_mm_yyyy(value: str) -> Optional[date]: + m = re.fullmatch(r"(\d{2})/(\d{2})/(\d{4})", value.strip()) + if not m: + return None + d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3)) + try: + return date(y, mo, d) + except ValueError: + return None + + +def _eff(report: Mapping[str, Any], key: str) -> str: + eff = report.get("effectiveness") + if not isinstance(eff, dict): + return "" + return _txt(eff.get(key)) + + +def _json_evidence_pdf_present(slot: Any) -> bool: + if slot is None: + return False + if isinstance(slot, dict): + if _txt(slot.get("serverStorageKey")): + return True + try: + if int(slot.get("size") or 0) > 0: + return True + except (TypeError, ValueError): + pass + return False + + +def _digits_cccd(s: str) -> str: + return "".join(c for c in s if c.isdigit()) + + +def _ban_cam_ket_complete(b: Any) -> bool: + if not isinstance(b, dict): + return False + nk = b.get("ngay_ky") if isinstance(b.get("ngay_ky"), dict) else {} + if not _txt(b.get("tac_gia_dang_ky")) or not _txt(b.get("don_vi")): + return False + if not _txt(b.get("ten_bai_bao")) or not _txt(b.get("nguoi_cam_ket")): + return False + cc = _digits_cccd(_txt(b.get("cccd"))) + if len(cc) < 8 or len(cc) > 12: + return False + nx = _txt(b.get("nam_xet")) + if not re.fullmatch(r"\d{4}", nx): + return False + vt = b.get("vai_tro") if isinstance(b.get("vai_tro"), dict) else {} + tc = _truthy(vt.get("tac_gia_chinh")) + dt = _truthy(vt.get("dong_tac_gia")) + if int(tc) + int(dt) != 1: + return False + ck = b.get("cam_ket") if isinstance(b.get("cam_ket"), dict) else {} + if not _truthy(ck.get("quyen_so_huu_1")) and not _truthy(ck.get("quyen_so_huu_2")): + return False + if not ( + _truthy(ck.get("dong_thuan")) + and _truthy(ck.get("bai_bao_uy_tin")) + and _truthy(ck.get("tuan_thu_phap_luat")) + ): + return False + ng = _txt(nk.get("ngay")) + th = _txt(nk.get("thang")) + nam = _txt(nk.get("nam")) + if not ng or not th or not re.fullmatch(r"\d{4}", nam): + return False + return True + + +def _reference_material_honesty_complete(h: Any) -> bool: + if not isinstance(h, dict): + return False + nk = h.get("ngay_ky") if isinstance(h.get("ngay_ky"), dict) else {} + if ( + not _txt(h.get("tac_gia_dang_ky")) + or not _txt(h.get("don_vi")) + or not _txt(h.get("ten_tai_lieu")) + or not _txt(h.get("nguoi_cam_ket")) + ): + return False + cc = _digits_cccd(_txt(h.get("cccd"))) + if len(cc) < 8 or len(cc) > 12: + return False + nx = _txt(h.get("nam_xet")) + if not re.fullmatch(r"\d{4}", nx): + return False + ck = h.get("cam_ket") if isinstance(h.get("cam_ket"), dict) else {} + if not ( + _truthy(ck.get("thong_tin_trung_thuc")) + and _truthy(ck.get("trach_nhiem_phap_luat")) + and _truthy(ck.get("bo_sung_khi_yeu_cau")) + ): + return False + ng = _txt(nk.get("ngay")) + th = _txt(nk.get("thang")) + nam = _txt(nk.get("nam")) + if not ng or not th or not re.fullmatch(r"\d{4}", nam): + return False + return True + + +def _research_domestic_honesty_complete(h: Any) -> bool: + if not isinstance(h, dict): + return False + nk = h.get("ngay_ky") if isinstance(h.get("ngay_ky"), dict) else {} + if ( + not _txt(h.get("tac_gia_dang_ky")) + or not _txt(h.get("don_vi")) + or not _txt(h.get("ten_bai_bao")) + or not _txt(h.get("nguoi_cam_ket")) + ): + return False + cc = _digits_cccd(_txt(h.get("cccd"))) + if len(cc) < 8 or len(cc) > 12: + return False + nx = _txt(h.get("nam_xet")) + if not re.fullmatch(r"\d{4}", nx): + return False + ck = h.get("cam_ket") if isinstance(h.get("cam_ket"), dict) else {} + if not ( + _truthy(ck.get("thong_tin_trung_thuc")) + and _truthy(ck.get("trach_nhiem_phap_luat")) + and _truthy(ck.get("bo_sung_khi_yeu_cau")) + ): + return False + ng = _txt(nk.get("ngay")) + th = _txt(nk.get("thang")) + nam = _txt(nk.get("nam")) + if not ng or not th or not re.fullmatch(r"\d{4}", nam): + return False + return True + + +def _push(ok: bool, gaps: List[str], msg: str) -> None: + if not ok: + gaps.append(msg) + + +def collect_report_tab_gaps(r: Mapping[str, Any]) -> List[str]: + gaps: List[str] = [] + _push(bool(_txt(r.get("introduction"))), gaps, "Báo cáo: Mở đầu (§1)") + _push(bool(_txt(r.get("initiativeName"))), gaps, "Báo cáo: Tên sáng kiến") + _push(bool(_txt(r.get("representativeAuthor"))), gaps, "Báo cáo: Tác giả đại diện") + _push(bool(_txt(r.get("representativePhone"))), gaps, "Báo cáo: Điện thoại") + _push(bool(_txt(r.get("representativeEmail"))), gaps, "Báo cáo: Email") + _push(bool(_txt(r.get("applicationField"))), gaps, "Báo cáo: Lĩnh vực áp dụng") + _push(bool(_txt(r.get("currentStatus"))), gaps, "Báo cáo: Hiện trạng / giải pháp đã biết (§4.1)") + _push(bool(_txt(r.get("purpose"))), gaps, "Báo cáo: Mục đích (§4.2)") + _push(bool(_txt(r.get("implementationSteps"))), gaps, "Báo cáo: Các bước thực hiện") + _push(bool(_txt(r.get("firstAppliedUnit"))), gaps, "Báo cáo: Đơn vị áp dụng lần đầu") + _push(bool(_txt(r.get("achievedResult"))), gaps, "Báo cáo: Kết quả thu được") + _push(bool(_txt(r.get("conditions"))), gaps, "Báo cáo: Điều kiện áp dụng") + _push(bool(_txt(r.get("novelty"))), gaps, "Báo cáo: Tính mới của sáng kiến") + _push(bool(_eff(r, "economic")), gaps, "Báo cáo: Hiệu quả kinh tế") + _push(bool(_eff(r, "teaching")), gaps, "Báo cáo: Hiệu quả công việc / giảng dạy") + _push(bool(_eff(r, "safety")), gaps, "Báo cáo: Môi trường & an toàn") + _push(bool(_eff(r, "social")), gaps, "Báo cáo: Nhận thức & xã hội") + return gaps + + +def _collect_author_gaps(authors: Any) -> List[str]: + gaps: List[str] = [] + if not isinstance(authors, list) or len(authors) == 0: + gaps.append("Đơn: thêm ít nhất một tác giả.") + return gaps + for i, a in enumerate(authors): + if not isinstance(a, dict): + gaps.append(f"Đơn — tác giả {i + 1}: dữ liệu không hợp lệ") + continue + p = f"Đơn — tác giả {i + 1}" + _push(bool(_txt(a.get("name"))), gaps, f"{p}: họ tên") + _push(bool(_txt(a.get("dob"))), gaps, f"{p}: ngày sinh") + _push(bool(_txt(a.get("workplace"))), gaps, f"{p}: nơi công tác") + _push(bool(_txt(a.get("title"))), gaps, f"{p}: chức danh") + _push(bool(_txt(a.get("qualification"))), gaps, f"{p}: trình độ") + total = 0 + for a in authors: + if isinstance(a, dict): + try: + total += int(float(a.get("contributionPercent") or 0)) + except (TypeError, ValueError): + pass + _push(total == 100, gaps, "Đơn: tổng % đóng góp của các tác giả phải bằng 100%.") + return gaps + + +def _classification_pdf_ok( + classification: str, + application: Mapping[str, Any], + evidence_flags: Mapping[str, bool], +) -> tuple[bool, List[str]]: + """Returns (all_ok, gap_messages).""" + gaps: List[str] = [] + + def research_ok() -> bool: + return bool(evidence_flags.get("research")) or _json_evidence_pdf_present(application.get("researchEvidenceFile")) + + def textbook_ok() -> bool: + return bool(evidence_flags.get("textbook")) or _json_evidence_pdf_present(application.get("textbookEvidenceFile")) + + def technical_ok() -> bool: + return bool(evidence_flags.get("technical")) or _json_evidence_pdf_present(application.get("technicalEvidenceFile")) + + if classification == "technical": + _push(technical_ok(), gaps, "Đơn (Nhóm 1): cần tệp PDF minh chứng kỹ thuật / văn bản đơn vị (đã tải lên máy chủ).") + return (len(gaps) == 0, gaps) + + if classification == "textbook": + kind = _txt(application.get("textbookEvidenceKind")) + _push(bool(kind), gaps, "Đơn (Nhóm 2.2): chọn loại minh chứng (xuất sắc / tài liệu tham khảo).") + _push(textbook_ok(), gaps, "Đơn (Nhóm 2.2): cần tệp PDF minh chứng (Quyết định xuất bản…).") + if kind == "book": + _push(_ban_cam_ket_complete(application.get("banCamKet")), gaps, "Đơn (Nhóm 2.2 — sách/giáo trình): hoàn thành bản cam kết tác giả.") + if kind == "reference": + _push( + _reference_material_honesty_complete(application.get("referenceMaterialHonesty")), + gaps, + "Đơn (Nhóm 2.2 — tài liệu tham khảo): hoàn thành biểu xác minh trung thực.", + ) + return (len(gaps) == 0, gaps) + + if classification == "research": + rek = _txt(application.get("researchEvidenceKind")) + _push(bool(rek), gaps, "Đơn (Nhóm 2.1): chọn loại minh chứng (tạp chí / poster…).") + _push(research_ok(), gaps, "Đơn (Nhóm 2.1): cần tệp PDF minh chứng bài báo / poster.") + if rek == "international": + ban_ok = _ban_cam_ket_complete(application.get("banCamKet")) + legacy = bool(_txt(application.get("internationalJournalDeclaration"))) + _push(ban_ok or legacy, gaps, "Đơn (Nhóm 2.1 — quốc tế): hoàn thành bản cam kết tác giả hoặc tuyên bố tạp chí.") + if rek == "domestic": + ban_ok_dom = _ban_cam_ket_complete(application.get("banCamKet")) + _push( + ban_ok_dom + or _research_domestic_honesty_complete(application.get("researchDomesticHonesty")), + gaps, + "Đơn (Nhóm 2.1 — trong nước): hoàn thành biểu xác nhận bài báo trong nước.", + ) + return (len(gaps) == 0, gaps) + + return (True, []) + + +def collect_application_tab_gaps( + application: Mapping[str, Any], + evidence_flags: Mapping[str, bool], +) -> List[str]: + gaps: List[str] = [] + unit_ok = bool(_txt(application.get("unitName"))) + if not unit_ok: + authors0 = application.get("authors") + if isinstance(authors0, list) and len(authors0) > 0 and isinstance(authors0[0], dict): + unit_ok = bool(_txt(authors0[0].get("workplace"))) + _push(unit_ok, gaps, "Đơn: Tên đơn vị") + gaps.extend(_collect_author_gaps(application.get("authors"))) + _push(bool(_txt(application.get("initiativeName"))), gaps, "Đơn: Tên sáng kiến") + _push(bool(_txt(application.get("investorName"))), gaps, "Đơn: Chủ đầu tư") + _push(bool(_txt(application.get("applicationField"))), gaps, "Đơn: Lĩnh vực áp dụng") + fad_raw = _txt(application.get("firstApplyDate")) + fad = _parse_dd_mm_yyyy(fad_raw) if fad_raw else None + _push(fad is not None, gaps, "Đơn: Ngày áp dụng lần đầu (định dạng dd/mm/yyyy)") + if fad is not None and (fad < FIRST_APPLY_MIN or fad > FIRST_APPLY_MAX): + gaps.append("Đơn: Ngày áp dụng lần đầu phải từ 15/04/2025 đến 15/04/2026.") + + ic = application.get("initiativeClassification") + ic_str = _txt(ic) if ic is not None else "" + _push(ic_str != "", gaps, "Đơn: Phân loại sáng kiến (Nhóm 1 / 2.1 / 2.2)") + if ic_str: + _, cgaps = _classification_pdf_ok(ic_str, application, evidence_flags) + gaps.extend(cgaps) + + _push(bool(_txt(application.get("contentSummary"))), gaps, "Đơn: Nội dung của sáng kiến (§4)") + _push(bool(_txt(application.get("conditions"))), gaps, "Đơn: Điều kiện áp dụng") + _push(bool(_txt(application.get("authorEvaluation"))), gaps, "Đơn: Đánh giá lợi ích (tác giả)") + _push(bool(_txt(application.get("trialEvaluation"))), gaps, "Đơn: Đánh giá (đơn vị áp dụng thử)") + + ss = application.get("supportStaff") + if isinstance(ss, list) and len(ss) > 0: + for i, row in enumerate(ss): + if not isinstance(row, dict): + gaps.append(f"Đơn — người hỗ trợ {i + 1}: dữ liệu không hợp lệ") + continue + p = f"Đơn — người hỗ trợ {i + 1}" + _push(bool(_txt(row.get("name"))), gaps, f"{p}: họ tên") + _push(bool(_txt(row.get("dob"))), gaps, f"{p}: ngày sinh") + _push(bool(_txt(row.get("workplace"))), gaps, f"{p}: nơi công tác") + _push(bool(_txt(row.get("title"))), gaps, f"{p}: chức danh") + _push(bool(_txt(row.get("qualification"))), gaps, f"{p}: trình độ") + _push(bool(_txt(row.get("supportContent"))), gaps, f"{p}: nội dung hỗ trợ") + + sd = application.get("submissionDay") + sm = application.get("submissionMonth") + _push(sd is not None and _txt(sd) != "", gaps, "Đơn: Ngày ký (ngày)") + _push(sm is not None and _txt(sm) != "", gaps, "Đơn: Ngày ký (tháng)") + _push(bool(_txt(application.get("submissionYear"))), gaps, "Đơn: Ngày ký (năm)") + return gaps + + +def collect_submission_readiness_gaps( + tabs: Mapping[str, Any], + evidence_flags: Mapping[str, bool], +) -> List[str]: + """Validate merged draft tabs + Postgres/MinIO evidence registry.""" + gaps: List[str] = [] + raw_r = tabs.get("report") + raw_a = tabs.get("application") + raw_c = tabs.get("contribution") + if not isinstance(raw_r, dict) or not isinstance(raw_a, dict) or not isinstance(raw_c, dict): + gaps.append("Bản nháp thiếu dữ liệu một hoặc nhiều tab (báo cáo / đơn / xác nhận đóng góp).") + + report = raw_r if isinstance(raw_r, dict) else {} + application = raw_a if isinstance(raw_a, dict) else {} + contribution = raw_c if isinstance(raw_c, dict) else {} + + gaps.extend(collect_report_tab_gaps(report)) + gaps.extend(collect_application_tab_gaps(application, evidence_flags)) + + _push(_truthy(report.get("honestyConfirmed")), gaps, "Báo cáo: cần tick ô cam kết trung thực ở cuối tab Báo cáo.") + _push(_truthy(application.get("honestyConfirmed")), gaps, "Đơn: cần tick ô cam kết trung thực ở cuối tab Đơn.") + _push( + _truthy(contribution.get("digitalSignatureConfirmed")), + gaps, + "Xác nhận đóng góp: cần tick ô cam kết trung thực ở tab Xác nhận tỷ lệ đóng góp trước khi gửi.", + ) + return gaps + + +async def fetch_evidence_presence_flags(session: AsyncSession, initiative_id: uuid.UUID) -> Dict[str, bool]: + stmt = select(ApplicationArtifact.role, ApplicationArtifact.storage_uri).where( + ApplicationArtifact.initiative_id == initiative_id, + ApplicationArtifact.role.in_( + ( + EVIDENCE_ROLE_RESEARCH, + EVIDENCE_ROLE_TEXTBOOK, + EVIDENCE_ROLE_TECHNICAL, + ) + ), + ) + rows = (await session.execute(stmt)).all() + out = {"research": False, "textbook": False, "technical": False} + for role, uri in rows: + if not _txt(uri): + continue + if role == EVIDENCE_ROLE_RESEARCH: + out["research"] = True + elif role == EVIDENCE_ROLE_TEXTBOOK: + out["textbook"] = True + elif role == EVIDENCE_ROLE_TECHNICAL: + out["technical"] = True + return out diff --git a/be0/src/initiative_db/submissions.py b/be0/src/initiative_db/submissions.py new file mode 100644 index 0000000..78daa99 --- /dev/null +++ b/be0/src/initiative_db/submissions.py @@ -0,0 +1,1359 @@ +"""Persist and query submitted applications in PostgreSQL-backed initiative tables.""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import datetime, timezone +from io import BytesIO +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.initiative_db.application_storage import ( + ROLE_OFFICIAL_FORM_DOCX, + ROLE_OFFICIAL_FORM_PDF, + STORAGE_FILESYSTEM, + STORAGE_MINIO_EXPORTS, + record_submit_snapshot, + upsert_application_taxonomy, + upsert_application_workflow, + upsert_artifact_full_pdf, + upsert_artifact_official_form, +) +from src.initiative_db.drafts import SYSTEM_DRAFT_OWNER_ID +from src.initiative_db.models import ApplicationAdminResult +from src.initiative_db.models import ApplicationReviewDocument +from src.initiative_db.models import ApplicationSubmitSnapshot +from src.initiative_db.models import Draft, Initiative, User +from src.initiative_db.submission_readiness import ( + ApplicationSubmissionNotReadyError, + collect_submission_readiness_gaps, + fetch_evidence_presence_flags, +) + +logger = logging.getLogger(__name__) + + +class ApplicationSubmitPersistError(Exception): + """Canonical storage (MinIO / printable forms) failed during submit — do not silently fall back.""" + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _iso_utc(dt: Optional[datetime]) -> str: + if dt is None: + return "" + value = dt.astimezone(timezone.utc).replace(microsecond=0).isoformat() + return value.replace("+00:00", "Z") + + +def _normalize_case_id(case_id: Optional[str], fallback_prefix: str = "CASE") -> str: + raw = case_id or f"{fallback_prefix}-{int(datetime.now().timestamp() * 1000)}" + safe = "".join(ch for ch in raw if ch.isalnum() or ch in ("-", "_")) + if not safe: + raise ValueError("Invalid case id") + return safe + + +async def _get_or_create_latest_draft(session: AsyncSession, initiative: Initiative, case_id: str) -> Draft: + draft_stmt = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + draft = (await session.execute(draft_stmt)).scalar_one_or_none() + if draft is not None: + return draft + + draft = Draft( + draft_code=f"DRAFT-{case_id}", + initiative_id=initiative.id, + payload={"caseId": case_id, "updatedAt": _iso_utc(_now_utc()), "tabs": {}}, + ) + session.add(draft) + await session.flush() + return draft + + +def _map_status_for_client(raw: str) -> str: + """Align DB enum values with frontend `ApplicationStatus` (mock list uses `pending` for new).""" + if raw == "submitted": + return "pending" + return raw + + +def _calendar_year_from_submitted(row: Dict[str, Any]) -> Optional[int]: + sd = str(row.get("submittedDate") or "") + if len(sd) >= 4 and sd[:4].isdigit(): + return int(sd[:4]) + return None + + +def _as_review_document_row(doc: ApplicationReviewDocument) -> Dict[str, Any]: + return { + "id": str(doc.id), + "initiativeId": str(doc.initiative_id), + "caseId": doc.case_id, + "documentVersion": int(doc.document_version or 1), + "officialBieuMau": dict(doc.official_bieu_mau or {}), + "templateData": dict(doc.template_data or {}) if isinstance(doc.template_data, dict) else None, + "fullBundle": dict(doc.full_bundle or {}) if isinstance(doc.full_bundle, dict) else None, + "createdBy": str(doc.created_by) if doc.created_by else None, + "createdAt": _iso_utc(doc.created_at), + } + + +def _submission_display_id(initiative: Initiative, stored: Dict[str, Any]) -> str: + """Public row id for list + GET /api/applications/{id} (must stay in sync everywhere we resolve by id).""" + sid = stored.get("id") + if sid is not None and str(sid).strip(): + return str(sid) + # Match `sub-{uuid4().hex[:16]}` (no hyphens); avoid `str(uuid)[:16]` which truncates dashed UUID awkwardly. + raw_id = getattr(initiative, "id", None) + if raw_id is not None: + hex32 = getattr(raw_id, "hex", None) + if isinstance(hex32, str) and len(hex32) >= 16: + return f"sub-{hex32[:16]}" + compact = str(raw_id).replace("-", "") + if len(compact) >= 16: + return f"sub-{compact[:16]}" + case = str(getattr(initiative, "case_code", "") or "") + tail = "".join(c for c in case if c.isalnum())[:16] or "noid" + return f"sub-{tail}" + + +def _sanitize_case_key_fragment(case_key: str) -> str: + """Same character allow-list as ``main._normalize_case_id`` without inventing a fallback id.""" + raw = (case_key or "").strip() + return "".join(ch for ch in raw if ch.isalnum() or ch in ("-", "_")) + + +async def resolve_initiative_for_draft_case_key( + session: AsyncSession, + case_key: str, +) -> Optional[Initiative]: + """ + Resolve ``Initiative`` for draft/evidence URLs keyed by ``Initiative.case_code`` *or* the public + submission row id (``sub-…`` / ``SUB-…``, case-insensitive — must match ``_submission_display_id``). + """ + safe = _sanitize_case_key_fragment(case_key) + if not safe: + return None + + ini = (await session.execute(select(Initiative).where(Initiative.case_code == safe))).scalar_one_or_none() + if ini is not None: + return ini + + key_lower = safe.lower() + stmt = ( + select(Initiative) + .where(Initiative.status != "draft") + .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at)) + ) + initiatives = (await session.execute(stmt)).scalars().all() + for initiative in initiatives: + draft_stmt = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + draft = (await session.execute(draft_stmt)).scalar_one_or_none() + if draft is None: + continue + payload = dict(draft.payload) if isinstance(draft.payload, dict) else {} + stored = payload.get("submissionRecord") if isinstance(payload.get("submissionRecord"), dict) else {} + disp = _submission_display_id(initiative, stored) + if disp.lower() == key_lower: + return initiative + return None + + +def _as_submission_item(initiative: Initiative, payload: Dict[str, Any]) -> Dict[str, Any]: + stored = payload.get("submissionRecord") if isinstance(payload.get("submissionRecord"), dict) else {} + item_id = _submission_display_id(initiative, stored) + submitted_at = initiative.submitted_at + submitted_date = _iso_utc(submitted_at) or str(stored.get("submittedDate") or "") + submission_file = payload.get("submissionFile") if isinstance(payload.get("submissionFile"), dict) else {} + author = stored.get("author") if isinstance(stored.get("author"), dict) else {} + + row: Dict[str, Any] = { + "id": item_id, + "submittedDate": submitted_date, + "name": str(stored.get("name") or "Hồ sơ sáng kiến"), + "author": { + "id": str(author.get("id") or initiative.case_code), + "name": str(author.get("name") or "—"), + "email": author.get("email"), + "phone": author.get("phone"), + }, + "subjectId": str(stored.get("subjectId") or ""), + "groupId": str(stored.get("groupId") or ""), + "status": _map_status_for_client(str(initiative.status or stored.get("status") or "submitted")), + "reviewStatus": str(stored.get("reviewStatus") or "not_reviewed"), + "supervisor": stored.get("supervisor"), + "reviewer": stored.get("reviewer"), + "reviewDeadline": stored.get("reviewDeadline"), + "conference": stored.get("conference"), + "topicType": str(stored.get("topicType") or "Hồ sơ PDF (đơn + báo cáo)"), + "files": { + "fullText": submission_file if submission_file.get("url") else None, + "abstract": None, + "poster": None, + }, + } + cy = _calendar_year_from_submitted(row) + if cy is not None: + row["calendarYear"] = cy + # Initiative.case_code is the key for application-drafts; submission "id" is often sub-… and must not be used to load tabs. + row["draft_case_id"] = initiative.case_code + tabs = payload.get("tabs") if isinstance(payload.get("tabs"), dict) else {} + app_tab = tabs.get("application") if isinstance(tabs.get("application"), dict) else {} + ic = app_tab.get("initiativeClassification") + if ic is not None and ic != "": + row["initiativeClassification"] = ic + rek = app_tab.get("researchEvidenceKind") + if rek is not None and rek != "": + row["researchEvidenceKind"] = rek + tek = app_tab.get("textbookEvidenceKind") + if tek is not None and tek != "": + row["textbookEvidenceKind"] = tek + row["textbook_evidence_kind"] = tek + return row + + +async def _admin_feedback_by_initiative_ids( + session: AsyncSession, + initiative_ids: List[uuid.UUID], +) -> Dict[uuid.UUID, str]: + """Initiative id → admin result ``feedback`` (trimmed) for API ``nhan_xet`` / «Nhận xét» column.""" + if not initiative_ids: + return {} + stmt = select(ApplicationAdminResult).where(ApplicationAdminResult.initiative_id.in_(initiative_ids)) + rows = (await session.execute(stmt)).scalars().all() + out: Dict[uuid.UUID, str] = {} + for rec in rows: + text = (rec.feedback or "").strip() + if text: + out[rec.initiative_id] = text + return out + + +async def _admin_decision_reviewers_by_initiative_ids( + session: AsyncSession, + initiative_ids: List[uuid.UUID], +) -> Dict[uuid.UUID, Dict[str, Any]]: + """ + Initiative id → ``reviewer`` person for rows with ``application_admin_results``. + + Uses ``updated_by`` (last admin who saved approve/reject) and ``users.full_name`` so + GET /api/applications exposes «Người đánh giá» consistently with adjudication history. + """ + if not initiative_ids: + return {} + stmt = select(ApplicationAdminResult).where(ApplicationAdminResult.initiative_id.in_(initiative_ids)) + recs = (await session.execute(stmt)).scalars().all() + if not recs: + return {} + + uid_list = [r.updated_by for r in recs if r.updated_by is not None] + name_by_uid: Dict[uuid.UUID, str] = {} + if uid_list: + urows = (await session.execute(select(User).where(User.id.in_(uid_list)))).scalars().all() + for u in urows: + name_by_uid[u.id] = (u.full_name or "").strip() or "—" + + out: Dict[uuid.UUID, Dict[str, Any]] = {} + for rec in recs: + uid = rec.updated_by + if uid is None: + out[rec.initiative_id] = {"id": "", "name": "—"} + else: + out[rec.initiative_id] = { + "id": str(uid), + "name": name_by_uid.get(uid, "—"), + } + return out + + +META_CASE_KEYS = ( + "initiativeCaseId", + "applicationCaseId", + "draftCaseId", + "caseCode", +) + + +def _looks_like_client_draft_session_id(value: str) -> bool: + s = value.strip() + return s.upper().startswith("DRAFT-") + + +async def _find_initiative_for_submit_metadata( + session: AsyncSession, + metadata: Dict[str, Any], + owner_user_id: Optional[uuid.UUID], +) -> Optional[Initiative]: + """Match an existing applicant draft (CASE-…) when the client sends Postgres case keys; ignore DRAFT-* session ids.""" + candidates: List[str] = [] + for key in META_CASE_KEYS: + raw = metadata.get(key) + if isinstance(raw, str) and raw.strip(): + candidates.append(raw.strip()) + + raw_fallback = metadata.get("caseId") + if isinstance(raw_fallback, str) and raw_fallback.strip(): + s = raw_fallback.strip() + if not _looks_like_client_draft_session_id(s): + candidates.append(s) + + seen: set[str] = set() + for c in candidates: + if c in seen: + continue + seen.add(c) + try: + nid = _normalize_case_id(c, fallback_prefix="CASE") + except ValueError: + continue + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == nid)) + ).scalar_one_or_none() + if ini is not None: + logger.info( + "Submit linked to existing initiative case_code=%s id=%s", + ini.case_code, + ini.id, + ) + return ini + + if owner_user_id is None: + return None + + stmt = ( + select(Initiative) + .where( + Initiative.owner_id == owner_user_id, + Initiative.case_code.ilike(r"CASE-%"), + ) + .order_by(desc(Initiative.updated_at)) + .limit(1) + ) + fb = (await session.execute(stmt)).scalar_one_or_none() + if fb is not None: + logger.warning( + "Submit matched initiative via owner CASE-* fallback case_code=%s owner=%s " + "(prefer sending initiativeCaseId from the applicant dashboard)", + fb.case_code, + owner_user_id, + ) + return fb + + +def _new_case_code_for_fresh_submit(metadata: Dict[str, Any]) -> str: + """When no existing initiative matches, allocate a stable case_code from metadata or SUB-….""" + for key in META_CASE_KEYS: + raw = metadata.get(key) + if isinstance(raw, str) and raw.strip(): + cand = raw.strip() + if not _looks_like_client_draft_session_id(cand): + return _normalize_case_id(cand, fallback_prefix="CASE") + raw2 = metadata.get("caseId") + if isinstance(raw2, str) and raw2.strip(): + cand2 = raw2.strip() + if not _looks_like_client_draft_session_id(cand2): + return _normalize_case_id(cand2, fallback_prefix="CASE") + return _normalize_case_id(None, fallback_prefix="SUB") + + +def _tabs_effectively_empty(tabs: Any) -> bool: + if not isinstance(tabs, dict) or len(tabs) == 0: + return True + for key in ("report", "application", "contribution"): + val = tabs.get(key) + if isinstance(val, dict) and len(val) > 0: + return False + if val: + return False + return True + + +async def _upload_submitted_pdf_to_exports_minio_required( + initiative: Initiative, + pdf_body: bytes, + original_name: Optional[str], +) -> str: + """Upload full submitted PDF to exports bucket; required for HTTP submit path.""" + from src.minio.storage import S3Storage, StorageError, settings as s3s + + s3 = S3Storage() + bucket = s3s.s3_bucket_exports + key = s3.build_key_for_initiative(initiative.id, original_name or "ho-so.pdf") + try: + await s3.upload( + bucket, + key, + BytesIO(pdf_body), + "application/pdf", + metadata={ + "case_code": str(initiative.case_code or ""), + "role": "full_pdf_submission", + }, + ) + except StorageError as exc: + raise ApplicationSubmitPersistError(f"Lưu PDF hồ sơ lên MinIO thất bại: {exc}") from exc + except Exception as exc: + raise ApplicationSubmitPersistError(f"Lưu PDF hồ sơ lên MinIO thất bại: {exc}") from exc + logger.info("Submitted PDF copied to MinIO exports bucket key=%s", key[:48]) + return key + + +async def _persist_official_application_forms( + session: AsyncSession, + initiative: Initiative, + owner_user_id: Optional[uuid.UUID], +) -> None: + """ + If a review-document row has non-empty officialBieuMau, render DOCX+PDF and register MinIO artifacts. + Skips when no bundle exists (legacy submits). + """ + from src.be01.docx_to_pdf import convert_docx_bytes_to_pdf + from src.be01.fill_application_form import fill_application_form_docx + from src.be01.official_to_data_blank import official_to_data_blank + from src.minio.storage import S3Storage, StorageError, settings as s3s + + stmt = ( + select(ApplicationReviewDocument) + .where(ApplicationReviewDocument.initiative_id == initiative.id) + .order_by(desc(ApplicationReviewDocument.document_version)) + .limit(1) + ) + doc = (await session.execute(stmt)).scalar_one_or_none() + if doc is None: + return + obm = doc.official_bieu_mau + if not isinstance(obm, dict) or len(obm) == 0: + return + + try: + ctx = official_to_data_blank(dict(obm)) + except Exception as exc: + raise ApplicationSubmitPersistError(f"Dữ liệu biểu mẫu không hợp lệ: {exc}") from exc + + try: + docx_bytes = await asyncio.to_thread(fill_application_form_docx, ctx) + pdf_bytes = await asyncio.to_thread( + convert_docx_bytes_to_pdf, + docx_bytes, + relax_justified_softbreaks=True, + strip_table_row_heights=False, + ) + except FileNotFoundError as exc: + raise ApplicationSubmitPersistError( + "Không thể tạo PDF mẫu: thiếu LibreOffice hoặc file mẫu Word. " + str(exc) + ) from exc + except Exception as exc: + raise ApplicationSubmitPersistError(f"Không thể tạo biểu mẫu DOCX/PDF: {exc}") from exc + + s3 = S3Storage() + bucket = s3s.s3_bucket_exports + safe_case = str(initiative.case_code or "case").replace("/", "_")[:80] + base_name = f"official-form-{safe_case}" + docx_key = s3.build_key_for_initiative(initiative.id, f"{base_name}.docx") + pdf_key = s3.build_key_for_initiative(initiative.id, f"{base_name}.pdf") + + try: + docx_res = await s3.upload( + bucket, + docx_key, + BytesIO(docx_bytes), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + metadata={"case_code": str(initiative.case_code or ""), "role": ROLE_OFFICIAL_FORM_DOCX}, + ) + pdf_res = await s3.upload( + bucket, + pdf_key, + BytesIO(pdf_bytes), + "application/pdf", + metadata={"case_code": str(initiative.case_code or ""), "role": ROLE_OFFICIAL_FORM_PDF}, + ) + except StorageError as exc: + raise ApplicationSubmitPersistError(f"Tải biểu mẫu lên MinIO thất bại: {exc}") from exc + + await upsert_artifact_official_form( + session, + initiative_id=initiative.id, + role=ROLE_OFFICIAL_FORM_DOCX, + storage_uri=docx_key, + original_name=f"{base_name}.docx", + byte_size=docx_res["size"], + sha256_hex=docx_res["sha256"], + uploaded_by=owner_user_id, + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + storage_kind=STORAGE_MINIO_EXPORTS, + ) + await upsert_artifact_official_form( + session, + initiative_id=initiative.id, + role=ROLE_OFFICIAL_FORM_PDF, + storage_uri=pdf_key, + original_name=f"{base_name}.pdf", + byte_size=pdf_res["size"], + sha256_hex=pdf_res["sha256"], + uploaded_by=owner_user_id, + mime_type="application/pdf", + storage_kind=STORAGE_MINIO_EXPORTS, + ) + + +async def merge_applicant_draft_bundle_tabs_from_snapshot_if_empty( + session: AsyncSession, + *, + initiative: Initiative, + bundle: Dict[str, Any], +) -> Dict[str, Any]: + """ + Recover tab JSON for admins when drafts.payload.tabs stayed empty but a submit snapshot exists + (e.g. older bug linked submit to wrong initiative row). + """ + tabs = bundle.get("tabs") + if not _tabs_effectively_empty(tabs): + return bundle + stmt = ( + select(ApplicationSubmitSnapshot) + .where(ApplicationSubmitSnapshot.initiative_id == initiative.id) + .order_by(desc(ApplicationSubmitSnapshot.captured_at)) + .limit(1) + ) + snap = (await session.execute(stmt)).scalar_one_or_none() + if snap is None or not isinstance(snap.merged_tabs, dict): + return bundle + snap_tabs = snap.merged_tabs + if _tabs_effectively_empty(snap_tabs): + return bundle + merged = dict(bundle) + merged["tabs"] = {**snap_tabs, **(merged.get("tabs") or {})} + merged.setdefault("caseId", initiative.case_code) + return merged + + +async def merge_application_draft_document_with_snapshot_if_needed( + session: AsyncSession, + case_code: str, + doc: Dict[str, Any], +) -> Dict[str, Any]: + """GET /api/v1/application-drafts/:caseId — hydrate empty tabs from immutable submit snapshot when present.""" + raw = (case_code or "").strip() + if not raw: + return doc + try: + norm = _normalize_case_id(raw, fallback_prefix="CASE") + except ValueError: + norm = raw + ini = (await session.execute(select(Initiative).where(Initiative.case_code == norm))).scalar_one_or_none() + if ini is None: + return doc + return await merge_applicant_draft_bundle_tabs_from_snapshot_if_empty(session, initiative=ini, bundle=doc) + + +async def save_submitted_application( + session: AsyncSession, + metadata: Dict[str, Any], + file_url: str, + submission_id: Optional[str] = None, + owner_user_id: Optional[uuid.UUID] = None, + *, + pdf_byte_size: Optional[int] = None, + pdf_sha256: Optional[str] = None, + pdf_original_name: Optional[str] = None, + pdf_body: Optional[bytes] = None, +) -> Dict[str, Any]: + initiative_name = str(metadata.get("initiativeName") or metadata.get("name") or "").strip() or "Hồ sơ sáng kiến" + author_name = str(metadata.get("authorName") or "").strip() or "—" + author_email = str(metadata.get("authorEmail") or "").strip() or None + author_phone = str(metadata.get("authorPhone") or "").strip() or None + now = _now_utc() + resolved_submission_id = submission_id or f"sub-{uuid.uuid4().hex[:16]}" + effective_owner = owner_user_id or SYSTEM_DRAFT_OWNER_ID + + matched = await _find_initiative_for_submit_metadata(session, metadata, owner_user_id) + + if matched is not None: + initiative = matched + case_id = initiative.case_code + else: + case_id = _new_case_code_for_fresh_submit(metadata) + initiative = (await session.execute(select(Initiative).where(Initiative.case_code == case_id))).scalar_one_or_none() + if initiative is None: + initiative = Initiative(case_code=case_id, owner_id=effective_owner) + session.add(initiative) + await session.flush() + elif owner_user_id and initiative.owner_id == SYSTEM_DRAFT_OWNER_ID: + initiative.owner_id = owner_user_id + + assert initiative is not None + + if owner_user_id and initiative.owner_id == SYSTEM_DRAFT_OWNER_ID: + initiative.owner_id = owner_user_id + + draft = await _get_or_create_latest_draft(session, initiative, case_id) + payload_pre: Dict[str, Any] = dict(draft.payload) if isinstance(draft.payload, dict) else {} + tabs_pre = dict(payload_pre.get("tabs") or {}) + bundle_v = await merge_applicant_draft_bundle_tabs_from_snapshot_if_empty( + session, + initiative=initiative, + bundle={"tabs": tabs_pre, "caseId": case_id}, + ) + merged_tabs_validate = dict(bundle_v.get("tabs") or {}) + evidence_flags = await fetch_evidence_presence_flags(session, initiative.id) + missing_ready = collect_submission_readiness_gaps(merged_tabs_validate, evidence_flags) + if missing_ready: + raise ApplicationSubmissionNotReadyError(missing_ready) + + initiative.status = "submitted" + initiative.submitted_at = now + + current_payload: Dict[str, Any] = payload_pre + current_payload["caseId"] = case_id + current_payload["updatedAt"] = _iso_utc(now) + current_payload["tabs"] = dict(current_payload.get("tabs") or {}) + current_payload["submissionFile"] = {"url": file_url, "type": "pdf"} + current_payload["submissionRecord"] = { + "id": resolved_submission_id, + "submittedDate": _iso_utc(now), + "name": initiative_name, + "author": { + "id": case_id, + "name": author_name, + "email": author_email, + "phone": author_phone, + }, + "subjectId": str(metadata.get("subjectId") or ""), + "groupId": str(metadata.get("groupId") or ""), + "status": "submitted", + "reviewStatus": "not_reviewed", + "supervisor": None, + "reviewer": None, + "reviewDeadline": None, + "conference": None, + "topicType": str(metadata.get("topicType") or "Hồ sơ PDF (đơn + báo cáo)"), + } + + draft.payload = current_payload + draft.version = (draft.version or 0) + 1 + await session.flush() + + sr = current_payload.get("submissionRecord") + sr_dict = dict(sr) if isinstance(sr, dict) else {} + merged_tabs = dict(current_payload.get("tabs") or {}) + submit_meta: Dict[str, Any] = { + "caseId": case_id, + "draftVersion": draft.version, + "submissionFile": current_payload.get("submissionFile"), + } + if isinstance(metadata, dict): + for k in ( + "initiativeName", + "name", + "authorName", + "authorEmail", + "topicType", + "subjectId", + "groupId", + "initiativeCaseId", + "applicationCaseId", + "draftCaseId", + ): + if k in metadata: + submit_meta[k] = metadata[k] + + artifact_storage_uri = file_url + artifact_storage_kind: Optional[str] = STORAGE_FILESYSTEM if file_url else None + if pdf_body and len(pdf_body) >= 50: + minio_key = await _upload_submitted_pdf_to_exports_minio_required( + initiative, pdf_body, pdf_original_name or f"{resolved_submission_id}.pdf" + ) + artifact_storage_uri = minio_key + artifact_storage_kind = STORAGE_MINIO_EXPORTS + + await record_submit_snapshot( + session, + initiative_id=initiative.id, + submission_record_id=resolved_submission_id, + merged_tabs=merged_tabs, + submit_metadata=submit_meta, + full_pdf_uri=file_url, + ) + await upsert_application_taxonomy( + session, + initiative_id=initiative.id, + subject_id=str(sr_dict.get("subjectId") or metadata.get("subjectId") or ""), + group_id=str(sr_dict.get("groupId") or metadata.get("groupId") or ""), + topic_type=str(sr_dict.get("topicType") or metadata.get("topicType") or ""), + ) + await upsert_application_workflow( + session, + initiative_id=initiative.id, + submission_record=sr_dict, + ) + await upsert_artifact_full_pdf( + session, + initiative_id=initiative.id, + storage_uri=artifact_storage_uri, + original_name=pdf_original_name, + byte_size=pdf_byte_size, + sha256_hex=pdf_sha256, + uploaded_by=owner_user_id, + storage_kind=artifact_storage_kind, + ) + await _persist_official_application_forms(session, initiative, owner_user_id) + + return { + "id": resolved_submission_id, + "submittedDate": _iso_utc(now), + "publicUrl": file_url, + "name": initiative_name, + } + + +async def create_submitted_application_shell( + session: AsyncSession, + *, + owner_user_id: uuid.UUID, + name: Optional[str] = None, + author_name: Optional[str] = None, + author_email: Optional[str] = None, + author_phone: Optional[str] = None, +) -> Dict[str, Any]: + """ + Create a new submitted-application shell record and return list-item shape. + This allocates an `applicationId` before the applicant uploads the final PDF. + """ + now = _now_utc() + case_id = _normalize_case_id(f"SUB-{uuid.uuid4().hex[:12]}", fallback_prefix="SUB") + submission_id = f"sub-{uuid.uuid4().hex[:16]}" + + initiative = Initiative( + case_code=case_id, + owner_id=owner_user_id, + status="submitted", + submitted_at=now, + ) + session.add(initiative) + await session.flush() + + submission_record = { + "id": submission_id, + "submittedDate": _iso_utc(now), + "name": (name or "").strip() or "Hồ sơ mới", + "author": { + "id": case_id, + "name": (author_name or "").strip() or "—", + "email": (author_email or "").strip() or None, + "phone": (author_phone or "").strip() or None, + }, + "subjectId": "", + "groupId": "", + "status": "submitted", + "reviewStatus": "not_reviewed", + "supervisor": None, + "reviewer": None, + "reviewDeadline": None, + "conference": None, + "topicType": "Hồ sơ khởi tạo", + } + payload: Dict[str, Any] = { + "caseId": case_id, + "updatedAt": _iso_utc(now), + "tabs": {}, + "submissionRecord": submission_record, + } + draft = Draft( + draft_code=f"DRAFT-{case_id}", + initiative_id=initiative.id, + payload=payload, + ) + session.add(draft) + await session.flush() + return _as_submission_item(initiative, payload) + + +async def save_review_document_bundle( + session: AsyncSession, + *, + case_id: str, + official_bieu_mau: Dict[str, Any], + template_data: Optional[Dict[str, Any]], + full_bundle: Optional[Dict[str, Any]], + owner_user_id: Optional[uuid.UUID], +) -> Dict[str, Any]: + normalized_case = _normalize_case_id(case_id, fallback_prefix="CASE") + stmt = select(Initiative).where(Initiative.case_code == normalized_case) + initiative = (await session.execute(stmt)).scalar_one_or_none() + effective_owner = owner_user_id or SYSTEM_DRAFT_OWNER_ID + if initiative is None: + initiative = Initiative(case_code=normalized_case, owner_id=effective_owner) + session.add(initiative) + await session.flush() + elif owner_user_id and initiative.owner_id == SYSTEM_DRAFT_OWNER_ID: + initiative.owner_id = owner_user_id + + max_stmt = ( + select(ApplicationReviewDocument.document_version) + .where(ApplicationReviewDocument.initiative_id == initiative.id) + .order_by(desc(ApplicationReviewDocument.document_version)) + .limit(1) + ) + latest_ver = (await session.execute(max_stmt)).scalar_one_or_none() + next_ver = int(latest_ver or 0) + 1 + row = ApplicationReviewDocument( + initiative_id=initiative.id, + case_id=normalized_case, + document_version=next_ver, + official_bieu_mau=dict(official_bieu_mau or {}), + template_data=dict(template_data or {}) if isinstance(template_data, dict) else None, + full_bundle=dict(full_bundle or {}) if isinstance(full_bundle, dict) else None, + created_by=owner_user_id, + ) + session.add(row) + await session.flush() + return _as_review_document_row(row) + + +async def get_latest_review_document_bundle( + session: AsyncSession, *, case_id: str +) -> Optional[Dict[str, Any]]: + normalized_case = _normalize_case_id(case_id, fallback_prefix="CASE") + stmt = select(Initiative).where(Initiative.case_code == normalized_case) + initiative = (await session.execute(stmt)).scalar_one_or_none() + if initiative is None: + return None + doc_stmt = ( + select(ApplicationReviewDocument) + .where(ApplicationReviewDocument.initiative_id == initiative.id) + .order_by(desc(ApplicationReviewDocument.document_version), desc(ApplicationReviewDocument.created_at)) + .limit(1) + ) + doc = (await session.execute(doc_stmt)).scalar_one_or_none() + if doc is None: + return None + return _as_review_document_row(doc) + + +async def list_review_document_bundles( + session: AsyncSession, *, case_id: str, limit: int = 20 +) -> List[Dict[str, Any]]: + normalized_case = _normalize_case_id(case_id, fallback_prefix="CASE") + stmt = select(Initiative).where(Initiative.case_code == normalized_case) + initiative = (await session.execute(stmt)).scalar_one_or_none() + if initiative is None: + return [] + doc_stmt = ( + select(ApplicationReviewDocument) + .where(ApplicationReviewDocument.initiative_id == initiative.id) + .order_by(desc(ApplicationReviewDocument.document_version), desc(ApplicationReviewDocument.created_at)) + .limit(max(1, min(limit, 200))) + ) + rows = (await session.execute(doc_stmt)).scalars().all() + return [_as_review_document_row(x) for x in rows] + + +async def get_review_document_bundle_by_id( + session: AsyncSession, *, review_document_id: str +) -> Optional[Dict[str, Any]]: + try: + rid = uuid.UUID(str(review_document_id)) + except ValueError: + return None + row = await session.get(ApplicationReviewDocument, rid) + if row is None: + return None + return _as_review_document_row(row) + + +async def update_review_document_bundle( + session: AsyncSession, + *, + review_document_id: str, + official_bieu_mau: Dict[str, Any], + template_data: Optional[Dict[str, Any]], + full_bundle: Optional[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + try: + rid = uuid.UUID(str(review_document_id)) + except ValueError: + return None + row = await session.get(ApplicationReviewDocument, rid) + if row is None: + return None + row.official_bieu_mau = dict(official_bieu_mau or {}) + row.template_data = dict(template_data or {}) if isinstance(template_data, dict) else None + row.full_bundle = dict(full_bundle or {}) if isinstance(full_bundle, dict) else None + await session.flush() + return _as_review_document_row(row) + + +async def delete_review_document_bundle( + session: AsyncSession, *, review_document_id: str +) -> bool: + try: + rid = uuid.UUID(str(review_document_id)) + except ValueError: + return False + row = await session.get(ApplicationReviewDocument, rid) + if row is None: + return False + await session.delete(row) + await session.flush() + return True + + +async def resolve_submitted_initiative_for_backup( + session: AsyncSession, + application_id: str, +) -> Optional[Tuple[Initiative, str]]: + """ + Resolve initiative + public submission id (``sub-…``) the same way as ``get_application_by_id``. + Used by the admin backup ZIP builder. + """ + aid = (application_id or "").strip() + if not aid: + return None + + stmt = ( + select(Initiative) + .where(Initiative.status != "draft") + .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at)) + ) + initiatives = (await session.execute(stmt)).scalars().all() + for initiative in initiatives: + draft_stmt = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + draft = (await session.execute(draft_stmt)).scalar_one_or_none() + payload = dict(draft.payload) if draft is not None and isinstance(draft.payload, dict) else {} + stored = payload.get("submissionRecord") if isinstance(payload.get("submissionRecord"), dict) else {} + if _submission_display_id(initiative, stored) == aid or initiative.case_code == aid: + return initiative, _submission_display_id(initiative, stored) + return None + + +async def get_application_by_id(session: AsyncSession, application_id: str) -> Optional[Dict[str, Any]]: + """Return one application row (same shape as list items) or None.""" + aid = application_id.strip() + if not aid: + return None + + stmt = ( + select(Initiative) + .where(Initiative.status != "draft") + .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at)) + ) + initiatives = (await session.execute(stmt)).scalars().all() + _iids = [i.id for i in initiatives] + feedback_map = await _admin_feedback_by_initiative_ids(session, _iids) + reviewer_map = await _admin_decision_reviewers_by_initiative_ids(session, _iids) + + for initiative in initiatives: + draft_stmt = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + draft = (await session.execute(draft_stmt)).scalar_one_or_none() + payload = dict(draft.payload) if draft is not None and isinstance(draft.payload, dict) else {} + stored = payload.get("submissionRecord") if isinstance(payload.get("submissionRecord"), dict) else {} + if _submission_display_id(initiative, stored) == aid or initiative.case_code == aid: + row = _as_submission_item(initiative, payload) + fb = feedback_map.get(initiative.id) + if fb: + row["nhan_xet"] = fb + rev = reviewer_map.get(initiative.id) + if rev is not None: + row["reviewer"] = rev + return row + + return None + + +def _matches_filters( + row: Dict[str, Any], + *, + name: str, + author_name: str, + reviewer_name: str, + status: str, + review_status: str, + date_from: str, + date_to: str, + lifecycle: str = "", + skip_status_filter: bool = False, +) -> bool: + row_status = str(row.get("status") or "") + lc = (lifecycle or "").strip().lower() + if lc == "inbox": + if row_status in ("approved", "rejected"): + return False + elif lc == "decided": + if row_status not in ("approved", "rejected"): + return False + n = name.strip().lower() + if n and n not in str(row.get("name") or "").lower(): + return False + an = author_name.strip().lower() + author = row.get("author") or {} + if an and an not in str(author.get("name") or "").lower(): + return False + rn = reviewer_name.strip().lower() + if rn: + reviewer = row.get("reviewer") or {} + if rn not in str(reviewer.get("name") or "").lower(): + return False + if not skip_status_filter and status and row_status != status: + return False + if review_status and str(row.get("reviewStatus") or "") != review_status: + return False + sd = str(row.get("submittedDate") or "") + sd_day = sd[:10] if len(sd) >= 10 else "" + if date_from and sd_day and sd_day < date_from: + return False + if date_to and sd_day and sd_day > date_to: + return False + return True + + +def _sort_submission_pairs_in_place( + pairs: List[Tuple[Dict[str, Any], Dict[str, Any]]], + *, + sort_by: str, + sort_order: str, +) -> None: + """Same ordering as GET /api/applications (after optional ``status`` query narrowing).""" + reverse = sort_order != "asc" + if sort_by == "name": + pairs.sort(key=lambda x: str(x[0].get("name") or ""), reverse=reverse) + elif sort_by == "author": + pairs.sort(key=lambda x: str((x[0].get("author") or {}).get("name") or ""), reverse=reverse) + else: + pairs.sort(key=lambda x: str(x[0].get("submittedDate") or ""), reverse=reverse) + + +async def collect_submission_row_payload_pairs( + session: AsyncSession, + *, + name: str, + author_name: str, + reviewer_name: str, + review_status: str, + date_from: str, + date_to: str, + lifecycle: str = "", +) -> List[Tuple[Dict[str, Any], Dict[str, Any]]]: + """ + Submissions matching list filters (lifecycle, text, dates), before ``status`` query param + and before sort. Each entry is ``(list_row, draft_payload_dict)``. + """ + stmt = ( + select(Initiative) + .where(Initiative.status != "draft") + .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at)) + ) + initiatives = (await session.execute(stmt)).scalars().all() + _iids = [i.id for i in initiatives] + feedback_map = await _admin_feedback_by_initiative_ids(session, _iids) + reviewer_map = await _admin_decision_reviewers_by_initiative_ids(session, _iids) + + pairs: List[Tuple[Dict[str, Any], Dict[str, Any]]] = [] + for initiative in initiatives: + draft_stmt = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + draft = (await session.execute(draft_stmt)).scalar_one_or_none() + payload = dict(draft.payload) if draft is not None and isinstance(draft.payload, dict) else {} + row = _as_submission_item(initiative, payload) + fb = feedback_map.get(initiative.id) + if fb: + row["nhan_xet"] = fb + rev = reviewer_map.get(initiative.id) + if rev is not None: + row["reviewer"] = rev + if not _matches_filters( + row, + name=name, + author_name=author_name, + reviewer_name=reviewer_name, + status="", + review_status=review_status, + date_from=date_from, + date_to=date_to, + lifecycle=lifecycle, + skip_status_filter=True, + ): + continue + pairs.append((row, payload)) + + return pairs + + +async def list_submitted_applications( + session: AsyncSession, + *, + page: int, + page_size: int, + name: str, + author_name: str, + reviewer_name: str, + status: str, + review_status: str, + date_from: str, + date_to: str, + sort_by: str, + sort_order: str, + lifecycle: str = "", +) -> Dict[str, Any]: + pairs = await collect_submission_row_payload_pairs( + session, + name=name, + author_name=author_name, + reviewer_name=reviewer_name, + review_status=review_status, + date_from=date_from, + date_to=date_to, + lifecycle=lifecycle, + ) + items = [r for r, _ in pairs] + + status_counts = { + "approved": sum(1 for r in items if str(r.get("status") or "") == "approved"), + "rejected": sum(1 for r in items if str(r.get("status") or "") == "rejected"), + } + if status: + pairs = [(r, p) for r, p in pairs if str(r.get("status") or "") == status] + _sort_submission_pairs_in_place(pairs, sort_by=sort_by, sort_order=sort_order) + items = [r for r, _ in pairs] + + total = len(items) + start = (page - 1) * page_size + page_data = items[start : start + page_size] + total_pages = max(1, (total + page_size - 1) // page_size) if total else 1 + + return { + "data": page_data, + "pagination": { + "page": page, + "pageSize": page_size, + "totalItems": total, + "totalPages": total_pages, + }, + "statusCounts": status_counts, + } + + +async def submitted_applications_pairs_for_export( + session: AsyncSession, + *, + name: str, + author_name: str, + reviewer_name: str, + status: str, + review_status: str, + date_from: str, + date_to: str, + sort_by: str, + sort_order: str, + lifecycle: str = "", +) -> List[Tuple[Dict[str, Any], Dict[str, Any]]]: + """(row, draft_payload) tuples after the same filters + sort as GET /api/applications (all pages).""" + pairs = await collect_submission_row_payload_pairs( + session, + name=name, + author_name=author_name, + reviewer_name=reviewer_name, + review_status=review_status, + date_from=date_from, + date_to=date_to, + lifecycle=lifecycle, + ) + if status: + pairs = [(r, p) for r, p in pairs if str(r.get("status") or "") == status] + _sort_submission_pairs_in_place(pairs, sort_by=sort_by, sort_order=sort_order) + return pairs + + +async def list_my_submitted_applications( + session: AsyncSession, + user_id: uuid.UUID, + user_email: str, +) -> List[Dict[str, Any]]: + """ + Submissions for the logged-in applicant: owned initiatives, or legacy rows matched by author email. + """ + stmt = ( + select(Initiative) + .where(Initiative.status != "draft") + .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at)) + ) + initiatives = (await session.execute(stmt)).scalars().all() + _iids = [i.id for i in initiatives] + feedback_map = await _admin_feedback_by_initiative_ids(session, _iids) + reviewer_map = await _admin_decision_reviewers_by_initiative_ids(session, _iids) + + email_norm = user_email.strip().lower() + seen: set[str] = set() + out: List[Dict[str, Any]] = [] + + for initiative in initiatives: + draft_stmt = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + draft = (await session.execute(draft_stmt)).scalar_one_or_none() + payload = dict(draft.payload) if draft is not None and isinstance(draft.payload, dict) else {} + row = _as_submission_item(initiative, payload) + fb = feedback_map.get(initiative.id) + if fb: + row["nhan_xet"] = fb + rev = reviewer_map.get(initiative.id) + if rev is not None: + row["reviewer"] = rev + rid = str(row.get("id") or "") + if not rid or rid in seen: + continue + + owns = initiative.owner_id == user_id + auth_email = str((row.get("author") or {}).get("email") or "").strip().lower() + legacy_email_match = bool(email_norm and auth_email and auth_email == email_norm) + + if owns or legacy_email_match: + seen.add(rid) + out.append(row) + + out.sort(key=lambda x: str(x.get("submittedDate") or ""), reverse=True) + return out + + +def _applicant_may_mutate_row( + initiative: Initiative, + row: Dict[str, Any], + user_id: uuid.UUID, + user_email: str, +) -> bool: + """Same visibility rule as list_my_submitted_applications: owner or legacy author email match.""" + if initiative.owner_id == user_id: + return True + email_norm = user_email.strip().lower() + auth_email = str((row.get("author") or {}).get("email") or "").strip().lower() + return bool(email_norm and auth_email and auth_email == email_norm) + + +async def _resolve_initiative_and_latest_draft_for_application_id( + session: AsyncSession, + application_id: str, +) -> tuple[Initiative, Draft]: + aid = application_id.strip() + if not aid: + raise LookupError("missing_id") + + stmt = ( + select(Initiative) + .where(Initiative.status != "draft") + .order_by(desc(Initiative.submitted_at), desc(Initiative.updated_at)) + ) + initiatives = (await session.execute(stmt)).scalars().all() + + for initiative in initiatives: + draft_stmt = ( + select(Draft) + .where(Draft.initiative_id == initiative.id) + .order_by(Draft.updated_at.desc()) + .limit(1) + ) + draft = (await session.execute(draft_stmt)).scalar_one_or_none() + if draft is None: + continue + payload = dict(draft.payload) if isinstance(draft.payload, dict) else {} + stored = payload.get("submissionRecord") if isinstance(payload.get("submissionRecord"), dict) else {} + disp = _submission_display_id(initiative, stored) + if disp.lower() == aid.lower() or initiative.case_code == aid: + return initiative, draft + + raise LookupError("not_found") + + +def _parse_submitted_date_input(submitted_date: str) -> datetime: + """Accept `YYYY-MM-DD` (from HTML date input) or ISO strings.""" + s = submitted_date.strip() + if len(s) >= 10 and s[4] == "-" and s[7] == "-": + y, mo, d = int(s[0:4]), int(s[5:7]), int(s[8:10]) + return datetime(y, mo, d, 12, 0, 0, tzinfo=timezone.utc) + raw = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(raw) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +async def update_my_submitted_application( + session: AsyncSession, + user_id: uuid.UUID, + user_email: str, + application_id: str, + name: str, + submitted_date: str, +) -> Dict[str, Any]: + """ + Update display fields on a submitted initiative (submissionRecord + initiative.submitted_at). + Raises LookupError if not found, PermissionError if caller cannot mutate this row. + """ + initiative, draft = await _resolve_initiative_and_latest_draft_for_application_id(session, application_id) + payload = dict(draft.payload) if isinstance(draft.payload, dict) else {} + row = _as_submission_item(initiative, payload) + if not _applicant_may_mutate_row(initiative, row, user_id, user_email): + raise PermissionError("forbidden") + + new_name = name.strip() or str(row.get("name") or "Hồ sơ sáng kiến") + submitted_at = _parse_submitted_date_input(submitted_date) + initiative.submitted_at = submitted_at + + sr = dict(payload.get("submissionRecord") or {}) + sr["name"] = new_name + sr["submittedDate"] = _iso_utc(submitted_at) + payload["submissionRecord"] = sr + payload["updatedAt"] = _iso_utc(_now_utc()) + draft.payload = payload + draft.version = (draft.version or 0) + 1 + await session.flush() + + return _as_submission_item(initiative, payload) + + +async def delete_my_submitted_application( + session: AsyncSession, + user_id: uuid.UUID, + user_email: str, + application_id: str, +) -> None: + """Delete initiative (cascades drafts). Raises LookupError / PermissionError.""" + initiative, draft = await _resolve_initiative_and_latest_draft_for_application_id(session, application_id) + payload = dict(draft.payload) if isinstance(draft.payload, dict) else {} + row = _as_submission_item(initiative, payload) + if not _applicant_may_mutate_row(initiative, row, user_id, user_email): + raise PermissionError("forbidden") + + await session.delete(initiative) + await session.flush() diff --git a/be0/src/initiative_db/user_notifications.py b/be0/src/initiative_db/user_notifications.py new file mode 100644 index 0000000..e7c899e --- /dev/null +++ b/be0/src/initiative_db/user_notifications.py @@ -0,0 +1,207 @@ +"""Applicant in-app notifications (admin adjudication).""" + +from __future__ import annotations + +import logging +import math +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy import desc, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.initiative_db.models import Draft, Initiative, UserNotification + +logger = logging.getLogger(__name__) + +__all__ = [ + "merit_category_label_from_draft_payload", + "best_effort_notify_applicant_after_admin_decision", + "list_notifications_for_user", + "count_unread_notifications", + "mark_notification_read", +] + + +def merit_category_label_from_draft_payload(payload: Dict[str, Any]) -> Optional[str]: + """ + Align with fe0 `getApplicationMeritCategoryHint`: 2.1.1 / 2.1.2 và sách giáo trình (textbook + book) → Xuất sắc; + 2.1.4 (`poster-without-review`) → Trung bình; else Khá for known groups. + Returns None if classification is missing (caller may still show «Khá» for approved). + """ + tabs = payload.get("tabs") if isinstance(payload.get("tabs"), dict) else {} + app = tabs.get("application") if isinstance(tabs.get("application"), dict) else {} + c = app.get("initiativeClassification") + k = str(app.get("researchEvidenceKind") or "").strip() + if c == "research" and k in ("international", "domestic"): + return "Xuất sắc" + if c == "research" and k == "poster-without-review": + return "Trung bình" + tb = str(app.get("textbookEvidenceKind") or "").strip() + if c == "textbook" and tb == "book": + return "Xuất sắc" + if c in ("technical", "research", "textbook"): + return "Khá" + return None + + +def _iso(dt: Optional[datetime]) -> Optional[str]: + if dt is None: + return None + v = dt.astimezone(timezone.utc).replace(microsecond=0).isoformat() + return v.replace("+00:00", "Z") + + +def _notification_to_api(row: UserNotification) -> Dict[str, Any]: + return { + "id": str(row.id), + "type": row.type, + "title": row.title, + "body": row.body, + "applicationId": row.application_id, + "relatedInitiativeId": str(row.related_initiative_id) if row.related_initiative_id else None, + "sourceAdminResultId": str(row.source_admin_result_id) if row.source_admin_result_id else None, + "decision": row.decision, + "meritCategoryLabel": row.merit_category_label, + "feedback": row.feedback_text or "", + "rationale": row.rationale_text, + "readAt": _iso(row.read_at), + "createdAt": _iso(row.created_at), + } + + +async def best_effort_notify_applicant_after_admin_decision(result: Dict[str, Any]) -> None: + """ + Second transaction after admin-result commit: insert inbox row for initiative owner. + Swallows all errors (logged); never raises. + """ + try: + from src.initiative_db.engine import get_session, is_postgres_enabled + + if not is_postgres_enabled(): + return + + initiative_id = uuid.UUID(str(result["initiativeId"])) + application_id = str(result["applicationId"]).strip() + decision = str(result.get("decision") or "").strip().lower() + if decision not in ("approved", "rejected"): + return + + feedback = str(result.get("feedback") or "") + rationale = result.get("rationale") + rationale_s = (str(rationale).strip() if rationale is not None else None) or None + admin_result_id = uuid.UUID(str(result["id"])) + + async with get_session() as session: + ini = await session.get(Initiative, initiative_id) + if ini is None: + return + recipient_id = ini.owner_id + + draft_stmt = ( + select(Draft) + .where(Draft.initiative_id == initiative_id) + .order_by(desc(Draft.updated_at)) + .limit(1) + ) + draft = (await session.execute(draft_stmt)).scalar_one_or_none() + payload: Dict[str, Any] = dict(draft.payload) if draft and isinstance(draft.payload, dict) else {} + + merit: Optional[str] = None + if decision == "approved": + merit = merit_category_label_from_draft_payload(payload) + if merit is None: + merit = "Khá" + + if decision == "approved": + title = "Hồ sơ được duyệt" + else: + title = "Hồ sơ không được duyệt" + + body_parts: List[str] = [] + if decision == "approved" and merit: + body_parts.append(f"Phân hạng đề xuất: {merit}.") + if feedback.strip(): + fb = feedback.strip() + body_parts.append(fb[:280] + ("…" if len(fb) > 280 else "")) + elif decision == "rejected": + body_parts.append("Xem phản hồi và lý do chi tiết bên dưới.") + body = " ".join(body_parts) if body_parts else title + + row = UserNotification( + recipient_user_id=recipient_id, + type="admin_application_decision", + title=title, + body=body, + application_id=application_id, + related_initiative_id=initiative_id, + source_admin_result_id=admin_result_id, + decision=decision, + merit_category_label=merit if decision == "approved" else None, + feedback_text=feedback, + rationale_text=rationale_s, + ) + session.add(row) + await session.commit() + except Exception: + logger.exception("best_effort_notify_applicant_after_admin_decision failed") + + +async def list_notifications_for_user( + session: AsyncSession, + user_id: uuid.UUID, + *, + page: int, + page_size: int, +) -> Dict[str, Any]: + page = max(1, page) + page_size = max(1, min(100, page_size)) + + count_stmt = select(func.count()).select_from(UserNotification).where(UserNotification.recipient_user_id == user_id) + total = int((await session.execute(count_stmt)).scalar_one()) + total_pages = max(1, math.ceil(total / page_size)) if total else 1 + start = (page - 1) * page_size + + stmt = ( + select(UserNotification) + .where(UserNotification.recipient_user_id == user_id) + .order_by(desc(UserNotification.created_at)) + .offset(start) + .limit(page_size) + ) + rows = (await session.execute(stmt)).scalars().all() + + return { + "data": [_notification_to_api(r) for r in rows], + "pagination": { + "page": page, + "pageSize": page_size, + "totalItems": total, + "totalPages": total_pages, + }, + } + + +async def count_unread_notifications(session: AsyncSession, user_id: uuid.UUID) -> int: + stmt = select(func.count()).select_from(UserNotification).where( + UserNotification.recipient_user_id == user_id, + UserNotification.read_at.is_(None), + ) + return int((await session.execute(stmt)).scalar_one()) + + +async def mark_notification_read(session: AsyncSession, user_id: uuid.UUID, notification_id: uuid.UUID) -> bool: + """Return True if a row was updated.""" + now = datetime.now(timezone.utc) + stmt = ( + update(UserNotification) + .where( + UserNotification.id == notification_id, + UserNotification.recipient_user_id == user_id, + ) + .values(read_at=now) + ) + res = await session.execute(stmt) + await session.flush() + return (res.rowcount or 0) > 0 diff --git a/be0/src/internal_control/__init__.py b/be0/src/internal_control/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/internal_control/__pycache__/__init__.cpython-311.pyc b/be0/src/internal_control/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b13673bf42e46d32ab9133340260fd811990e3d5 GIT binary patch literal 146 zcmZ3^%ge<80tgjEsy$%s>_Z Dr=1*I literal 0 HcmV?d00001 diff --git a/be0/src/internal_control/access_control/__init__.py b/be0/src/internal_control/access_control/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/internal_control/access_control/__pycache__/__init__.cpython-313.pyc b/be0/src/internal_control/access_control/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f8cb788dd5f8da5fc4134568474cb13a5b74127 GIT binary patch literal 144 zcmey&%ge<80tAK0Y%qvm`!Vub}c4hfQvNN@-52T@fo#Kgj%I5aS~= LBO_xGGmr%U_V*%d literal 0 HcmV?d00001 diff --git a/be0/src/internal_control/cloud_infrastructure_controls/__init__.py b/be0/src/internal_control/cloud_infrastructure_controls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/internal_control/cloud_infrastructure_controls/__pycache__/__init__.cpython-313.pyc b/be0/src/internal_control/cloud_infrastructure_controls/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a54e24b67c6de4b74fc03559d23521c461188d99 GIT binary patch literal 159 zcmey&%ge<80tqGbKdypq(S zyu_UNTZl aX-=wL5i8I*kafi%#z$sGM#ds$APWG#)hO}+ literal 0 HcmV?d00001 diff --git a/be0/src/internal_control/data_integrity_security/__init__.py b/be0/src/internal_control/data_integrity_security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/internal_control/data_integrity_security/__pycache__/__init__.cpython-313.pyc b/be0/src/internal_control/data_integrity_security/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93d90974a20216f65255e955d7c2c82facf05b50 GIT binary patch literal 153 zcmey&%ge<80t+|;}h{rLFIJfK9pUP0w84x8Nkl+v73 XyCPPgDIi;lL5z>gjEsy$%s>_Z_zon! literal 0 HcmV?d00001 diff --git a/be0/src/internal_control/it_governance/__init__.py b/be0/src/internal_control/it_governance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/internal_control/it_governance/__pycache__/__init__.cpython-311.pyc b/be0/src/internal_control/it_governance/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..32a236803a757248d33b74a945c9241ac4a3f43d GIT binary patch literal 160 zcmZ3^%ge<80tPO4oI aE6@y(Eyesm;sY}yBjX1K7*WIw6axSc*(KKi literal 0 HcmV?d00001 diff --git a/be0/src/internal_control/it_governance/__pycache__/__init__.cpython-313.pyc b/be0/src/internal_control/it_governance/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..219552eaf690b708da270764d974be8c1a28fabf GIT binary patch literal 143 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 E05_FZNdN!< literal 0 HcmV?d00001 diff --git a/be0/src/internal_control/it_governance/__pycache__/document_io.cpython-313.pyc b/be0/src/internal_control/it_governance/__pycache__/document_io.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a78d7beac2eaf065ab2f09576f035c7d68e72553 GIT binary patch literal 7692 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 VpcnoPg`QfcStjraA~~Js{{RDxOiBO% literal 0 HcmV?d00001 diff --git a/be0/src/internal_control/it_governance/__pycache__/memory_manager.cpython-313.pyc b/be0/src/internal_control/it_governance/__pycache__/memory_manager.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc0bc3c7669abb4a7d6b7d8f569a0c774c74105b GIT binary patch literal 149 zcmey&%ge<80tgjEsy$%s>_ZHg6?; literal 0 HcmV?d00001 diff --git a/be0/src/internal_control/it_governance/document_io.py b/be0/src/internal_control/it_governance/document_io.py new file mode 100644 index 0000000..0e1b35a --- /dev/null +++ b/be0/src/internal_control/it_governance/document_io.py @@ -0,0 +1,151 @@ +import os, glob +import fitz +# import easyocr +import pymupdf +import json + +from typing import Dict, List, Optional, Any +from src.utils import initialize_a_logger + + +class DocumentIO: + def __init__(self): + self.logger = initialize_a_logger("./logs/DocumentIO.log") + self.input_filename = "" + self.output_filename = "" + self.cur_page_number = None + self.cur_page_content = None + self.content = {} + + + async def upload_document(self, input_filename:str, ext="json"): + self.input_filename = './' + input_filename + self.logger.info(f"input filename {self.input_filename}") + + self.output_filename = self.input_filename.replace("pdf",ext) + self.logger.info(f"output filename { self.output_filename}") + result = await self.load_json_file(self.output_filename) + return result + + def create_document_with_easyocr(self, file_path): + doc = fitz.open(file_path) + reader = easyocr.Reader(['en']) # Initialize once for efficiency + results = {} + + for page_num in range(doc.page_count): + page = doc[page_num] + + # Convert page to image + pix = page.get_pixmap(dpi=500) # Higher DPI for better OCR + img_data = pix.tobytes("png") + + ocr_results = reader.readtext(img_data) + + # Extract text and confidence scores + page_text = "" + word_details = [] + + for (_ , text, confidence) in ocr_results: + if confidence > 0.2: # Filter low-confidence results + page_text += text + " " + word_details.append({ + 'text': text + }) + + results[f'page_{page_num + 1}'] = { + 'text': page_text.strip(), + } + + doc.close() + return results + + + async def load_json_file(self, output_filename: str) -> Dict[str, Any]: + if not os.path.exists(output_filename): + results: Dict[str, Any] = self.create_document_with_easyocr(self.input_filename) + self.content = results + + with open(output_filename, "w", encoding="utf-8") as f: + json.dump(results, f, indent=4, ensure_ascii=False) + else: + with open(output_filename, "r", encoding="utf-8") as f: + try: + results = json.load(f) + self.content = results + except json.JSONDecodeError: + if self.logger: + self.logger.debug("Error: load_json_file failed!") + self.content = {} + return self.content + + def load_page(self, page_id: int) -> Dict[str, Any]: + page_key = f"page_{page_id}" + if not self.content: + if self.logger: + self.logger.debug("Content not loaded yet. Run load_json_file first.") + return {} + + try: + if len(self.content) == 0: + self.content = self.load_json_file(self.output_filename) + self.logger.info("Load json file in load page") + + page_data = self.content.get(page_key, {}) + except Exception as e: + if self.logger: + self.logger.debug(f"Error accessing page {page_id}: {e}") + return {} + + return page_data + + def extract_header(self, pdf_path:str, page_num:int=2, header_height_ratio=0.1): + """ + Extracts text from the top portion of a page (header area). + """ + doc = fitz.open(pdf_path) + page = doc[page_num] + blocks = page.get_text("blocks") # list of (x0, y0, x1, y1, text, block_no, block_type) + + page_height = page.rect.height + header_cutoff = page_height * header_height_ratio + + header_text = [] + for b in blocks: + x0, y0, x1, y1, text, *_ = b + if y1 <= header_cutoff: # only take text in the top area + header_text.append(text.strip()) + + return "\n".join(header_text) + + + def extract_footer(self, pdf_path, page_num=2, footer_height_ratio=0.1): + """ + Extracts text from the bottom portion of a page (footer area). + """ + doc = fitz.open(pdf_path) + page = doc[page_num] + blocks = page.get_text("blocks") # (x0, y0, x1, y1, text, block_no, block_type) + + page_height = page.rect.height + footer_cutoff = page_height * (1 - footer_height_ratio) + + footer_text = [] + for b in blocks: + x0, y0, x1, y1, text, *_ = b + if y0 >= footer_cutoff: # only take text in the bottom area + footer_text.append(text.strip()) + + return "\n".join(footer_text) + + def create_document_with_pymupdf(self, filepath): + doc_map = {} + doc = pymupdf.open(filepath) + + for i,page in enumerate(doc): # iterate the document pages + header = self.extract_header(filepath, page_num=i) + footer = self.extract_footer(filepath, page_num=i) + text = page.get_text() + text = text.replace(header, "") + text = text.replace(footer, "") + doc_map[f"page {i}"]= text + return doc_map \ No newline at end of file diff --git a/be0/src/internal_control/it_governance/memory_manager.py b/be0/src/internal_control/it_governance/memory_manager.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/internal_control/it_governance/response_manager.py b/be0/src/internal_control/it_governance/response_manager.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/internal_control/it_operations/__init__.py b/be0/src/internal_control/it_operations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/internal_control/it_operations/__pycache__/__init__.cpython-313.pyc b/be0/src/internal_control/it_operations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1d6a835be29ba08fb1640096f2ce098425d0f9b GIT binary patch literal 143 zcmey&%ge<80tKEC4TrB~SnW literal 0 HcmV?d00001 diff --git a/be0/src/internal_control/security_awareness_training/__init__.py b/be0/src/internal_control/security_awareness_training/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/internal_control/security_awareness_training/__pycache__/__init__.cpython-313.pyc b/be0/src/internal_control/security_awareness_training/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..493240911424aa5d9e7840ae84fc183d8fecbee4 GIT binary patch literal 157 zcmey&%ge<80tzNZ)ZF-#)Ux=T%(T?x%H*6>{rLFIyv&mLc)fzkTO2mI`6;D2 Ysdh!IKvO`r6oVKanHd=wi~@c;k- literal 0 HcmV?d00001 diff --git a/be0/src/internal_control/system_dev_lifecycle/__pycache__/sdlc_planning.cpython-313.pyc b/be0/src/internal_control/system_dev_lifecycle/__pycache__/sdlc_planning.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a2b3f65d6906382a5594ecc0ff1525743584ee67 GIT binary patch literal 15708 zcmcgzX>c3YeO~~Jn$wQ8HJQ6_Ygr0ZS4R2++HrWMZZ% zC+SS7w5g>g9|^qW^tKi+BOxOX^_6D_B? zQx@5B$|_q0dbXano#JKwlwG!;a>$NTPT9%IZKqwQT(XPh`O|LM-N1EnqP>X|9aH=s zo7S%E5uH;W(KY26;zf58cZHX|rVPnRm`ci7iO*D0!AdGkB|cVC zWh$v;CDo>qDppdXmpH|0I|hw$*0AzgQ_Wgd;y0D}SxKE(*Tl*7VmASXGah3a6wKa(I4LwT+A&5Adq{!s5IXJsyjsRQ^m% zNvW=L^Ql-e5ss_QGqIE;hvNZ@>WbpKRBTQfiUk+A zmdT!(@<4@JJ%09NA|=hpVVVw`_!_Z~S{_-DWhs#g&CiAvNiAbZD6ueiS&~&>WLAn? zjbkA~nEafAg5=zMTuMn%y`XAdN87h2C5U$MrY9P-r?PE2(%aAtjP{ZBtU1O(+yjB$DVG z-6&KVOQd86tpXoqB)^8f62zyH3|xdF zNi>p-2bDzyOB9Mq*Fy2wv=mv4#HFATjYmTBade9{>YHDbY0cSHv`}6+BmF`=w=lFKYb=~?@j&IA?H?JJKwV30Dd|PLZ@66ZM=lJ@3MfK;#@(nFH zz9nDR^aH+0?ku*g+G&9$)geo%1vwGXIR~#fj-)98_2V>dkeL?ID%wO|w2O{DfJ=1Y z$t`-=lUFP=J(Y_UrYE0RS^6$$PgumN2~MnLptB%XL%Bj2vJFi%_IbXE+Q$4>eQin;S=A6Z{D-3)>u^KQ(@(*>d?ViB};EE z!~jME+Nw(fxg@Va-*D;OC!-i?YxfL{QO1fBi#CIkf>N03$o^c9aq!k1(5SZZ;Nr>}M$2j|IiXi{JYBO1 zapZC|EmzRZhubIZ8(Z%cM8*xED0P(XezL-oeacnTAhB7%Dt9AME157F2pLjHp>-Do zxr^R572j&;AwtPe2k=Nn&6e?NiE39-o zSjk*EvT5njO-m;>w0p3-!I9x>3ui1`+u+j3axlv`&xoDe!qEd8?mM(qZd3eA)10kwU_ktXt2K<|@g~kQ_s8C{s^o}QyzWK*nfPMEC zU~gey-wj1rqDrVZO!-9&f^>=U8QcuGU)~g~2RAJ}w*kWKbsB{Gxwbt|L-6TL$C+oq zm*~adO9tB(@YOX=&IOQT{f z*A{#V6+60~K|+Ja&!D%BB-E`+T}3e-H^^Axb`Z3jc$^WGDJ(`%UwTX<=pG`dq2WPH z7$%_Af}|TkPyjkLnYY1Mom7vqaCt5UtE^~Ptr2NIIc$6l)dWm{=tetFBd-Av6J6KH z97+d7ts_7QmJ`!y%nd5_EI?$89T`8OdQB72FffP~o{fP$TVSA?VPMZX3y28uRs1Uw zZU+4|OJ$v{15_N|fC>|Dzwm#8ieHK3Kds?n?=b9F!?P8(7fsI#FlmXZy#nPnBQ9ik z(IH+VV>W&Gf2slCAyQxKQ~+7V>3z?m0yJA$(Z;D6Ji6i0^0b+0$k~r>O2|oXd(!^c zr1`D%#@;OerkUD%;mI2-7Ans9RSe*}_%|sLKx%WKo!wfAy!aHLb)L+0oO)gf^ee&I z5e;kmNCwTvlZ#BcY!{A1$Zrk9dj-r!V+aS3+or`q1QI?`qxM=Q5$YAXnMDUl`}E7t zgOXBHMjso@g>?NEgVT_5fchq!GpB7cq?kTGqEBNhr0m-QDVm+S52C2JOrfx`Nb*JW z{t^D42P+r1#>&3`C05AXV+O|+WS*|ASS+LzAJHNr6bT{siic@pI8~+i2Q(Rreu%Zq zTJ@7wW4f#^eOSZM{$X;kqYDv=L;*VIlS-<$v}D3u60wh@OyY}B#DZ=yg4tR%+DZ={ z)1z?0*;usq7)6@kC|!#oAh2^#Sd_vt!Uxj`6_8z{_YznKyBccd;7N+*F(^l43X~S& zEf`LCAvKG>Q!xY~=Fx_RXecyOC1tIaTG%fl`uF4@WahMyx+@x58&&F>pFThOJh*>i ziU=tp=z`D)jaCzss;9J^8rX-?z6tF8Pm0AS+C64&cPrfQ-vaklOa<=`l@?rn14DR( zf8|TK85;Q123?C71jOa5o0g7kTB_M_r6@*kt7gL;;@Wzi#uyhe9TU$GqJ|~@A%^@8 z*UtgqmIMXvSa`pNd!>ux61EAOXrt(o$PZuVxF~`$XCYTad%`L@@Xkr^DqyiA@7D5z z{3UEvP@b6K>^6?`aspZI9x5Y&NLRQ;Zv|{NZVp;%uZ{Y}fPMuxXq`ZWgW3esF{AO4 zpt*{S;VRRIFKU5h3E{YKN{>Bg0WyJww8{D}#^4wYg)o9*Mwt5o6P7aeBskS=l#ZtD zClTxw9Y3nXQbsLI8CSH*1aKqLDeFmQwf2MB7YUR#lKJWG2blo_#GWn5rXTGIl`D{Koboau&?r*++_e|#H^O;vC zG8d<^ul`!*{2Q6xP^PTy_VM3O=gN8>Ik~C;qHC`5Ou2C1*^zH(T`te>I{ZHWx9$(z z-`kcsAIYAI=3bUEho>_=GZ|OY%E7E_$HUH{jHfM&Ah*z)@w8<5)>7!}J)7z&MgAE# zt2Qj+x{w&f0ht8vL*^X zCM=Vq+_QbUb-oli|eR0hdgP z%mQu$lw*!F^PHFw%zX91^7-nzCrM*IUtP~6?FBtwUGHSmrgdA3rKMD~6j8t^+g^H8%wc!c zy>5jzt7CnRIxwW3*$&RAJhKjt3Ouup<2mX$pQDazL!FM|s+3aEJ=s}&!p4<%vfFqn z>$4Tl;cU6Oo((;0wz{S^*Ug-C$%d~%oICDTo0+i92aAJlX6EW11Fb#8I z{H!_hq&k=$7|_$*dOE15LwdSLPxtESK0V#9x{6kuW<9Vy)@ckeP%D!#Vy6bM1s#os z63M9aysC;>fHG+tCM8v$BBesuww4z|^Ds4W!i8FtqBD}}r(7h7^Bm+^;b;SuDl%Co zGLM_;Bnt=;bvgo5RM2M8+!kSL7Lw=IMT&}5<K-|Hx973bWp}`UFjhQ%`_w8QER8bxH3g%7=&iZ$ zZO(d|bKcer->O*>J|$9npCl_eV%h3$w}VEUvJinTcRQQ%urePZh_m(C%ou9fk^`1&E`p zXI2WokntDrn9O&I*vc$SS4BC}Tbdn0TFK<4Ss9R32c2uWDg|t+4ZfUO9@EaNu){;_ z>h0HWyuMPNE8o5}`p{dobe54!b&}5*ibXXy zmbjRLJ`v*S;zfBz$*&+&av=oUqUly`sl|sare8Hep310OjPr!w_lt*qJvtSu#mjgt3I z5i-JpGA~6aFeDh~MVO1PdGO?^v-!)PrW_+P(G{}H0rJ7Y4A?cJ{_{r7-=IF6eb_PyA%qIUXs{9GjZx$J5gI3j<-E02F@JjthULoJwb+h5dSl;JfY0mk& z@6=}woy+b&p9`GNGk-i=x$}NyFk2bC>s+ne|HHDXM_%~YKdsV>!voxNDga~Al7)(NW54nQJhfg+?V zAXKum$IT$c)aWXm4IM|j8?U115xsqG0&X|Pugzha3zVg|pO6wb#!2T;m@g?q1tG*xBVae|Av4se z-V}}?#W(Q%m>qNBR1v8(3iFZ7ND>}c zv3>0UM*wL$8v$I04Q;jxI(l)ggw6U7&>|^k<ycU0<#`xO5sSsHuJBH?oaE1cg=- z*@oWysfo;`PyIvib}-v{`2DW$caCI2pUyY;!z1h}%`L?c1_x^0#fqYYY zM(EEr4LtC-zB~1MQ(t`JzP~r?@6Gvxk7_&>j-}B;16NkPboOUIY3G_}ED8bt3uQ-b zuCF?9Q@tiPbRB~jG0xW+gS0Wfw;!hrN@br2Sy`b}(9k zKLTdZ(rHV{V3=pgMDhLZ7kcrF}L zVso*07&{D$0>mz}LWS$G)GRpCL^L6V*qq06M4nNGJxuhL;tIpU1vI*`G8qO%TniEo zFh^-yUm;taHQ*U%$6@v6=#V#=xj5_yo0kqrl9^z9hOHB}qp}Oz9?+Hwsw%tKZVb*G zOL3ST(@76(cNr#eDqX+D1k+`7&H&usO9#u-mC!VFsFOMlUQv<>oI>X*Rh_dq;Vj9D zYL{d=DW@wbDnk3#)9eV#urQWx8r4p-V3zaP;i36s(IL{VK)piuQlZ_x%O%}p)#I8rQ$jel2k)Dt>o6}zYI;#H-$(p}L1`4yb!Tz!ZQls2| z^J>9nt??{7AA7jkmUmye_0j`h^Kv57GMFhF%2zey{au-D`!cor3r?=KqrmaCo@G~| zf^%2hcXec49jmS`v{T*mZtJbqFSai`9{T*t<^TC(vS!<|-nRSRVAdP_n6udT<}2$n z%`bf8*j>xlNAK*gQ`S}B{@4mM^>usl6FI!l(J>QAk>-hHc-AkFqLmA)U zk6ed;Ua-;IPn3Sl;Il_;M;zP-j<%ycwhy{}M-JFN7_?CSKt1jsR{D-^vwhf3B_D3H z zsU^nZ$|k&7s>^I>=50&i$gKHe4^9aa5*h4Zgu5mDXo2Z8hAjCDcn7e41BvPcM%ix) zEHTxF0kCg2;(0BKpx(wJtJ^BBp1O84CnvMP#~ zp`rejX(&3Pf0~2gRU)wwW>45mr6nVcNBq`y5bZ_L7V8ywLEol4Eq#-X$sq2yiZ}&Rwrkf(`^vi-7s4n_}f$4({42WM> zjM6V*%-?F7RadDv=`BLKV;kSpda1^iI+H1ELtEm!0a`E(Tfx~k$-}(xG5I!To^CCv zuT7LRc2uZVky8hXncZrY(Fr!!nMww;n`P&~x~JW0WoZ~8HVkM-J7fcXeM2fowTERn zyr|k{u+0G91lDylnNl72txz~Vqw)!u*s2|SPq4GS^0!ez{tnf$Cg$b8pr^m2Cmy2$ z@8Z5&_2T>D#y@((_XAq8TlJL2q7YbV(e+E)x@{*%0VH21ZcG-E{P+0k7bJOpfy@&3 zF@iSr2%P!0-M)~k=vg}PprTvPJ#<#w+>vuOuDo`~nQIL`^bh6h@!O#`M0_eeP?GEi z4`mf^@4vDC=81Pt-#VQU2JepNYWL^72bPZKz2#fHL2oYa^3dlNZw`F!*S}J;a^cI3 zw_p8oYrd}G-RWD?D;IKgJD0ur%IbIBx7_bm+^Sefb$0J8e&U{#QiYaD_%9PAnGt%SB+X(q~D!y-Byv((MM_+UQ267Kxl=qR-OylgpNROZB6na{WEhdxHfI z$@gq5$z1%DmPQu-or()>)s|{~;??(RX*AV^ot>7KEqCw%rFZ$e;=QiBm%dr?m^~L> zvD8> RMIntegrationState: + """Phase 1: Concept Development - Initial records planning""" + + phase1_checklist = [ + { + "id": 1, + "task": "Include Records Officer in system design process", + "status": "pending", + "requires_approval": True, + "approver": "Records Officer" + }, + { + "id": 2, + "task": "Identify records that support the business process", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 3, + "task": "Evaluate current record schedules applicability", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 4, + "task": "Determine if new record schedule is required", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 5, + "task": "Obtain Records Officer signature on Investment Summary Proposal", + "status": "pending", + "requires_approval": True, + "approver": "Records Officer" + } + ] + + state["current_phase"] = "Concept Development" + state["phase_number"] = 1 + state["checklist_items"] = phase1_checklist + state["pending_approvals"] = ["Records Officer - System Design", "Records Officer - Investment Summary"] + + return state + +# Phase 2: Requirements Document +def phase2_requirements_document(state: RMIntegrationState) -> RMIntegrationState: + """Phase 2: Requirements Document - Document all records requirements""" + + phase2_checklist = [ + { + "id": 6, + "task": "Identify and incorporate all records-related requirements into CONOPS Report", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 7, + "task": "Draft new records schedules if needed", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 8, + "task": "Obtain Records Officer signature on requirements document", + "status": "pending", + "requires_approval": True, + "approver": "Records Officer" + } + ] + + state["current_phase"] = "Requirements Document" + state["phase_number"] = 2 + state["checklist_items"] = phase2_checklist + state["pending_approvals"] = ["Records Officer - Requirements Document"] + + return state + +# Phase 3: Design +def phase3_design(state: RMIntegrationState) -> RMIntegrationState: + """Phase 3: Design - Incorporate records management into system design""" + + phase3_checklist = [ + { + "id": 9, + "task": "Incorporate records management requirements into system design", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 10, + "task": "Obtain Records Officer signature on system design document", + "status": "pending", + "requires_approval": True, + "approver": "Records Officer" + } + ] + + state["current_phase"] = "Design" + state["phase_number"] = 3 + state["checklist_items"] = phase3_checklist + state["pending_approvals"] = ["Records Officer - System Design"] + + return state + +# Phase 4: Detailed Design +def phase4_detailed_design(state: RMIntegrationState) -> RMIntegrationState: + """Phase 4: Detailed Design - Include records staff in project meetings""" + + phase4_checklist = [ + { + "id": 11, + "task": "Include agency records management staff in project status meetings", + "status": "pending", + "requires_approval": False, + "approver": None + } + ] + + state["current_phase"] = "Detailed Design" + state["phase_number"] = 4 + state["checklist_items"] = phase4_checklist + state["pending_approvals"] = [] + + return state + +# Phase 5: Development +def phase5_development(state: RMIntegrationState) -> RMIntegrationState: + """Phase 5: Development - Continue records staff involvement and submit schedules""" + + phase5_checklist = [ + { + "id": 12, + "task": "Continue including records management staff in project meetings", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 13, + "task": "Submit proposed records schedules to NARA", + "status": "pending", + "requires_approval": False, + "approver": None + } + ] + + state["current_phase"] = "Development" + state["phase_number"] = 5 + state["checklist_items"] = phase5_checklist + state["pending_approvals"] = [] + + return state + +# Phase 6: Integration & System Testing +def phase6_integration_testing(state: RMIntegrationState) -> RMIntegrationState: + """Phase 6: Integration & System Testing - Test records management integration""" + + phase6_checklist = [ + { + "id": 14, + "task": "Incorporate records management requirements into system testing", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 15, + "task": "Obtain Records Officer signature on Systems Test Report", + "status": "pending", + "requires_approval": True, + "approver": "Records Officer" + } + ] + + state["current_phase"] = "Integration & System Testing" + state["phase_number"] = 6 + state["checklist_items"] = phase6_checklist + state["pending_approvals"] = ["Records Officer - Systems Test Report"] + + return state + +# Phase 7: Deployment & Acceptance +def phase7_deployment_acceptance(state: RMIntegrationState) -> RMIntegrationState: + """Phase 7: Deployment & Acceptance - Final approvals and deployment""" + + phase7_checklist = [ + { + "id": 16, + "task": "Continue including records management staff in project meetings", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 17, + "task": "Obtain Records Officer signature on deployment approval document", + "status": "pending", + "requires_approval": True, + "approver": "Records Officer" + } + ] + + state["current_phase"] = "Deployment & Acceptance" + state["phase_number"] = 7 + state["checklist_items"] = phase7_checklist + state["pending_approvals"] = ["Records Officer - Deployment Approval"] + + return state + +# Phase 8: Production +def phase8_production(state: RMIntegrationState) -> RMIntegrationState: + """Phase 8: Production - Post-deployment monitoring and compliance""" + + phase8_checklist = [ + { + "id": 18, + "task": "Complete Mid-Cycle Review (3 years after production)", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 19, + "task": "Implement disposition authorities per approved dispositions", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 20, + "task": "Send Mid-Cycle Review report to records management staff", + "status": "pending", + "requires_approval": False, + "approver": None + }, + { + "id": 21, + "task": "Obtain Records Officer signature on Mid-Cycle Review certification", + "status": "pending", + "requires_approval": True, + "approver": "Records Officer" + } + ] + + state["current_phase"] = "Production" + state["phase_number"] = 8 + state["checklist_items"] = phase8_checklist + state["pending_approvals"] = ["Records Officer - Mid-Cycle Review Certification"] + + return state + +# Validation and routing functions +def validate_phase_completion(state: RMIntegrationState) -> RMIntegrationState: + """Validate that all required items in current phase are completed""" + + validation_results = {} + all_completed = True + + for item in state["checklist_items"]: + if item["status"] != "completed": + all_completed = False + validation_results[item["id"]] = f"Item {item['id']} not completed: {item['task']}" + + if state["pending_approvals"]: + all_completed = False + validation_results["approvals"] = f"Pending approvals: {', '.join(state['pending_approvals'])}" + + state["validation_results"] = validation_results + state["next_phase_ready"] = all_completed + + return state + +def route_next_phase(state: RMIntegrationState) -> Literal["next_phase", "current_phase", "end"]: + """Route to next phase or stay in current phase based on completion status""" + + if not state["next_phase_ready"]: + return "current_phase" + elif state["phase_number"] >= 8: + return "end" + else: + return "next_phase" + +def progress_to_next_phase(state: RMIntegrationState) -> RMIntegrationState: + """Progress to the next phase in the workflow""" + + next_phase = state["phase_number"] + 1 + + # Map phase numbers to phase functions + phase_functions = { + 1: phase1_concept_development, + 2: phase2_requirements_document, + 3: phase3_design, + 4: phase4_detailed_design, + 5: phase5_development, + 6: phase6_integration_testing, + 7: phase7_deployment_acceptance, + 8: phase8_production + } + + if next_phase in phase_functions: + return phase_functions[next_phase](state) + else: + state["current_phase"] = "Completed" + state["project_status"] = "All phases completed successfully" + return state + +def stay_current_phase(state: RMIntegrationState) -> RMIntegrationState: + """Stay in current phase until requirements are met""" + + state["project_status"] = f"Phase {state['phase_number']} ({state['current_phase']}) - Requirements not met" + return state + +# Create the workflow graph +def create_rm_integration_workflow(): + """Create the LangGraph workflow for RM Integration""" + + workflow = StateGraph(RMIntegrationState) + + # Add nodes + workflow.add_node("phase1", phase1_concept_development) + workflow.add_node("phase2", phase2_requirements_document) + workflow.add_node("phase3", phase3_design) + workflow.add_node("phase4", phase4_detailed_design) + workflow.add_node("phase5", phase5_development) + workflow.add_node("phase6", phase6_integration_testing) + workflow.add_node("phase7", phase7_deployment_acceptance) + workflow.add_node("phase8", phase8_production) + workflow.add_node("validate", validate_phase_completion) + workflow.add_node("next_phase", progress_to_next_phase) + workflow.add_node("current_phase", stay_current_phase) + + # Set entry point + workflow.set_entry_point("phase1") + + # Add edges + workflow.add_edge("phase1", "validate") + workflow.add_edge("phase2", "validate") + workflow.add_edge("phase3", "validate") + workflow.add_edge("phase4", "validate") + workflow.add_edge("phase5", "validate") + workflow.add_edge("phase6", "validate") + workflow.add_edge("phase7", "validate") + workflow.add_edge("phase8", "validate") + + # Add conditional edges from validate + workflow.add_conditional_edges( + "validate", + route_next_phase, + { + "next_phase": "next_phase", + "current_phase": "current_phase", + "end": END + } + ) + + # Add edge from next_phase back to appropriate phase + workflow.add_edge("next_phase", "validate") + workflow.add_edge("current_phase", END) + + return workflow.compile() + +# Example usage and testing +def run_rm_integration_example(): + """Example of how to run the RM integration workflow""" + + # Initialize the workflow + app = create_rm_integration_workflow() + + # Initial state + initial_state = { + "current_phase": "", + "phase_number": 0, + "checklist_items": [], + "completed_items": [], + "pending_approvals": [], + "records_officer_involved": False, + "project_status": "Starting RM Integration Process", + "comments": {}, + "validation_results": {}, + "next_phase_ready": False + } + + # Run the workflow + result = app.invoke(initial_state) + + return result + +# Utility functions for workflow management +def update_item_status(state: RMIntegrationState, item_id: int, status: str, comment: str = "") -> RMIntegrationState: + """Update the status of a specific checklist item""" + + for item in state["checklist_items"]: + if item["id"] == item_id: + item["status"] = status + if status == "completed" and item_id not in state["completed_items"]: + state["completed_items"].append(item_id) + break + + if comment: + state["comments"][item_id] = comment + + return state + +def generate_status_report(state: RMIntegrationState) -> dict: + """Generate a comprehensive status report""" + + completed_count = len([item for item in state["checklist_items"] if item["status"] == "completed"]) + total_count = len(state["checklist_items"]) + + report = { + "current_phase": state["current_phase"], + "phase_number": state["phase_number"], + "completion_percentage": (completed_count / total_count * 100) if total_count > 0 else 0, + "completed_items": completed_count, + "total_items": total_count, + "pending_approvals": state["pending_approvals"], + "validation_results": state["validation_results"], + "project_status": state["project_status"], + "timestamp": datetime.now().isoformat() + } + + return report + +def test_ollama_similarity(requirement: str) -> Dict[str, Any]: + """ + Call the /test_ollama_similarity endpoint with a requirement text + + Args: + requirement: The requirement text to generate embeddings for + + Returns: + Dictionary containing embedding preview, dimensions, and model info + """ + try: + # Prepare the request payload + payload = { + "prompt": requirement + } + + # Make POST request to the API + response = requests.post( + f"{API_BASE_URL}/test_ollama_similarity", + json=payload, + headers={"Content-Type": "application/json"} + ) + + # Check if request was successful + response.raise_for_status() + + # Parse and return the JSON response + result = response.json() + + return result + + except requests.exceptions.RequestException as e: + return {"error": f"Request failed: {str(e)}"} + except json.JSONDecodeError as e: + return {"error": f"Failed to parse response: {str(e)}"} + +def test_multiple_requirements(requirements: list) -> list: + """ + Process multiple requirements and return their embeddings + + Args: + requirements: List of requirement texts + + Returns: + List of results for each requirement + """ + results = [] + + for i, req in enumerate(requirements): + result = test_ollama_similarity(req) + results.append({ + "requirement": req, + "result": result + }) + + return results + +def compare_requirements_similarity(req1: str, req2: str) -> Dict[str, Any]: + """ + Compare similarity between two requirements using cosine similarity + + Args: + req1: First requirement text + req2: Second requirement text + + Returns: + Dictionary with embeddings and similarity score + """ + import numpy as np + + # Get embeddings for both requirements + result1 = test_ollama_similarity(req1) + result2 = test_ollama_similarity(req2) + + if "error" in result1 or "error" in result2: + return { + "error": "Failed to generate embeddings", + "result1": result1, + "result2": result2 + } + + # For full comparison, we need the complete embeddings + # This is just a demo with preview data + emb1 = np.array(result1.get('embedding_preview', [])) + emb2 = np.array(result2.get('embedding_preview', [])) + + # Calculate cosine similarity + if len(emb1) > 0 and len(emb2) > 0: + dot_product = np.dot(emb1, emb2) + norm1 = np.linalg.norm(emb1) + norm2 = np.linalg.norm(emb2) + similarity = dot_product / (norm1 * norm2) + else: + similarity = None + + return { + "requirement1": req1, + "requirement2": req2, + "similarity_score": float(similarity) if similarity is not None else None + } + + + +if __name__ == "__main__": + # Run example + result = run_rm_integration_example() + print("RM Integration Workflow Result:") + print(json.dumps(generate_status_report(result), indent=2)) \ No newline at end of file diff --git a/be0/src/internal_control/system_interface_data_transfer/__init__.py b/be0/src/internal_control/system_interface_data_transfer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/internal_control/system_interface_data_transfer/__pycache__/__init__.cpython-313.pyc b/be0/src/internal_control/system_interface_data_transfer/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3db5db5059620e81dd93fd0e3b9f922fa8f33a6 GIT binary patch literal 160 zcmey&%ge<80tzNZ)ZBQmcv@m|YJ5s!Nn(6SQDRTZlX-=wL5i8IKEC70jBc=cV literal 0 HcmV?d00001 diff --git a/be0/src/memory_manager.py b/be0/src/memory_manager.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/minio/002_add_upload_status.py b/be0/src/minio/002_add_upload_status.py new file mode 100644 index 0000000..6d44ea1 --- /dev/null +++ b/be0/src/minio/002_add_upload_status.py @@ -0,0 +1,51 @@ +"""add upload_status to attachments + +Tracks the two-phase upload lifecycle: + PENDING - row created, awaiting client's direct upload to MinIO + CONFIRMED - client finished upload, /confirm endpoint verified head_object + FAILED - presign issued but client never completed; swept by cron + +Revision ID: 002_add_upload_status +Revises: 001_initial_schema +""" +from alembic import op +import sqlalchemy as sa + + +revision = "002_add_upload_status" +down_revision = "001_initial_schema" + + +def upgrade(): + op.add_column( + "attachments", + sa.Column( + "upload_status", + sa.String(16), + nullable=False, + server_default="CONFIRMED", # existing rows are already confirmed + ), + ) + op.create_check_constraint( + "chk_attachment_upload_status", + "attachments", + "upload_status IN ('PENDING','CONFIRMED','FAILED')", + ) + op.add_column( + "attachments", + sa.Column("sha256", sa.String(64), nullable=True), + ) + # Partial index so the cleanup cron can find stale PENDINGs fast + op.create_index( + "idx_attach_pending", + "attachments", + ["uploaded_at"], + postgresql_where=sa.text("upload_status = 'PENDING'"), + ) + + +def downgrade(): + op.drop_index("idx_attach_pending", table_name="attachments") + op.drop_column("attachments", "sha256") + op.drop_constraint("chk_attachment_upload_status", "attachments") + op.drop_column("attachments", "upload_status") diff --git a/be0/src/minio/__init__.py b/be0/src/minio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be0/src/minio/__pycache__/002_add_upload_status.cpython-313.pyc b/be0/src/minio/__pycache__/002_add_upload_status.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..130fe845c06c4abf19e3d7363d3b2f6f9708c50b GIT binary patch literal 2060 zcmbVN&u`mQ9Jd|YvE8ICWo^_SOpgtc_S8hG>XOuG3$n?gkw~dqoH@+`+(gcD)Sn}83=kKpi{63$TW;(4f z^g8&%IuRJ=*VZC7T>Na#gY%S88N#Tn%H3x_<_L$VpSYhOyvh?n6^N*c6AVeJNw88m zk(G3vWfh3kKqWd^5LAJGl zjcvomHxz_Ey;oex7e7J>T|>mJBZFW)z~&s%>$(+KP8k`t1wChcWLg9pLAwh~ELcu{ z4SL*NE3W3(3-?x_N0f~=EXVRIxD9g-!Z=O<&17pmJK=AD~6+e>%)=1*BSBN zwjPuM3Amekuwze|hn?7Zn*yCT408oG{vqFfu`af~izj4oT^Z6vr0qdQVi9K1OXXET z6_@zMcoyoOACWUkj4jkAfqK#+MGRL@{{O`*|0`algLgnpEhYM*O+|>hEuc*U+QD4+ zHyiBEhP#43;t^A3vac1xfiA6t=^k2~4MXW_D0euW0>sly zyrI|ZAe=HPo7zRQUWPrs-qRW7i)eNx*4xb7OlJc45$R{Lq3Boig~j*IJ^&OCvMd>Z zXEWl#n4|}d3ltD?K+giPX3C>D{?sBM5O&G3S zt2!a)>!Elz@Zrb<(POYIM{IalHXh@@{lJZ2}lN+hY)70RT{9b;) z^mU;zI?ygWJCi zrnBWoBh!b=$NU}2fZ}d(fB9hc1^+gzPWZ87e(Wf-c=*K&{@z76_cOoH8jzL4q^6m! zp=n`Kw{f*(88KSmyKd9te4AXNC?pd9CxmH=M{6%zG$a(*UvW#fs;*hH@okcYC_P#I tSuigXEX%&)WS0N^62oSGVKOg8CUtd3Y7D=%lm1N{XT|3GJo|17;GaQ};wk_D literal 0 HcmV?d00001 diff --git a/be0/src/minio/__pycache__/__init__.cpython-311.pyc b/be0/src/minio/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bd455ce7e36193be1c605f54c081d1bafca23f9 GIT binary patch literal 135 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 TWx`#T2^`9tfN2FRJt+Ram`T4K literal 0 HcmV?d00001 diff --git a/be0/src/minio/__pycache__/cleanup.cpython-313.pyc b/be0/src/minio/__pycache__/cleanup.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0611569369fad39e783e71f564ef3bb578f8e23a GIT binary patch literal 5276 zcmb^#TTmO<_3lfL)e07b0As)xj306g7{Bo&4aS&<0H<2icnYazkyfBstjOJ!9hgjq zwlj_0bjEh4nYc5V)*qRU^V8{P`qd;&YxfWNCAy_enoK|ZCmVO%B>m{QyAqOZlK%8E z+B3qp)FSge5GdFc;saz5>L=(`0^D)p0znsd{!Ef41~7|4i%ctsk##GG|m~1@6S)PPyYG!Iqvplq4 z%;=isr4Yzss`P*{s}d~{Ggad(OnFm#&9s*n^mt6uXMjtHwcLi8A?gfJF*Fl&n=$TF zUjamc8q=7#&(SGs@zZc^mTyjdEk-nxq%p(l21bnAOTqUSHDqJr!Nom`y6FxPaope4pScyo*jf$at-HlnKb5Sl*U9njN zCu>S;3Sas(6d}s)VGa~!j08#;<+1d6=zNq9prI*{MQBBn!4k$~eF=&*mZ0(&6(UXL zSqUof!{~oX>RFf63$)rx6v|^+dZ5NA;&JvkOCylt>+~aJa0a^rW@Fz&$cG?!xanV+ z|NqshQc2d*7(m}~8*C3USg;)2i0+QMH?l{H9fP}}^64JkmatOBZlrrBx0N*hW_y=8 z4>bcnF)F^~ISL+|fEyn3Ar$pSs5zc$QL%Imqp?;;lBgGCK1yl8ZHGz+zL|Setk~aG zI#V8jPtot~yYhd{T^)BMm;_0j7U?cQ1_*~!3eOs z92^7I3|4n1LB6lU9)DP(_k;#*PifFuWK}UJRcC(vtgK^y2miOFF3TE{7f(bqNImgX zT6-ehRHPMCorX<28`v2L;&S|~W@0!7>B=;^2#pjF7pvzrJppku=H!5c)s(SQW6Vi6 zMy#AR5)f5cb8+z_$URRsWngrmf1)C<#ZrmgI0^E_=2dg{8GMXSMNW@Z;2Y1Pyficr z8Nk{3bVg0Y49FZ=10TmbiaB)0vj;Hl8$Ve*0C@v))-ik}o;5ScY%h_0Lb*xj& zw@JwUTEb3`+l(W4n=!uFa}so$7>&4Bd9Oh4EYDbL4hNtS41f)wUU#OAL@6h7(%y~kCrNw2 zb!{x~z-w`Bp5}GSmCnq}XvA`#SBVZjW%29fQ!kN>iiZhfit=6YX#cn)=05&cEwU=kq!L^Gl~zy>)qS(=Bh)9dGkBex*8a z>Gg}RuS#-(6>93%IJdv5Aj8?q=a!|`wSXv#kL!>xaB=6Gy9z<%^{+Ley2cCr1)lRq znN_*@oiq8Cqq&x&?<>E5`FAhhk;4TK^4DA%y*Rom*DVK!Zp*_fHNne9zF}vsVdt_O zdL(eb0+4E#Yq#E(I<7N0DRfurc+dcZA8$rt%cFxx3|7bpdet|*J#}U3-HzoQBe&bb zcY>p90v8ZJ3yxl93LNrRT?+p+{FmTpK|o%)fMDw}2ZRa|3IvzJtJQ7q?##EpkZXV8 zUUlCEZpBx1dFHOKWkqVpORYJn^{R1C+WJ61@(E^5Lh|MdT!Hm^Pcf@f{i@uUm%DOu z*NPml;m(}g3GgGY09r`47G1Zc?%($0r2TiL?guq+{!t?md~bw*5C(OxAGpK!toY=m z(SJW0W>8(r8e(kA)xTYJrD}Q0w>}8mX!)>Z`9&o!Pu!9xzAA8Z|I5c)L4kYT=KnlA zTvF$*xB06_yOF#JNZ8WV=B4JG)V$nsK$OCt?#@gd(c*vox*uwxMM zA2l5Y_z&GJLo)ZVR6pc%eca9h{Kw8iLv8F0Bn}>Q-w@jdd)zmka{=a!9)YeLYcbWU^$+n zqOxzLvj4^g@P_j1OqAJ32Zbq>wK^MRBb3%tEeamo%Rr$EJnUza`zZvNDqJ@zJs1F! zKE~iFr0|s7IrNqQB{TS^4~o9AZO%A@4sl5)B#c|tl}RghZZ9~)DL7_%`ilh>8H8zw zFWPek+;U!pT&qH|uslUjvpCoY31k>>$su~)TgKu61PrLV3@VYGk!&|39Tc5*ufV@C z0o{FSc5m>_)}<3Ga&2D5ITkV7?lzcA?g^@n|dz919l0W39YNsiteycjpW zHL&7Fw-#li9QD2~7Vd_F(w^Vw<|_P?8z%$6BgEOZwJmCi{h9d%1)lP84c2XM^~GVS z=&NLi9uO=Ma(1y$f!_oGrr)lZfI`FZWXS&j!=i2WrwzOvh!DF2!03gpY+l~=#`q7$ zSE@IyNP(5sow-17!HtB#1H=oif`|lnp0CUCbyr80`MO*De%tdyn=E%MmdM0oF(_(I{bC(D{m~L)l8dhisvpkmflym!UuZ zJWmp^Pa}+xfetbn!#rR;4F9iM#BBW>Regf?=Fr}MqSn8m&Cq{>LZ5nNr(U>v__nuW z$@P!AmTP?w*OpPu0~ccbmpfp}Y=6KWWop3h*c}B5J^Vi7Wn2#%8K(E4M_^jkWH)ob G7U;jQ{gz|^ literal 0 HcmV?d00001 diff --git a/be0/src/minio/__pycache__/storage.cpython-311.pyc b/be0/src/minio/__pycache__/storage.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8ff5cae745c8f21e62a8b77693dbd0e8b85aa4e GIT binary patch literal 18886 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 literal 0 HcmV?d00001 diff --git a/be0/src/minio/__pycache__/test_storage.cpython-313-pytest-8.3.4.pyc b/be0/src/minio/__pycache__/test_storage.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ab6747385cc2e58a420d9a76632606898658180 GIT binary patch literal 15355 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 literal 0 HcmV?d00001 diff --git a/be0/src/minio/__pycache__/test_storage.cpython-313.pyc b/be0/src/minio/__pycache__/test_storage.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43848143a2c443e7152865b3fd092118bb84d0ad GIT binary patch literal 10572 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 EAB&Q|UjP6A literal 0 HcmV?d00001 diff --git a/be0/src/minio/attachments.py b/be0/src/minio/attachments.py new file mode 100644 index 0000000..948688a --- /dev/null +++ b/be0/src/minio/attachments.py @@ -0,0 +1,322 @@ +""" +Attachment API endpoints. + +Two upload flows: + (A) Small files (< 10 MB): multipart/form-data → FastAPI → MinIO + (B) Large files: client requests signed URL → uploads direct to MinIO +""" +from __future__ import annotations + +import io +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from app.auth.dependencies import get_current_user, User +from app.db import get_db +from app.storage import storage, settings, ALLOWED_MIME_TYPES, StorageError +from app.repositories import attachment_repo, application_repo + +router = APIRouter(prefix="/applications/{app_id}/attachments", tags=["attachments"]) + +SMALL_FILE_LIMIT_BYTES = 10 * 1024 * 1024 # 10 MB + + +# ---------------------------------------------------------------------- # +# Request / response models +# ---------------------------------------------------------------------- # +class AttachmentOut(BaseModel): + attachment_id: int + file_name: str + file_size: int + mime_type: str + kind: str | None + uploaded_at: str + download_url: str | None = None + + +class PresignedUploadRequest(BaseModel): + file_name: str = Field(min_length=1, max_length=255) + mime_type: str + file_size: int = Field(gt=0) + kind: str | None = None + + +class PresignedUploadResponse(BaseModel): + upload_url: str + headers: dict[str, str] + object_key: str + # Client calls POST /attachments/confirm with this after upload completes + pending_attachment_id: int + + +# ---------------------------------------------------------------------- # +# (A) Direct upload: multipart → FastAPI → MinIO +# ---------------------------------------------------------------------- # +@router.post("", response_model=AttachmentOut, status_code=status.HTTP_201_CREATED) +async def upload_attachment( + app_id: int, + file: Annotated[UploadFile, File(description="Attachment file")], + kind: str | None = None, + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """ + Upload a small file (< 10 MB) through the API. + + For files larger than this, call POST /attachments/presign instead — + proxying large uploads through FastAPI wastes memory and CPU. + """ + # 1. Authorization: user must be author of the app, and app must be DRAFT + app = await application_repo.get(db, app_id) + if app is None: + raise HTTPException(404, "Application not found") + if app.status != "DRAFT": + raise HTTPException(422, "Attachments can only be added to DRAFT applications") + if not await application_repo.is_author(db, app_id, user.id): + raise HTTPException(403, "Only authors may upload attachments") + + # 2. Validate MIME type — never trust the client alone + if file.content_type not in ALLOWED_MIME_TYPES: + raise HTTPException(422, f"MIME type {file.content_type} not allowed") + + # 3. Check declared size + if file.size and file.size > SMALL_FILE_LIMIT_BYTES: + raise HTTPException( + 413, + "File too large for direct upload. Use POST /presign for large files.", + ) + + # 4. Stream to MinIO + object_key = storage.build_key(app_id, file.filename or "file") + try: + result = await storage.upload( + bucket=settings.s3_bucket_attachments, + key=object_key, + fileobj=file.file, + mime_type=file.content_type, + metadata={"uploaded_by": str(user.id), "app_id": str(app_id)}, + ) + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc + except StorageError as exc: + raise HTTPException(502, f"Storage unavailable: {exc}") from exc + + # 5. Write metadata to Postgres (storage is the source of truth for bytes, + # DB is the source of truth for metadata — two-phase write) + attachment = await attachment_repo.insert( + db, + application_id=app_id, + file_name=file.filename, + file_path=object_key, + file_size=result["size"], + mime_type=file.content_type, + kind=kind, + uploaded_by=user.id, + ) + await db.commit() + + return AttachmentOut( + attachment_id=attachment.attachment_id, + file_name=attachment.file_name, + file_size=attachment.file_size, + mime_type=attachment.mime_type, + kind=attachment.kind, + uploaded_at=attachment.uploaded_at.isoformat(), + ) + + +# ---------------------------------------------------------------------- # +# (B) Large-file upload: presigned URL flow +# ---------------------------------------------------------------------- # +@router.post("/presign", response_model=PresignedUploadResponse) +async def presign_upload( + app_id: int, + req: PresignedUploadRequest, + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """ + Step 1 of large-file upload: client requests a signed URL. + + Flow: + 1. Client POSTs metadata → receives {upload_url, pending_attachment_id} + 2. Client PUTs the file bytes to upload_url (direct to MinIO) + 3. Client POSTs /attachments/confirm with pending_attachment_id + """ + app = await application_repo.get(db, app_id) + if app is None or app.status != "DRAFT": + raise HTTPException(422, "Invalid application state") + if not await application_repo.is_author(db, app_id, user.id): + raise HTTPException(403, "Only authors may upload attachments") + if req.mime_type not in ALLOWED_MIME_TYPES: + raise HTTPException(422, "MIME type not allowed") + if req.file_size > settings.max_upload_size_mb * 1024 * 1024: + raise HTTPException(413, "File exceeds maximum size") + + object_key = storage.build_key(app_id, req.file_name) + + # Write a PENDING attachment row — gets finalized on /confirm + pending = await attachment_repo.insert_pending( + db, + application_id=app_id, + file_name=req.file_name, + file_path=object_key, + file_size=req.file_size, + mime_type=req.mime_type, + kind=req.kind, + uploaded_by=user.id, + ) + await db.commit() + + presigned = await storage.get_upload_url( + bucket=settings.s3_bucket_attachments, + key=object_key, + mime_type=req.mime_type, + ) + + return PresignedUploadResponse( + upload_url=presigned["url"], + headers=presigned["headers"], + object_key=object_key, + pending_attachment_id=pending.attachment_id, + ) + + +@router.post("/{pending_id}/confirm", response_model=AttachmentOut) +async def confirm_upload( + app_id: int, + pending_id: int, + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """Step 3: confirm the direct upload succeeded; verify the object exists.""" + pending = await attachment_repo.get(db, pending_id) + if pending is None or pending.application_id != app_id: + raise HTTPException(404, "Pending attachment not found") + if pending.uploaded_by != user.id: + raise HTTPException(403, "Not your upload") + + # Verify MinIO actually received the file + try: + head = await storage.head( + bucket=settings.s3_bucket_attachments, key=pending.file_path + ) + except FileNotFoundError: + raise HTTPException(422, "Upload never completed") + + # Optionally verify size matches declaration + actual_size = head["ContentLength"] + if actual_size != pending.file_size: + # Client lied about size — quarantine and reject + await storage.move( + settings.s3_bucket_attachments, + pending.file_path, + settings.s3_bucket_quarantine, + pending.file_path, + ) + await attachment_repo.delete(db, pending_id) + await db.commit() + raise HTTPException(422, "File size mismatch") + + await attachment_repo.mark_confirmed(db, pending_id) + await db.commit() + + return AttachmentOut( + attachment_id=pending.attachment_id, + file_name=pending.file_name, + file_size=actual_size, + mime_type=pending.mime_type, + kind=pending.kind, + uploaded_at=pending.uploaded_at.isoformat(), + ) + + +# ---------------------------------------------------------------------- # +# Download +# ---------------------------------------------------------------------- # +@router.get("/{attachment_id}") +async def download_attachment( + app_id: int, + attachment_id: int, + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """ + Returns a signed URL. The browser follows it to download directly from + MinIO — our API doesn't proxy the bytes, which saves significant bandwidth + and CPU. + """ + attachment = await attachment_repo.get(db, attachment_id) + if attachment is None or attachment.application_id != app_id: + raise HTTPException(404) + + # Authorization + if not await application_repo.user_can_read(db, app_id, user.id): + raise HTTPException(403) + + url = await storage.get_download_url( + bucket=settings.s3_bucket_attachments, + key=attachment.file_path, + filename=attachment.file_name, + ) + return {"download_url": url, "expires_in": settings.s3_signed_url_ttl} + + +@router.get("/{attachment_id}/stream") +async def stream_attachment( + app_id: int, + attachment_id: int, + user: User = Depends(get_current_user), + db=Depends(get_db), +): + """ + Alternative: proxy the stream through FastAPI. Use this when you need + to apply additional authorization/watermarking server-side, at the cost + of extra bandwidth on your API servers. + """ + attachment = await attachment_repo.get(db, attachment_id) + if attachment is None or attachment.application_id != app_id: + raise HTTPException(404) + if not await application_repo.user_can_read(db, app_id, user.id): + raise HTTPException(403) + + return StreamingResponse( + storage.download_stream( + bucket=settings.s3_bucket_attachments, key=attachment.file_path + ), + media_type=attachment.mime_type, + headers={ + "Content-Disposition": f'attachment; filename="{attachment.file_name}"' + }, + ) + + +# ---------------------------------------------------------------------- # +# Delete +# ---------------------------------------------------------------------- # +@router.delete("/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_attachment( + app_id: int, + attachment_id: int, + user: User = Depends(get_current_user), + db=Depends(get_db), +): + app = await application_repo.get(db, app_id) + if app.status != "DRAFT": + raise HTTPException(422, "Can only delete attachments on DRAFT applications") + if not await application_repo.is_author(db, app_id, user.id): + raise HTTPException(403) + + attachment = await attachment_repo.get(db, attachment_id) + if attachment is None or attachment.application_id != app_id: + raise HTTPException(404) + + # Delete from MinIO first. If the DB delete fails, we have an orphan + # in storage (recoverable — versioning), which is better than an + # orphan in the DB that points to nothing. + await storage.delete(settings.s3_bucket_attachments, attachment.file_path) + await attachment_repo.delete(db, attachment_id) + await db.commit() diff --git a/be0/src/minio/cleanup.py b/be0/src/minio/cleanup.py new file mode 100644 index 0000000..727085f --- /dev/null +++ b/be0/src/minio/cleanup.py @@ -0,0 +1,121 @@ +""" +Orphan cleanup — runs daily via Celery Beat. + +Sweeps two kinds of inconsistency between PostgreSQL and MinIO: + 1. PENDING attachments older than 1 hour → delete the row (and object if any) + 2. Objects in MinIO with no DB row → move to quarantine for 7 days then delete + +Run schedule: every day at 03:00 UTC (after nightly backup, before business hours) +""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone, timedelta + +from celery import shared_task +from sqlalchemy import text + +from app.db import sync_engine +from app.storage import storage, settings + +logger = logging.getLogger(__name__) + +STALE_PENDING_AGE = timedelta(hours=1) + + +@shared_task(bind=True, max_retries=3) +def cleanup_storage_orphans(self): + """Reconcile PostgreSQL attachments table with MinIO bucket contents.""" + stats = {"pending_deleted": 0, "orphan_objects_quarantined": 0} + + # ---- Pass 1: expire stale PENDINGs ---- + cutoff = datetime.now(tz=timezone.utc) - STALE_PENDING_AGE + with sync_engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT attachment_id, file_path + FROM attachments + WHERE upload_status = 'PENDING' + AND uploaded_at < :cutoff + """ + ), + {"cutoff": cutoff}, + ).fetchall() + + for row in rows: + # Try to delete from MinIO (may not exist) + try: + _sync_delete(settings.s3_bucket_attachments, row.file_path) + except Exception as exc: + logger.warning("MinIO delete failed for %s: %s", row.file_path, exc) + + conn.execute( + text("DELETE FROM attachments WHERE attachment_id = :id"), + {"id": row.attachment_id}, + ) + stats["pending_deleted"] += 1 + + # ---- Pass 2: find objects in MinIO with no matching DB row ---- + with sync_engine.begin() as conn: + db_keys = { + r.file_path + for r in conn.execute(text("SELECT file_path FROM attachments")) + } + + orphan_keys = [] + for key in _sync_list_objects(settings.s3_bucket_attachments): + if key not in db_keys: + orphan_keys.append(key) + + # Quarantine orphans — don't delete outright in case it's a race condition + # with an in-flight upload. The quarantine bucket auto-expires in 7 days. + for key in orphan_keys: + try: + _sync_move( + settings.s3_bucket_attachments, + key, + settings.s3_bucket_quarantine, + key, + ) + stats["orphan_objects_quarantined"] += 1 + except Exception as exc: + logger.exception("Failed to quarantine %s: %s", key, exc) + + logger.info("Orphan cleanup done: %s", stats) + return stats + + +# Sync helpers for boto3 inside Celery (aioboto3 is for async FastAPI routes) +def _sync_client(): + import boto3 + + return boto3.client( + "s3", + endpoint_url=settings.s3_endpoint_url, + aws_access_key_id=settings.s3_access_key, + aws_secret_access_key=settings.s3_secret_key, + region_name=settings.s3_region, + ) + + +def _sync_delete(bucket, key): + _sync_client().delete_object(Bucket=bucket, Key=key) + + +def _sync_list_objects(bucket): + s3 = _sync_client() + paginator = s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=bucket): + for obj in page.get("Contents", []): + yield obj["Key"] + + +def _sync_move(src_bucket, src_key, dst_bucket, dst_key): + s3 = _sync_client() + s3.copy_object( + Bucket=dst_bucket, + Key=dst_key, + CopySource={"Bucket": src_bucket, "Key": src_key}, + ) + s3.delete_object(Bucket=src_bucket, Key=src_key) diff --git a/be0/src/minio/storage.py b/be0/src/minio/storage.py new file mode 100644 index 0000000..47c8385 --- /dev/null +++ b/be0/src/minio/storage.py @@ -0,0 +1,407 @@ +""" +Async S3/MinIO client. Abstracts the details so application code never +touches boto3 directly. +""" +from __future__ import annotations + +import hashlib +import io +import logging +import uuid +from datetime import datetime, timezone +from typing import AsyncIterator, BinaryIO + +import aioboto3 +from botocore.config import Config as BotoConfig +from botocore.exceptions import ClientError +from pydantic_settings import BaseSettings + +logger = logging.getLogger(__name__) + + +class S3Settings(BaseSettings): + s3_endpoint_url: str + """Host the API uses to reach S3 (e.g. http://minio:9000 inside Docker).""" + s3_public_endpoint_url: str | None = None + """If set, presigned GET/PUT URLs use this host so browsers can open them (e.g. http://localhost:19000).""" + s3_region: str = "us-east-1" + s3_access_key: str + s3_secret_key: str + s3_bucket_attachments: str + s3_bucket_exports: str + s3_bucket_quarantine: str + s3_bucket_templates: str = "initiative-templates" + """Admin-managed .docx templates (server-side only; no browser CORS needed).""" + s3_bucket_imagehub_blobs: str = "imagehub-blobs" + """ImageHub content-addressed blob store (imaging dataset files; deduped by sha256).""" + s3_signed_url_ttl: int = 900 # 15 minutes + max_upload_size_mb: int = 50 + max_blob_size_mb: int = 2048 # ImageHub imaging blobs are large (DICOM series, NIfTI volumes) + + class Config: + env_file = ".env" + + +settings = S3Settings() + + +# Allowed MIME types — validated server-side, never trust the client +ALLOWED_MIME_TYPES = { + "application/pdf", + "image/png", + "image/jpeg", + "image/webp", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", # docx + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # xlsx + "application/vnd.openxmlformats-officedocument.presentationml.presentation", # pptx + "application/msword", + "application/vnd.ms-excel", + "text/plain", +} + + +class S3Storage: + """ + Async context-manager friendly wrapper around MinIO/S3. + + Design choices: + - Keys are content-addressed: {app_id}/{yyyy}/{mm}/{uuid}-{safe_filename} + This avoids collisions, makes listing by app cheap, and sharding by + month keeps directory-style prefixes from getting too deep. + - Never streams a whole file into memory — chunked upload/download + for large attachments. + - All errors are caught and re-raised as domain exceptions. + """ + + def __init__(self, settings: S3Settings = settings): + self._settings = settings + self._session = aioboto3.Session( + aws_access_key_id=settings.s3_access_key, + aws_secret_access_key=settings.s3_secret_key, + region_name=settings.s3_region, + ) + + def _client(self): + """Return a client as an async context manager.""" + return self._session.client( + "s3", + endpoint_url=self._settings.s3_endpoint_url, + # Path-style addressing works with both MinIO and S3 + config=BotoConfig(signature_version="s3v4"), + ) + + def _client_presign(self): + """Presigned URLs must use a host reachable from the user's browser (often not minio:9000).""" + ep = self._settings.s3_public_endpoint_url or self._settings.s3_endpoint_url + return self._session.client( + "s3", + endpoint_url=ep, + config=BotoConfig(signature_version="s3v4"), + ) + + async def ensure_buckets_exist(self) -> None: + """Create configured buckets if they do not exist (local MinIO / first boot).""" + names = ( + self._settings.s3_bucket_attachments, + self._settings.s3_bucket_exports, + self._settings.s3_bucket_quarantine, + self._settings.s3_bucket_templates, + self._settings.s3_bucket_imagehub_blobs, + ) + for name in names: + async with self._client() as s3: + try: + await s3.create_bucket(Bucket=name) + except ClientError as exc: + code = (exc.response or {}).get("Error", {}).get("Code", "") + if code in ( + "BucketAlreadyOwnedByYou", + "BucketAlreadyExists", + ): + continue + raise + logger.info("S3 bucket ready: %s", name) + + # ------------------------------------------------------------------ # + # Key construction + # ------------------------------------------------------------------ # + @staticmethod + def build_key(application_id: int, filename: str) -> str: + """ + Build a unique, safe object key for an attachment. + + Example: 42/2025/10/7c9e8a1b-4d5f-flowchart.pdf + """ + now = datetime.now(tz=timezone.utc) + safe = _sanitize_filename(filename) + unique = uuid.uuid4().hex[:16] + return f"{application_id}/{now:%Y}/{now:%m}/{unique}-{safe}" + + @staticmethod + def build_key_for_initiative(initiative_id: uuid.UUID, filename: str) -> str: + """ + Same as build_key but namespaces by initiative UUID (initiative application drafts). + """ + now = datetime.now(tz=timezone.utc) + safe = _sanitize_filename(filename) + unique = uuid.uuid4().hex[:16] + iid = str(initiative_id).replace("-", "") + return f"initiatives/{iid}/{now:%Y}/{now:%m}/{unique}-{safe}" + + # ------------------------------------------------------------------ # + # Upload + # ------------------------------------------------------------------ # + async def upload( + self, + bucket: str, + key: str, + fileobj: BinaryIO, + mime_type: str, + metadata: dict[str, str] | None = None, + ) -> dict: + """ + Upload a file-like object to MinIO. Validates MIME type and size, + computes SHA-256 for integrity, returns result metadata. + """ + if mime_type not in ALLOWED_MIME_TYPES: + raise ValueError(f"MIME type not allowed: {mime_type}") + + # Compute size + hash while buffering + data = fileobj.read() + size = len(data) + max_bytes = self._settings.max_upload_size_mb * 1024 * 1024 + if size > max_bytes: + raise ValueError( + f"File too large: {size} bytes > {max_bytes} bytes limit" + ) + sha256 = hashlib.sha256(data).hexdigest() + + meta = {"sha256": sha256, **(metadata or {})} + + async with self._client() as s3: + try: + await s3.put_object( + Bucket=bucket, + Key=key, + Body=io.BytesIO(data), + ContentType=mime_type, + Metadata=meta, + # Omit ServerSideEncryption: default MinIO returns NotImplemented if KMS/SSE + # is not configured. Use bucket policies or a compatible backend for SSE. + ) + except ClientError as exc: + logger.exception("S3 upload failed: bucket=%s key=%s", bucket, key) + raise StorageError(f"Upload failed: {exc}") from exc + + logger.info( + "Uploaded s3://%s/%s size=%d sha256=%s", bucket, key, size, sha256[:12] + ) + return {"bucket": bucket, "key": key, "size": size, "sha256": sha256} + + # ------------------------------------------------------------------ # + # Download (streamed) + # ------------------------------------------------------------------ # + async def download_stream( + self, bucket: str, key: str, chunk_size: int = 64 * 1024 + ) -> AsyncIterator[bytes]: + """ + Stream the object body in chunks. Use for FastAPI StreamingResponse. + """ + async with self._client() as s3: + try: + obj = await s3.get_object(Bucket=bucket, Key=key) + except ClientError as exc: + if exc.response["Error"]["Code"] == "NoSuchKey": + raise FileNotFoundError(f"Object not found: {key}") from exc + raise StorageError(f"Download failed: {exc}") from exc + + # aiobotocore: `async with body as stream` yields aiohttp ClientResponse; + # `stream.read(n)` fails (read() takes no size). Keep the Body wrapper and + # read from `body` inside `async with body:` so chunk sizes work. See aiobotocore#1156. + body = obj["Body"] + async with body: + while True: + chunk = await body.read(chunk_size) + if not chunk: + break + yield chunk + + # ------------------------------------------------------------------ # + # Signed URLs + # ------------------------------------------------------------------ # + async def get_download_url( + self, + bucket: str, + key: str, + ttl: int | None = None, + filename: str | None = None, + *, + inline: bool = False, + response_content_type: str | None = None, + ) -> str: + """ + Generate a pre-signed URL so the browser can GET directly from MinIO. + + - ``inline=False`` (default): Content-Disposition attachment — save-as / download. + - ``inline=True``: Content-Disposition inline — PDF/image viewers and iframes. + - ``response_content_type``: optional override (e.g. application/pdf) for clients + that rely on the response header when the stored object metadata is wrong. + """ + ttl = ttl or self._settings.s3_signed_url_ttl + params: dict = {"Bucket": bucket, "Key": key} + if filename: + safe = _sanitize_filename(filename) + disp = "inline" if inline else "attachment" + params["ResponseContentDisposition"] = f'{disp}; filename="{safe}"' + elif inline: + params["ResponseContentDisposition"] = "inline" + if response_content_type: + params["ResponseContentType"] = response_content_type + async with self._client_presign() as s3: + return await s3.generate_presigned_url( + "get_object", Params=params, ExpiresIn=ttl + ) + + async def get_upload_url( + self, bucket: str, key: str, mime_type: str, ttl: int | None = None + ) -> dict: + """ + Generate a pre-signed URL for direct browser → MinIO upload. + Returns URL + required headers. Use this for large files to bypass + the FastAPI request-body limit. + """ + ttl = ttl or self._settings.s3_signed_url_ttl + async with self._client_presign() as s3: + url = await s3.generate_presigned_url( + "put_object", + Params={ + "Bucket": bucket, + "Key": key, + "ContentType": mime_type, + }, + ExpiresIn=ttl, + ) + return {"url": url, "headers": {"Content-Type": mime_type}} + + # ------------------------------------------------------------------ # + # Delete / copy + # ------------------------------------------------------------------ # + async def delete(self, bucket: str, key: str) -> None: + """ + Delete an object. With versioning enabled, this creates a delete + marker — the previous version is still recoverable until lifecycle + rules remove it. + """ + async with self._client() as s3: + await s3.delete_object(Bucket=bucket, Key=key) + logger.info("Deleted s3://%s/%s", bucket, key) + + async def move( + self, src_bucket: str, src_key: str, dst_bucket: str, dst_key: str + ) -> None: + """Atomic move: copy then delete. Used when quarantining suspicious files.""" + async with self._client() as s3: + await s3.copy_object( + Bucket=dst_bucket, + Key=dst_key, + CopySource={"Bucket": src_bucket, "Key": src_key}, + ) + await s3.delete_object(Bucket=src_bucket, Key=src_key) + + async def head(self, bucket: str, key: str) -> dict: + """Fetch object metadata without downloading it.""" + async with self._client() as s3: + try: + return await s3.head_object(Bucket=bucket, Key=key) + except ClientError as exc: + if exc.response["Error"]["Code"] == "404": + raise FileNotFoundError(f"Object not found: {key}") from exc + raise + + # ------------------------------------------------------------------ # + # Content-addressed blobs (ImageHub) — dedup by sha256 + # ------------------------------------------------------------------ # + @staticmethod + def build_blob_key(sha256: str) -> str: + """Content-addressed key ``blobs///`` (sharded by hash prefix).""" + h = (sha256 or "").lower() + aa = h[0:2] if len(h) >= 2 else "00" + bb = h[2:4] if len(h) >= 4 else "00" + return f"blobs/{aa}/{bb}/{h}" + + async def blob_exists(self, bucket: str, key: str) -> bool: + """True if an object already exists at key (used for content-addressed dedup).""" + async with self._client() as s3: + try: + await s3.head_object(Bucket=bucket, Key=key) + return True + except ClientError as exc: + code = (exc.response or {}).get("Error", {}).get("Code", "") + if code in ("404", "NoSuchKey", "NotFound"): + return False + raise + + async def put_blob(self, data: bytes, media_type: str | None = None) -> dict: + """ + Store raw bytes as a content-addressed, globally deduped blob. + + Hashes the bytes (sha256) and PUTs to ``blobs///`` in the ImageHub + bucket only if not already present (``deduped`` tells the caller which happened). + Accepts any ``media_type`` — imaging MIME is unreliable (DICOM is often + ``application/octet-stream``) — but enforces ``max_blob_size_mb``. + + NOTE: like ``upload()``, this buffers the whole blob in memory; resumable/ + multipart upload for very large series is a later milestone. + """ + size = len(data) + max_bytes = self._settings.max_blob_size_mb * 1024 * 1024 + if size > max_bytes: + raise ValueError(f"Blob too large: {size} bytes > {max_bytes} bytes limit") + sha256 = hashlib.sha256(data).hexdigest() + bucket = self._settings.s3_bucket_imagehub_blobs + key = self.build_blob_key(sha256) + ctype = media_type or "application/octet-stream" + if await self.blob_exists(bucket, key): + return {"sha256": sha256, "size": size, "bucket": bucket, "key": key, + "media_type": ctype, "deduped": True} + async with self._client() as s3: + try: + await s3.put_object( + Bucket=bucket, Key=key, Body=io.BytesIO(data), ContentType=ctype, + Metadata={"sha256": sha256}, + ) + except ClientError as exc: + logger.exception("S3 put_blob failed: bucket=%s key=%s", bucket, key) + raise StorageError(f"Blob upload failed: {exc}") from exc + logger.info("Stored blob s3://%s/%s size=%d sha256=%s", bucket, key, size, sha256[:12]) + return {"sha256": sha256, "size": size, "bucket": bucket, "key": key, + "media_type": ctype, "deduped": False} + + +# ---------------------------------------------------------------------- # +# Helpers +# ---------------------------------------------------------------------- # +def _sanitize_filename(name: str) -> str: + """ + Strip path components and replace unsafe characters. Preserves + Vietnamese diacritics because MinIO keys are UTF-8. + """ + import re + import unicodedata + + # Strip any path separators + name = name.replace("/", "_").replace("\\", "_") + # Collapse whitespace + name = re.sub(r"\s+", "_", name.strip()) + # Remove control chars + name = "".join(ch for ch in name if unicodedata.category(ch)[0] != "C") + # Max length 200 chars (S3 key limit is 1024, but be conservative) + return name[:200] or "file" + + +class StorageError(Exception): + """Raised for any storage-layer failure.""" + + +# Module-level singleton +storage = S3Storage() diff --git a/be0/src/minio/test_storage.py b/be0/src/minio/test_storage.py new file mode 100644 index 0000000..c3f9aea --- /dev/null +++ b/be0/src/minio/test_storage.py @@ -0,0 +1,131 @@ +""" +Validation test — exercises the S3/MinIO integration logic against a mock S3. +Verifies: upload, download, presigned URLs, metadata, MIME validation. +""" +import io +import boto3 +from moto import mock_aws +import hashlib + +BUCKET = "sangkien-attachments" + +ALLOWED = {"application/pdf", "image/png", "image/jpeg"} + + +def build_key(app_id, filename): + from datetime import datetime, timezone + import uuid + now = datetime.now(tz=timezone.utc) + unique = uuid.uuid4().hex[:16] + safe = filename.replace("/", "_").replace(" ", "_") + return f"{app_id}/{now:%Y}/{now:%m}/{unique}-{safe}" + + +def upload(s3, key, data, mime_type, metadata): + if mime_type not in ALLOWED: + raise ValueError(f"MIME not allowed: {mime_type}") + sha = hashlib.sha256(data).hexdigest() + s3.put_object( + Bucket=BUCKET, Key=key, Body=data, + ContentType=mime_type, Metadata={"sha256": sha, **metadata}, + ServerSideEncryption="AES256", + ) + return {"key": key, "size": len(data), "sha256": sha} + + +@mock_aws +def test_full_flow(): + s3 = boto3.client("s3", region_name="us-east-1") + s3.create_bucket(Bucket=BUCKET) + + # 1. Upload a fake PDF + fake_pdf = b"%PDF-1.4\n%fake content for testing\n" * 100 + key = build_key(app_id=42, filename="flowchart sáng kiến.pdf") + result = upload(s3, key, fake_pdf, "application/pdf", + {"uploaded_by": "7", "app_id": "42"}) + print(f"✓ Uploaded: {result['key']}") + print(f" size={result['size']} sha256={result['sha256'][:16]}...") + + # 2. Head object — verify metadata survived + head = s3.head_object(Bucket=BUCKET, Key=key) + assert head["ContentType"] == "application/pdf" + assert head["Metadata"]["uploaded_by"] == "7" + assert head["Metadata"]["sha256"] == result["sha256"] + print(f"✓ Metadata preserved: uploaded_by={head['Metadata']['uploaded_by']}") + + # 3. Generate pre-signed download URL + download_url = s3.generate_presigned_url( + "get_object", + Params={"Bucket": BUCKET, "Key": key, + "ResponseContentDisposition": 'attachment; filename="flowchart.pdf"'}, + ExpiresIn=900, + ) + assert "Signature=" in download_url and "Expires=" in download_url + print(f"✓ Signed download URL generated (TTL=900s)") + + # 4. Generate pre-signed upload URL (for large-file flow) + upload_url = s3.generate_presigned_url( + "put_object", + Params={"Bucket": BUCKET, "Key": "42/2025/10/new-large-file.pdf", + "ContentType": "application/pdf"}, + ExpiresIn=900, + ) + assert "Signature=" in upload_url + print(f"✓ Signed upload URL generated") + + # 5. Download and verify bytes match + obj = s3.get_object(Bucket=BUCKET, Key=key) + got = obj["Body"].read() + assert got == fake_pdf + assert hashlib.sha256(got).hexdigest() == result["sha256"] + print(f"✓ Download: {len(got)} bytes, hash matches") + + # 6. MIME validation rejects bad types + try: + upload(s3, "bad.exe", b"MZ\x90", "application/x-msdownload", {}) + assert False, "Should have rejected .exe" + except ValueError as e: + print(f"✓ MIME validation blocked: {e}") + + # 7. Delete creates a delete marker (versioning), object recoverable + s3.put_bucket_versioning( + Bucket=BUCKET, + VersioningConfiguration={"Status": "Enabled"}, + ) + # Re-upload with versioning on, then delete + key2 = "42/2025/10/versioned.pdf" + s3.put_object(Bucket=BUCKET, Key=key2, Body=b"v1", ContentType="application/pdf") + s3.delete_object(Bucket=BUCKET, Key=key2) + versions = s3.list_object_versions(Bucket=BUCKET, Prefix=key2) + has_delete_marker = any(dm for dm in versions.get("DeleteMarkers", [])) + has_version = any(v for v in versions.get("Versions", [])) + assert has_delete_marker and has_version + print(f"✓ Versioning: delete marker present, previous version recoverable") + + # 8. List objects under a prefix (application-scoped listing) + resp = s3.list_objects_v2(Bucket=BUCKET, Prefix="42/") + keys = [o["Key"] for o in resp.get("Contents", [])] + print(f"✓ Listed {len(keys)} objects under prefix 42/") + + print("\n✅ All checks passed") + + +@mock_aws +def test_research_evidence_pdf_upload_metadata(): + """Parity check: applicant research PDF uses same bucket + PDF MIME as attachment pipeline.""" + s3 = boto3.client("s3", region_name="us-east-1") + s3.create_bucket(Bucket=BUCKET) + pdf = b"%PDF-1.4 research evidence fixture\n" + key = build_key(app_id=99, filename="minh-chung-nhom-2.1.4.pdf") + meta = {"uploaded_by": "1", "app_id": "99", "case_code": "CASE-MERIT", "role": "research_evidence"} + result = upload(s3, key, pdf, "application/pdf", meta) + head = s3.head_object(Bucket=BUCKET, Key=key) + assert head["Metadata"]["role"] == "research_evidence" + assert head["Metadata"]["case_code"] == "CASE-MERIT" + assert head["ContentType"] == "application/pdf" + assert result["sha256"] == hashlib.sha256(pdf).hexdigest() + + +if __name__ == "__main__": + test_full_flow() + test_research_evidence_pdf_upload_metadata() diff --git a/be0/src/research_routes.py b/be0/src/research_routes.py new file mode 100644 index 0000000..511308b --- /dev/null +++ b/be0/src/research_routes.py @@ -0,0 +1,827 @@ +"""Research-project proposals (Thuyết minh đề tài) + lifecycle. + +A PI fills the proposal form; the row goes draft → submitted; an admin approves it +(→ approved, the project's cockpit unlocks) or rejects it. The proposal row *is* the +project across its lifecycle; the cockpit's child entities (members/datasets/models/ +assets/milestones) hang off it (added in phase 2). + +Authz (v1): a project is readable / mutable by its owner OR a platform admin. +Approve / reject are admin-only. Every lifecycle transition writes an append-only audit row. + +Mounted under ``/api/v1`` in main.py → routes live at ``/api/v1/research/*``. +""" +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Any, Optional + +from fastapi import APIRouter, Body, Header, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import func, select + +from src.auth_jwt import decode_access_token_user_id, decode_bearer_token +from src.initiative_db.engine import get_session, is_postgres_enabled +from src.initiative_db.models import ( + ResearchProject, + ResearchProjectAsset, + ResearchProjectAudit, + ResearchProjectDataset, + ResearchProjectMember, + ResearchProjectMilestone, + ResearchProjectModel, + User, +) + +router = APIRouter(prefix="/research", tags=["research"]) + +_STATUS_DRAFT = "draft" +_STATUS_SUBMITTED = "submitted" +_STATUS_APPROVED = "approved" +_STATUS_REJECTED = "rejected" + +_ROLE_PI = "Chủ nhiệm (PI)" +_ROLE_ADMIN = "Quản trị viên" + + +# --------------------------------------------------------------------------- # +# Auth (mirrors template_routes / the extracted admin routers) +# --------------------------------------------------------------------------- # +def _jwt_roles(authorization: str | None) -> list[str]: + p = decode_bearer_token(authorization) + if not p: + return [] + r = p.get("roles") + return [str(x) for x in r] if isinstance(r, list) else [] + + +def _require_authed_uid(authorization: str | None) -> uuid.UUID: + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.") + return uid + + +def _is_admin(authorization: str | None) -> bool: + return "admin" in _jwt_roles(authorization) + + +def _require_admin_uid(authorization: str | None) -> uuid.UUID: + uid = _require_authed_uid(authorization) + if not _is_admin(authorization): + raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.") + return uid + + +def _require_db() -> None: + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa sẵn sàng.") + + +# --------------------------------------------------------------------------- # +# Scalar extraction from the proposal content blob (for listing / filtering / overview) +# --------------------------------------------------------------------------- # +def _coerce_int(v: Any) -> Optional[int]: + if v is None or v == "": + return None + try: + return int(float(str(v).strip())) + except (ValueError, TypeError): + return None + + +def _coerce_float(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(str(v).strip()) + except (ValueError, TypeError): + return None + + +def _extract_scalars(content: Any) -> dict[str, Any]: + """Pull the queryable scalars out of the (flat, dotted-key) proposal form values.""" + c = content if isinstance(content, dict) else {} + return { + "title": str(c.get("tenDeTai") or "").strip(), + "level": str(c.get("capDeTai") or "").strip(), + "pi_name": str(c.get("chuNhiem.hoTen") or "").strip(), + "period_months": _coerce_int(c.get("thoiGianThucHienThang")), + "budget_total": _coerce_float(c.get("tongKinhPhi")), + } + + +# --------------------------------------------------------------------------- # +# Schemas +# --------------------------------------------------------------------------- # +class ProjectOut(BaseModel): + id: str + ownerUserId: str + status: str + code: Optional[str] = None + title: str = "" + level: str = "" + piName: str = "" + periodMonths: Optional[int] = None + budgetTotal: Optional[float] = None + content: dict[str, Any] = Field(default_factory=dict) + submittedAt: Optional[datetime] = None + reviewedAt: Optional[datetime] = None + reviewNote: Optional[str] = None + createdAt: Optional[datetime] = None + updatedAt: Optional[datetime] = None + + +class ProjectCreateIn(BaseModel): + content: dict[str, Any] = Field(default_factory=dict) + + +class ProjectUpdateIn(BaseModel): + content: dict[str, Any] = Field(default_factory=dict) + + +class ProjectDetailPatchIn(BaseModel): + """Partial administrative-detail patch merged into an approved project's content.""" + patch: dict[str, Any] = Field(default_factory=dict) + + +class ApproveIn(BaseModel): + code: Optional[str] = Field(default=None, max_length=100) + note: Optional[str] = Field(default=None, max_length=2000) + + +class RejectIn(BaseModel): + note: Optional[str] = Field(default=None, max_length=2000) + + +class AuditOut(BaseModel): + id: int + occurredAt: Optional[datetime] = None + actorName: str = "" + roleLabel: str = "" + action: str + subject: str = "" + detail: str = "" + + +def _to_out(row: ResearchProject) -> ProjectOut: + return ProjectOut( + id=str(row.id), + ownerUserId=str(row.owner_user_id), + status=row.status, + code=row.code, + title=row.title or "", + level=row.level or "", + piName=row.pi_name or "", + periodMonths=row.period_months, + budgetTotal=float(row.budget_total) if row.budget_total is not None else None, + content=row.content if isinstance(row.content, dict) else {}, + submittedAt=row.submitted_at, + reviewedAt=row.reviewed_at, + reviewNote=row.review_note, + createdAt=row.created_at, + updatedAt=row.updated_at, + ) + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +async def _load_project(session, project_id: str, uid: uuid.UUID, is_admin: bool) -> ResearchProject: + """Fetch a project enforcing owner-or-admin read access (404 hides others' rows).""" + try: + pid = uuid.UUID(project_id) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Không tìm thấy đề tài.") + row = ( + await session.execute(select(ResearchProject).where(ResearchProject.id == pid)) + ).scalar_one_or_none() + if row is None or (not is_admin and row.owner_user_id != uid): + raise HTTPException(status_code=404, detail="Không tìm thấy đề tài.") + return row + + +async def _actor_name(session, uid: uuid.UUID) -> str: + u = await session.get(User, uid) + if u is None: + return "" + return (u.full_name or u.email or "").strip() + + +async def _write_audit( + session, + project_id: uuid.UUID, + actor_uid: Optional[uuid.UUID], + actor_name: str, + role_label: str, + action: str, + subject: str = "", + detail: str = "", +) -> None: + session.add( + ResearchProjectAudit( + project_id=project_id, + actor_user_id=actor_uid, + actor_name=actor_name or "", + role_label=role_label or "", + action=action, + subject=subject or "", + detail=detail or "", + ) + ) + + +# --------------------------------------------------------------------------- # +# Endpoints — proposals lifecycle +# --------------------------------------------------------------------------- # +@router.post("/projects", response_model=ProjectOut) +async def create_project( + payload: Optional[ProjectCreateIn] = Body(None), + authorization: Optional[str] = Header(None), +) -> ProjectOut: + """Authed: create a draft proposal owned by the current user.""" + _require_db() + uid = _require_authed_uid(authorization) + content = (payload.content if payload and isinstance(payload.content, dict) else {}) + scalars = _extract_scalars(content) + async with get_session() as session: + row = ResearchProject(id=uuid.uuid4(), owner_user_id=uid, status=_STATUS_DRAFT, content=content, **scalars) + session.add(row) + await session.flush() + await _write_audit( + session, row.id, uid, await _actor_name(session, uid), _ROLE_PI, + "Tạo bản thảo đề tài", scalars["title"] or "(chưa đặt tên)", + ) + await session.commit() + await session.refresh(row) + return _to_out(row) + + +@router.get("/projects", response_model=list[ProjectOut]) +async def list_projects(mine: bool = True, authorization: Optional[str] = Header(None)) -> list[ProjectOut]: + """List projects. Non-admin always sees only their own; admin can pass ?mine=false to see all.""" + _require_db() + uid = _require_authed_uid(authorization) + is_admin = _is_admin(authorization) + async with get_session() as session: + stmt = select(ResearchProject).order_by(ResearchProject.created_at.desc()) + if mine or not is_admin: + stmt = stmt.where(ResearchProject.owner_user_id == uid) + rows = (await session.execute(stmt)).scalars().all() + return [_to_out(r) for r in rows] + + +@router.get("/projects/{project_id}", response_model=ProjectOut) +async def get_project(project_id: str, authorization: Optional[str] = Header(None)) -> ProjectOut: + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + return _to_out(await _load_project(session, project_id, uid, _is_admin(authorization))) + + +@router.put("/projects/{project_id}", response_model=ProjectOut) +async def update_project( + project_id: str, + payload: ProjectUpdateIn = Body(...), + authorization: Optional[str] = Header(None), +) -> ProjectOut: + """Owner: replace the draft proposal content. Allowed only while status=draft.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + row = await _load_project(session, project_id, uid, _is_admin(authorization)) + if row.owner_user_id != uid: + raise HTTPException(status_code=403, detail="Chỉ chủ nhiệm mới sửa được bản thảo.") + if row.status != _STATUS_DRAFT: + raise HTTPException(status_code=409, detail="Chỉ sửa được khi đề tài ở trạng thái bản thảo.") + content = payload.content if isinstance(payload.content, dict) else {} + row.content = content + for k, v in _extract_scalars(content).items(): + setattr(row, k, v) + row.updated_at = datetime.now(tz=timezone.utc) + await session.commit() + await session.refresh(row) + return _to_out(row) + + +@router.put("/projects/{project_id}/detail", response_model=ProjectOut) +async def update_project_detail( + project_id: str, + payload: ProjectDetailPatchIn = Body(...), + authorization: Optional[str] = Header(None), +) -> ProjectOut: + """Owner-or-admin: shallow-merge administrative-detail fields into an approved + project's content JSONB. Unlike ``update_project`` (draft-only, wholesale-replace), + this serves the cockpit: it is allowed once the project is approved and MERGES the + patch into existing content so the original proposal keys survive. Writes an audit row.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + row = await _load_project(session, project_id, uid, _is_admin(authorization)) + _require_approved(row) + patch = payload.patch if isinstance(payload.patch, dict) else {} + merged = {**(row.content if isinstance(row.content, dict) else {}), **patch} + row.content = merged + for k, v in _extract_scalars(merged).items(): + setattr(row, k, v) + row.updated_at = datetime.now(tz=timezone.utc) + await _write_audit( + session, row.id, uid, await _actor_name(session, uid), + _role_label(authorization), "Cập nhật thông tin đề tài", "", + f"{len(patch)} trường", + ) + await session.commit() + await session.refresh(row) + return _to_out(row) + + +@router.post("/projects/{project_id}/submit", response_model=ProjectOut) +async def submit_project(project_id: str, authorization: Optional[str] = Header(None)) -> ProjectOut: + """Owner: submit a draft for review (draft → submitted).""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + row = await _load_project(session, project_id, uid, _is_admin(authorization)) + if row.owner_user_id != uid: + raise HTTPException(status_code=403, detail="Chỉ chủ nhiệm mới nộp được đề tài.") + if row.status != _STATUS_DRAFT: + raise HTTPException(status_code=409, detail="Đề tài đã được nộp hoặc đã xử lý.") + if not (row.title or "").strip(): + raise HTTPException(status_code=422, detail="Cần nhập tên đề tài trước khi nộp.") + row.status = _STATUS_SUBMITTED + row.submitted_at = datetime.now(tz=timezone.utc) + row.updated_at = row.submitted_at + await _write_audit( + session, row.id, uid, await _actor_name(session, uid), _ROLE_PI, "Nộp đề tài", row.title, + ) + await session.commit() + await session.refresh(row) + return _to_out(row) + + +@router.post("/projects/{project_id}/approve", response_model=ProjectOut) +async def approve_project( + project_id: str, + payload: Optional[ApproveIn] = Body(None), + authorization: Optional[str] = Header(None), +) -> ProjectOut: + """Admin: approve a submitted proposal (submitted → approved); optionally assign its code.""" + _require_db() + admin_uid = _require_admin_uid(authorization) + async with get_session() as session: + row = await _load_project(session, project_id, admin_uid, True) + if row.status != _STATUS_SUBMITTED: + raise HTTPException(status_code=409, detail="Chỉ duyệt được đề tài đang chờ duyệt.") + code = (payload.code if payload else None) or "" + note = (payload.note if payload else None) or "" + if code.strip(): + row.code = code.strip() + row.status = _STATUS_APPROVED + row.reviewed_by = admin_uid + row.reviewed_at = datetime.now(tz=timezone.utc) + row.review_note = note.strip() or None + row.updated_at = row.reviewed_at + await _seed_cockpit_from_proposal(session, row) + await _write_audit( + session, row.id, admin_uid, await _actor_name(session, admin_uid), _ROLE_ADMIN, + "Phê duyệt đề tài", row.code or row.title, row.review_note or "", + ) + await session.commit() + await session.refresh(row) + return _to_out(row) + + +@router.post("/projects/{project_id}/reject", response_model=ProjectOut) +async def reject_project( + project_id: str, + payload: Optional[RejectIn] = Body(None), + authorization: Optional[str] = Header(None), +) -> ProjectOut: + """Admin: reject a submitted proposal (submitted → rejected) with an optional note.""" + _require_db() + admin_uid = _require_admin_uid(authorization) + async with get_session() as session: + row = await _load_project(session, project_id, admin_uid, True) + if row.status != _STATUS_SUBMITTED: + raise HTTPException(status_code=409, detail="Chỉ từ chối được đề tài đang chờ duyệt.") + note = (payload.note if payload else None) or "" + row.status = _STATUS_REJECTED + row.reviewed_by = admin_uid + row.reviewed_at = datetime.now(tz=timezone.utc) + row.review_note = note.strip() or None + row.updated_at = row.reviewed_at + await _write_audit( + session, row.id, admin_uid, await _actor_name(session, admin_uid), _ROLE_ADMIN, + "Từ chối đề tài", row.title, row.review_note or "", + ) + await session.commit() + await session.refresh(row) + return _to_out(row) + + +@router.delete("/projects/{project_id}") +async def delete_project(project_id: str, authorization: Optional[str] = Header(None)) -> dict[str, Any]: + """Owner may delete a draft; admin may delete any. Children + audit cascade in the DB.""" + _require_db() + uid = _require_authed_uid(authorization) + is_admin = _is_admin(authorization) + async with get_session() as session: + row = await _load_project(session, project_id, uid, is_admin) + if not is_admin: + if row.owner_user_id != uid: + raise HTTPException(status_code=404, detail="Không tìm thấy đề tài.") + if row.status != _STATUS_DRAFT: + raise HTTPException(status_code=409, detail="Chỉ xóa được bản thảo; đề tài đã nộp cần liên hệ quản trị.") + await session.delete(row) + await session.commit() + return {"ok": True} + + +@router.get("/projects/{project_id}/audit", response_model=list[AuditOut]) +async def list_audit(project_id: str, authorization: Optional[str] = Header(None)) -> list[AuditOut]: + """Owner or admin: the append-only audit trail for a project, newest first.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + row = await _load_project(session, project_id, uid, _is_admin(authorization)) + rows = ( + await session.execute( + select(ResearchProjectAudit) + .where(ResearchProjectAudit.project_id == row.id) + .order_by(ResearchProjectAudit.occurred_at.desc(), ResearchProjectAudit.id.desc()) + ) + ).scalars().all() + return [ + AuditOut( + id=r.id, + occurredAt=r.occurred_at, + actorName=r.actor_name, + roleLabel=r.role_label, + action=r.action, + subject=r.subject, + detail=r.detail, + ) + for r in rows + ] + + +# --------------------------------------------------------------------------- # +# Cockpit entities — generic, config-driven CRUD (phase 2) +# The 5 child entity types share one CRUD surface keyed by an entity config. +# Mutations require the project to be approved (the cockpit "unlocks" on approval) and +# are allowed for the owner OR an admin; every mutation writes an audit row. +# --------------------------------------------------------------------------- # +_TEXT, _INT, _NUM = "text", "int", "num" + +_ENTITY_CONFIG: dict[str, dict[str, Any]] = { + "members": { + "model": ResearchProjectMember, + "singular": "thành viên", + "primary": "name", + "fields": [ + ("name", "name", _TEXT), + ("role", "role", _TEXT), + ("access", "access", _TEXT), + ("org", "org", _TEXT), + ("email", "email", _TEXT), + ("months", "months", _INT), + ("tasks", "tasks", _TEXT), + ("status", "status", _TEXT), + ], + }, + "datasets": { + "model": ResearchProjectDataset, + "singular": "bộ dữ liệu", + "primary": "name", + "fields": [ + ("name", "name", _TEXT), + ("type", "type", _TEXT), + ("records", "records", _INT), + ("source", "source", _TEXT), + ("sensitivity", "sensitivity", _TEXT), + ("ethics", "ethics", _TEXT), + ("owner", "owner", _TEXT), + ("status", "status", _TEXT), + ], + }, + "models": { + "model": ResearchProjectModel, + "singular": "mô hình", + "primary": "name", + "fields": [ + ("name", "name", _TEXT), + ("task", "task", _TEXT), + ("framework", "framework", _TEXT), + ("version", "version", _TEXT), + ("dataset", "dataset", _TEXT), + ("auc", "auc", _NUM), + ("sensitivity", "sensitivity", _NUM), + ("specificity", "specificity", _NUM), + ("accuracy", "accuracy", _NUM), + ("owner", "owner", _TEXT), + ("notes", "notes", _TEXT), + ("status", "status", _TEXT), + ], + }, + "assets": { + "model": ResearchProjectAsset, + "singular": "tài sản", + "primary": "name", + "fields": [ + ("name", "name", _TEXT), + ("category", "category", _TEXT), + ("acquisition", "acquisition", _TEXT), + ("value", "value", _NUM), + ("owner", "owner", _TEXT), + ("notes", "notes", _TEXT), + ("status", "status", _TEXT), + ], + }, + "milestones": { + "model": ResearchProjectMilestone, + "singular": "mốc tiến độ", + "primary": "title", + "fields": [ + ("title", "title", _TEXT), + ("deliverable", "deliverable", _TEXT), + ("start", "start_period", _TEXT), + ("end", "end_period", _TEXT), + ("owner", "owner", _TEXT), + ("budget", "budget", _NUM), + ("progress", "progress", _INT), + ("status", "status", _TEXT), + ], + }, +} + + +def _coerce_value(kind: str, v: Any) -> Any: + if kind == _INT: + return _coerce_int(v) + if kind == _NUM: + return _coerce_float(v) + return "" if v is None else str(v) + + +def _apply_fields(row: Any, cfg: dict[str, Any], data: dict[str, Any]) -> None: + """Whitelist-copy known fields from a client dict into an ORM row (prevents column injection).""" + for json_key, column, kind in cfg["fields"]: + if json_key in data: + setattr(row, column, _coerce_value(kind, data[json_key])) + + +def _entity_to_out(cfg: dict[str, Any], row: Any) -> dict[str, Any]: + out: dict[str, Any] = {"id": str(row.id), "sortOrder": row.sort_order} + for json_key, column, kind in cfg["fields"]: + val = getattr(row, column) + out[json_key] = float(val) if (kind == _NUM and val is not None) else val + return out + + +def _entity_cfg_or_404(entity: str) -> dict[str, Any]: + cfg = _ENTITY_CONFIG.get(entity) + if cfg is None: + raise HTTPException(status_code=404, detail="Không tìm thấy loại dữ liệu.") + return cfg + + +def _role_label(authorization: str | None) -> str: + return _ROLE_ADMIN if _is_admin(authorization) else _ROLE_PI + + +def _require_approved(project: ResearchProject) -> None: + if project.status != _STATUS_APPROVED: + raise HTTPException(status_code=409, detail="Chỉ quản lý được dữ liệu sau khi đề tài được phê duyệt.") + + +async def _entity_list(session, cfg: dict[str, Any], project_id: uuid.UUID) -> list[dict[str, Any]]: + model = cfg["model"] + rows = ( + await session.execute( + select(model).where(model.project_id == project_id).order_by(model.sort_order, model.created_at) + ) + ).scalars().all() + return [_entity_to_out(cfg, r) for r in rows] + + +async def _load_entity_or_404(session, cfg: dict[str, Any], project_id: uuid.UUID, item_id: str): + model = cfg["model"] + try: + iid = uuid.UUID(item_id) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Không tìm thấy mục.") + row = ( + await session.execute(select(model).where(model.id == iid, model.project_id == project_id)) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Không tìm thấy mục.") + return row + + +# --- seeding the cockpit from the proposal content (best-effort, on approval) --- +def _seed_members_from_content(content: Any) -> list[dict[str, Any]]: + c = content if isinstance(content, dict) else {} + out: list[dict[str, Any]] = [] + pi_name = str(c.get("chuNhiem.hoTen") or "").strip() + if pi_name: + out.append( + { + "name": pi_name, + "role": "Chủ nhiệm đề tài", + "access": _ROLE_PI, + "org": str(c.get("chuNhiem.tenToChuc") or ""), + "email": str(c.get("chuNhiem.email") or ""), + "status": "Đang hoạt động", + } + ) + sec_name = str(c.get("thuKy.hoTen") or "").strip() + if sec_name: + out.append( + { + "name": sec_name, + "role": "Thư ký khoa học", + "access": "Điều phối / Thư ký", + "org": str(c.get("thuKy.tenToChuc") or ""), + "email": str(c.get("thuKy.email") or ""), + "status": "Đang hoạt động", + } + ) + members_raw = c.get("thanhVienThucHien") + for m in members_raw if isinstance(members_raw, list) else []: + if isinstance(m, dict) and str(m.get("hoTenHocVi") or "").strip(): + out.append( + { + "name": str(m.get("hoTenHocVi")), + "role": str(m.get("chucDanh") or "Thành viên chính"), + "org": str(m.get("toChucCongTac") or ""), + "status": "Đang hoạt động", + } + ) + return out + + +def _seed_milestones_from_content(content: Any) -> list[dict[str, Any]]: + c = content if isinstance(content, dict) else {} + out: list[dict[str, Any]] = [] + timeline_raw = c.get("tienDoThucHien") + for t in timeline_raw if isinstance(timeline_raw, list) else []: + if isinstance(t, dict) and str(t.get("noiDungCongViec") or "").strip(): + out.append( + { + "title": str(t.get("noiDungCongViec")), + "deliverable": str(t.get("ketQua") or ""), + "start": str(t.get("thoiGian") or ""), + "owner": str(t.get("caNhanToChuc") or ""), + "budget": t.get("kinhPhi"), + "progress": 0, + "status": "Chưa bắt đầu", + } + ) + return out + + +async def _seed_cockpit_from_proposal(session, project: ResearchProject) -> None: + """On approval, populate members + milestones from the proposal (only when none exist yet).""" + existing = ( + await session.execute( + select(func.count()) + .select_from(ResearchProjectMember) + .where(ResearchProjectMember.project_id == project.id) + ) + ).scalar_one() + if existing: + return + for i, m in enumerate(_seed_members_from_content(project.content)): + row = ResearchProjectMember(id=uuid.uuid4(), project_id=project.id, sort_order=i) + _apply_fields(row, _ENTITY_CONFIG["members"], m) + session.add(row) + for i, t in enumerate(_seed_milestones_from_content(project.content)): + row = ResearchProjectMilestone(id=uuid.uuid4(), project_id=project.id, sort_order=i) + _apply_fields(row, _ENTITY_CONFIG["milestones"], t) + session.add(row) + + +# --- entity endpoints --- +@router.get("/projects/{project_id}/entities/{entity}") +async def list_entity( + project_id: str, entity: str, authorization: Optional[str] = Header(None) +) -> list[dict[str, Any]]: + _require_db() + uid = _require_authed_uid(authorization) + cfg = _entity_cfg_or_404(entity) + async with get_session() as session: + project = await _load_project(session, project_id, uid, _is_admin(authorization)) + return await _entity_list(session, cfg, project.id) + + +@router.post("/projects/{project_id}/entities/{entity}") +async def create_entity( + project_id: str, + entity: str, + payload: Optional[dict[str, Any]] = Body(None), + authorization: Optional[str] = Header(None), +) -> dict[str, Any]: + _require_db() + uid = _require_authed_uid(authorization) + cfg = _entity_cfg_or_404(entity) + data = payload if isinstance(payload, dict) else {} + async with get_session() as session: + project = await _load_project(session, project_id, uid, _is_admin(authorization)) + _require_approved(project) + count = ( + await session.execute( + select(func.count()).select_from(cfg["model"]).where(cfg["model"].project_id == project.id) + ) + ).scalar_one() + row = cfg["model"](id=uuid.uuid4(), project_id=project.id, sort_order=int(count or 0)) + _apply_fields(row, cfg, data) + session.add(row) + await session.flush() + subject = str(getattr(row, cfg["primary"]) or "") + await _write_audit( + session, project.id, uid, await _actor_name(session, uid), + _role_label(authorization), f"Thêm {cfg['singular']}", subject, + ) + await session.commit() + await session.refresh(row) + return _entity_to_out(cfg, row) + + +@router.put("/projects/{project_id}/entities/{entity}/{item_id}") +async def update_entity( + project_id: str, + entity: str, + item_id: str, + payload: dict[str, Any] = Body(...), + authorization: Optional[str] = Header(None), +) -> dict[str, Any]: + _require_db() + uid = _require_authed_uid(authorization) + cfg = _entity_cfg_or_404(entity) + async with get_session() as session: + project = await _load_project(session, project_id, uid, _is_admin(authorization)) + _require_approved(project) + row = await _load_entity_or_404(session, cfg, project.id, item_id) + _apply_fields(row, cfg, payload if isinstance(payload, dict) else {}) + row.updated_at = datetime.now(tz=timezone.utc) + await session.flush() + subject = str(getattr(row, cfg["primary"]) or "") + await _write_audit( + session, project.id, uid, await _actor_name(session, uid), + _role_label(authorization), f"Cập nhật {cfg['singular']}", subject, + ) + await session.commit() + await session.refresh(row) + return _entity_to_out(cfg, row) + + +@router.delete("/projects/{project_id}/entities/{entity}/{item_id}") +async def delete_entity( + project_id: str, + entity: str, + item_id: str, + authorization: Optional[str] = Header(None), +) -> dict[str, Any]: + _require_db() + uid = _require_authed_uid(authorization) + cfg = _entity_cfg_or_404(entity) + async with get_session() as session: + project = await _load_project(session, project_id, uid, _is_admin(authorization)) + _require_approved(project) + row = await _load_entity_or_404(session, cfg, project.id, item_id) + subject = str(getattr(row, cfg["primary"]) or "") + await session.delete(row) + await _write_audit( + session, project.id, uid, await _actor_name(session, uid), + _role_label(authorization), f"Xóa {cfg['singular']}", subject, + ) + await session.commit() + return {"ok": True} + + +@router.get("/projects/{project_id}/cockpit") +async def get_cockpit(project_id: str, authorization: Optional[str] = Header(None)) -> dict[str, Any]: + """Owner or admin: the whole cockpit in one shot — project + 5 entity lists + recent audit.""" + _require_db() + uid = _require_authed_uid(authorization) + async with get_session() as session: + project = await _load_project(session, project_id, uid, _is_admin(authorization)) + bundle: dict[str, Any] = {"project": _to_out(project).model_dump(mode="json")} + for name, cfg in _ENTITY_CONFIG.items(): + bundle[name] = await _entity_list(session, cfg, project.id) + audit_rows = ( + await session.execute( + select(ResearchProjectAudit) + .where(ResearchProjectAudit.project_id == project.id) + .order_by(ResearchProjectAudit.occurred_at.desc(), ResearchProjectAudit.id.desc()) + .limit(200) + ) + ).scalars().all() + bundle["audit"] = [ + AuditOut( + id=r.id, occurredAt=r.occurred_at, actorName=r.actor_name, roleLabel=r.role_label, + action=r.action, subject=r.subject, detail=r.detail, + ).model_dump(mode="json") + for r in audit_rows + ] + return bundle diff --git a/be0/src/shared_kernel/__init__.py b/be0/src/shared_kernel/__init__.py new file mode 100644 index 0000000..717e9ab --- /dev/null +++ b/be0/src/shared_kernel/__init__.py @@ -0,0 +1,5 @@ +"""Shared kernel — framework-free building blocks reused across bounded contexts. + +Nothing here may import FastAPI, SQLAlchemy, or any adapter. Inner layers +(`domain`, `application`) depend on this; it depends on nothing in the project. +""" diff --git a/be0/src/shared_kernel/entity.py b/be0/src/shared_kernel/entity.py new file mode 100644 index 0000000..4caf73b --- /dev/null +++ b/be0/src/shared_kernel/entity.py @@ -0,0 +1,21 @@ +"""Entity / AggregateRoot bases — identity-based equality (not attribute-based).""" + +from __future__ import annotations + +from typing import Any + + +class Entity: + """A domain entity: equality + hash by its ``id``, regardless of other fields.""" + + id: Any + + def __eq__(self, other: object) -> bool: + return isinstance(other, type(self)) and getattr(other, "id", None) == self.id + + def __hash__(self) -> int: + return hash((type(self).__name__, self.id)) + + +class AggregateRoot(Entity): + """The consistency boundary a repository loads and persists as a whole.""" diff --git a/be0/src/shared_kernel/errors.py b/be0/src/shared_kernel/errors.py new file mode 100644 index 0000000..0b3314e --- /dev/null +++ b/be0/src/shared_kernel/errors.py @@ -0,0 +1,40 @@ +"""Domain error hierarchy. The API layer is the ONLY place these map to HTTP status. + +Each carries a user-safe, Vietnamese-ready ``message``. Raise these from the +domain/application layers instead of ``fastapi.HTTPException`` so inner layers stay +framework-free. +""" + +from __future__ import annotations + + +class DomainError(Exception): + """Base for all domain-rule violations.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +class ValidationError(DomainError): + """Input violated a domain rule. → HTTP 400.""" + + +class AuthenticationError(DomainError): + """Identity could not be authenticated. → HTTP 401.""" + + +class AuthorizationError(DomainError): + """Authenticated but not permitted / not in the required state. → HTTP 403.""" + + +class RateLimited(DomainError): + """Too many attempts in the rate-limit window. → HTTP 429.""" + + +class NotFoundError(DomainError): + """A required aggregate does not exist. → HTTP 404.""" + + +class ConflictError(DomainError): + """State conflict, e.g. a uniqueness violation. → HTTP 409.""" diff --git a/be0/src/shared_kernel/value_object.py b/be0/src/shared_kernel/value_object.py new file mode 100644 index 0000000..0373c1a --- /dev/null +++ b/be0/src/shared_kernel/value_object.py @@ -0,0 +1,15 @@ +"""Value-object base — immutable, compared by value. + +Subclass with ``@dataclass(frozen=True)`` and add fields; equality/hash come from +the dataclass. Value objects validate their own invariants in ``__post_init__`` or a +``parse`` classmethod and are never mutated after construction. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ValueObject: + """Marker base for immutable, value-compared objects.""" diff --git a/be0/src/staff_profile_domain.py b/be0/src/staff_profile_domain.py new file mode 100644 index 0000000..5127c10 --- /dev/null +++ b/be0/src/staff_profile_domain.py @@ -0,0 +1,123 @@ +"""Shared validation + DTO helpers for user staff profiles (no FastAPI imports).""" + +from __future__ import annotations + +import re +import uuid +from datetime import datetime, timezone +from typing import Any, Mapping, Optional + +from src.initiative_db.models import User, UserStaffProfile + +EMPLOYEE_ID_PATTERN = re.compile(r"^[A-Z0-9-]{3,32}$") + +STAFF_PROFILE_AUDIT_KEYS: frozenset[str] = frozenset( + { + "employee_id", + "academic_title_code", + "academic_title_other", + "unit_id", + "unit_name_freetext", + "job_title", + "profile_verification_status", + "verification_submitted_at", + "verified_at", + "verified_by_user_id", + "rejection_reason", + "version", + } +) + + +def normalize_employee_id(raw: str | None) -> str | None: + if raw is None: + return None + s = str(raw).strip().upper() + return s or None + + +def assert_employee_id_shape(emp: str | None) -> None: + if emp is None: + return + if not EMPLOYEE_ID_PATTERN.match(emp): + raise ValueError("Mã nhân sự không hợp lệ (3–32 ký tự A-Z, 0-9, gạch ngang).") + + +def assert_unit_exclusive(user: User, sp: UserStaffProfile) -> None: + if user.unit_id is not None and sp.unit_name_freetext: + t = sp.unit_name_freetext.strip() + if t: + raise ValueError("Chọn đơn vị trong danh mục hoặc nhập tên tự do — không dùng cùng lúc.") + + +def staff_row_for_audit(sp: UserStaffProfile, user_unit_id: Optional[uuid.UUID]) -> dict[str, Any]: + """Whitelist snapshot for audit_events.before/after (JSONB).""" + out: dict[str, Any] = { + "employee_id": sp.employee_id, + "academic_title_code": sp.academic_title_code, + "academic_title_other": sp.academic_title_other, + "unit_id": str(user_unit_id) if user_unit_id else None, + "unit_name_freetext": sp.unit_name_freetext, + "job_title": sp.job_title, + "profile_verification_status": sp.profile_verification_status, + "verification_submitted_at": _iso(sp.verification_submitted_at), + "verified_at": _iso(sp.verified_at), + "verified_by_user_id": str(sp.verified_by_user_id) if sp.verified_by_user_id else None, + "rejection_reason": sp.rejection_reason, + "version": sp.version, + } + return {k: v for k, v in out.items() if k in STAFF_PROFILE_AUDIT_KEYS} + + +def _iso(dt: datetime | None) -> str | None: + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc).isoformat() + return dt.isoformat() + + +def material_staff_fields_changed( + before: Mapping[str, Any], + after: Mapping[str, Any], +) -> bool: + keys = ( + "employee_id", + "academic_title_code", + "academic_title_other", + "unit_id", + "unit_name_freetext", + "job_title", + ) + return any(before.get(k) != after.get(k) for k in keys) + + +def apply_reverify_from_verified(sp: UserStaffProfile, now: datetime) -> None: + """Strict policy: verified profile returns to pending when institutional fields change.""" + sp.profile_verification_status = "pending" + sp.verification_submitted_at = now + sp.verified_at = None + sp.verified_by_user_id = None + sp.rejection_reason = None + + +def assert_complete_for_submission(user: User, sp: UserStaffProfile) -> None: + emp = normalize_employee_id(sp.employee_id) + if not emp: + raise ValueError("Cần mã số nhân sự trước khi gửi xác minh.") + assert_employee_id_shape(emp) + + if not sp.academic_title_code: + raise ValueError("Chọn học hàm / học vị.") + if sp.academic_title_code == "other": + if not sp.academic_title_other or not str(sp.academic_title_other).strip(): + raise ValueError("Nhập nội dung khi chọn «Khác».") + + has_unit = user.unit_id is not None or ( + sp.unit_name_freetext and len(sp.unit_name_freetext.strip()) > 0 + ) + if not has_unit: + raise ValueError("Chọn đơn vị công tác hoặc nhập tên đơn vị.") + + if not sp.job_title or not str(sp.job_title).strip(): + raise ValueError("Nhập chức vụ công tác.") diff --git a/be0/src/structure_analysis.py b/be0/src/structure_analysis.py new file mode 100644 index 0000000..78ed6b2 --- /dev/null +++ b/be0/src/structure_analysis.py @@ -0,0 +1,57 @@ +import os + +import nltk +from rake_nltk import Rake + +class StructureAnalyzer(object): + def __init__(self, config=None): + self.config = config + self.keywords = [] + self.extractor = Rake(min_length=1, max_length=3) + + + def extract_keywords(self, text): + + #change min/max length of the keywords. + self.extractor.extract_keywords_from_text(text) + keywords = self.extractor.get_ranked_phrases() + + # Optional: POS tagging to keep only nouns and verbs + # pos_tags = nltk.pos_tag(keywords) + # keywords = [word for word, pos in pos_tags if pos.startswith('NN') or pos.startswith('VB')] + + return keywords + + def extract_keywords_combined(text, top_n=10): + """ + Combined approach: Use POS tagging for filtering and frequency for ranking + This is often the most effective method + """ + try: + lemmatizer = WordNetLemmatizer() + + # Tokenize + tokens = word_tokenize(text.lower()) + + # POS tagging + pos_tags = pos_tag(tokens) + + # Filter for important POS tags and lemmatize + stop_words = set(stopwords.words('english')) + keywords = [ + lemmatizer.lemmatize(word) for word, pos in pos_tags + if (pos.startswith('NN') or pos.startswith('JJ') or pos.startswith('VB')) + and word.isalnum() + and word not in stop_words + and len(word) > 2 # Filter out very short words + ] + + # Count frequency + keyword_freq = Counter(keywords) + + self.keywords =keyword_freq.most_common(top_n) + + return keyword_freq.most_common(top_n) + except Exception as e: + print(f"Error in combined extraction: {e}") + return [] \ No newline at end of file diff --git a/be0/src/template_routes.py b/be0/src/template_routes.py new file mode 100644 index 0000000..a94ab24 --- /dev/null +++ b/be0/src/template_routes.py @@ -0,0 +1,402 @@ +"""Admin-managed document templates: upload a .docx, extract its {{placeholders}}, and let +applicants render a filled DOCX/PDF by template id. + +- Storage: MinIO bucket ``s3_bucket_templates`` (the .docx file). +- Fields: Jinja placeholder names extracted from the .docx, persisted as JSONB on the row. +- Render: docxtpl fills the template with submitted values; LibreOffice converts to PDF. + +Mounted under ``/api/v1`` in main.py → routes live at ``/api/v1/templates``. +""" +from __future__ import annotations + +import io +import re +import uuid +import zipfile +from datetime import datetime, timezone +from typing import Any, Optional + +from fastapi import APIRouter, Body, File, Form, Header, HTTPException, Response, UploadFile +from pydantic import BaseModel, Field +from sqlalchemy import select + +from src.auth_jwt import decode_access_token_user_id, decode_bearer_token +from src.initiative_db.engine import get_session, is_postgres_enabled +from src.initiative_db.models import DocumentTemplate + +router = APIRouter(prefix="/templates", tags=["templates"]) + +_DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + + +# --------------------------------------------------------------------------- # +# Auth (mirrors the extracted admin routers) +# --------------------------------------------------------------------------- # +def _jwt_roles(authorization: str | None) -> list[str]: + p = decode_bearer_token(authorization) + if not p: + return [] + r = p.get("roles") + return [str(x) for x in r] if isinstance(r, list) else [] + + +def _require_authed_uid(authorization: str | None) -> uuid.UUID: + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.") + return uid + + +def _require_admin_uid(authorization: str | None) -> uuid.UUID: + uid = _require_authed_uid(authorization) + if "admin" not in _jwt_roles(authorization): + raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.") + return uid + + +def _require_db() -> None: + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa sẵn sàng.") + + +# --------------------------------------------------------------------------- # +# Storage (lazy — MinIO config may be absent in some environments) +# --------------------------------------------------------------------------- # +def _templates_storage(): + """Return (storage, bucket). Raises 503 if MinIO/S3 is not configured.""" + try: + from src.minio.storage import S3Storage, settings as s3_settings + + bucket = getattr(s3_settings, "s3_bucket_templates", None) or "initiative-templates" + return S3Storage(), bucket + except Exception as exc: # noqa: BLE001 — surface a clean 503 + raise HTTPException(status_code=503, detail=f"Lưu trữ tệp chưa sẵn sàng: {exc}") from exc + + +# --------------------------------------------------------------------------- # +# Placeholder extraction +# --------------------------------------------------------------------------- # +_VAR_RE = re.compile(r"\{\{\s*([a-zA-Z_][\w]*)") + + +def _humanize(key: str) -> str: + words = re.sub(r"[_.]+", " ", key).split() + return " ".join(w[:1].upper() + w[1:] for w in words) if words else key + + +def _regex_extract(docx_bytes: bytes) -> set[str]: + """Fallback: strip XML tags (placeholders may split across runs) then scan for {{ var }}.""" + found: set[str] = set() + try: + with zipfile.ZipFile(io.BytesIO(docx_bytes)) as z: + for name in z.namelist(): + if not name.endswith(".xml"): + continue + try: + flat = re.sub(r"<[^>]+>", "", z.read(name).decode("utf-8", "ignore")) + except Exception: # noqa: BLE001 + continue + for m in _VAR_RE.finditer(flat): + found.add(m.group(1)) + except (zipfile.BadZipFile, OSError): + pass + return found + + +def _extract_fields(docx_bytes: bytes) -> list[dict[str, str]]: + keys: set[str] = set() + try: + from docxtpl import DocxTemplate + + tpl = DocxTemplate(io.BytesIO(docx_bytes)) + keys = {str(v) for v in tpl.get_undeclared_template_variables()} + except Exception: # noqa: BLE001 — jinja control tags can trip the parser; fall back + keys = set() + if not keys: + keys = _regex_extract(docx_bytes) + roots = sorted({k.split(".")[0].split("[")[0].strip() for k in keys if k and k.strip()}) + return [{"key": k, "label": _humanize(k), "type": "text"} for k in roots] + + +def _safe_filename(name: str) -> str: + base = (name or "template.docx").strip().replace("\\", "/").split("/")[-1] + base = re.sub(r"[^A-Za-z0-9._-]+", "_", base) or "template.docx" + return base if base.lower().endswith(".docx") else f"{base}.docx" + + +# --------------------------------------------------------------------------- # +# Schemas +# --------------------------------------------------------------------------- # +class TemplateField(BaseModel): + key: str + label: str + type: str = "text" + + +class TemplateOut(BaseModel): + id: str + name: str + description: Optional[str] = None + fields: list[TemplateField] + originalFilename: Optional[str] = None + isActive: bool + createdAt: Optional[datetime] = None + updatedAt: Optional[datetime] = None + + +class TemplateUpdateIn(BaseModel): + name: Optional[str] = Field(default=None, max_length=300) + description: Optional[str] = None + isActive: Optional[bool] = None + + +def _to_out(row: DocumentTemplate) -> TemplateOut: + raw_fields = row.fields if isinstance(row.fields, list) else [] + return TemplateOut( + id=str(row.id), + name=row.name, + description=row.description, + fields=[TemplateField(**f) for f in raw_fields if isinstance(f, dict) and f.get("key")], + originalFilename=row.original_filename, + isActive=bool(row.is_active), + createdAt=row.created_at, + updatedAt=row.updated_at, + ) + + +# --------------------------------------------------------------------------- # +# Endpoints +# --------------------------------------------------------------------------- # +@router.post("", response_model=TemplateOut) +async def create_template( + name: str = Form(..., max_length=300), + description: str = Form(""), + file: UploadFile = File(...), + authorization: Optional[str] = Header(None), +) -> TemplateOut: + """Admin: upload a .docx template; extract its placeholder fields; store in MinIO.""" + _require_db() + admin_uid = _require_admin_uid(authorization) + if not (name or "").strip(): + raise HTTPException(status_code=422, detail="Tên mẫu không được để trống.") + + data = await file.read() + if len(data) < 100: + raise HTTPException(status_code=422, detail="Tệp .docx rỗng hoặc quá nhỏ.") + ctype = (file.content_type or "").strip() + fname = file.filename or "template.docx" + if ctype != _DOCX_MIME and not fname.lower().endswith(".docx"): + raise HTTPException(status_code=422, detail="Chỉ chấp nhận tệp .docx (Word).") + + fields = _extract_fields(data) + template_id = uuid.uuid4() + safe = _safe_filename(fname) + key = f"{template_id}/{safe}" + + storage, bucket = _templates_storage() + try: + result = await storage.upload(bucket, key, io.BytesIO(data), _DOCX_MIME) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail=f"Tải tệp lên thất bại: {exc}") from exc + + async with get_session() as session: + row = DocumentTemplate( + id=template_id, + name=name.strip(), + description=(description or "").strip() or None, + storage_key=key, + original_filename=safe, + content_sha256=result.get("sha256"), + fields=fields, + is_active=True, + created_by=admin_uid, + ) + session.add(row) + await session.commit() + await session.refresh(row) + return _to_out(row) + + +@router.get("", response_model=list[TemplateOut]) +async def list_templates(authorization: Optional[str] = Header(None)) -> list[TemplateOut]: + """Authed: list templates. Non-admin sees active only; admin sees all.""" + _require_db() + _require_authed_uid(authorization) + is_admin = "admin" in _jwt_roles(authorization) + async with get_session() as session: + stmt = select(DocumentTemplate).order_by(DocumentTemplate.created_at.desc()) + if not is_admin: + stmt = stmt.where(DocumentTemplate.is_active.is_(True)) + rows = (await session.execute(stmt)).scalars().all() + return [_to_out(r) for r in rows] + + +async def _get_row_or_404(session, template_id: str, *, is_admin: bool) -> DocumentTemplate: + try: + tid = uuid.UUID(template_id) + except (ValueError, TypeError): + raise HTTPException(status_code=404, detail="Không tìm thấy mẫu.") + row = ( + await session.execute(select(DocumentTemplate).where(DocumentTemplate.id == tid)) + ).scalar_one_or_none() + if row is None or (not is_admin and not row.is_active): + raise HTTPException(status_code=404, detail="Không tìm thấy mẫu.") + return row + + +@router.get("/{template_id}", response_model=TemplateOut) +async def get_template(template_id: str, authorization: Optional[str] = Header(None)) -> TemplateOut: + _require_db() + _require_authed_uid(authorization) + is_admin = "admin" in _jwt_roles(authorization) + async with get_session() as session: + return _to_out(await _get_row_or_404(session, template_id, is_admin=is_admin)) + + +@router.get("/{template_id}/file") +async def download_template_file(template_id: str, authorization: Optional[str] = Header(None)) -> Response: + """Admin: download the raw .docx (for preview/editing in the admin UI).""" + _require_db() + _require_admin_uid(authorization) + async with get_session() as session: + row = await _get_row_or_404(session, template_id, is_admin=True) + key, fname = row.storage_key, row.original_filename or "template.docx" + storage, bucket = _templates_storage() + chunks: list[bytes] = [] + try: + async for chunk in storage.download_stream(bucket, key): + chunks.append(chunk) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Tệp mẫu không tồn tại trong kho.") + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail=f"Không tải được tệp mẫu: {exc}") from exc + return Response( + content=b"".join(chunks), + media_type=_DOCX_MIME, + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + +@router.put("/{template_id}", response_model=TemplateOut) +async def update_template( + template_id: str, + payload: TemplateUpdateIn = Body(...), + authorization: Optional[str] = Header(None), +) -> TemplateOut: + """Admin: update metadata (name / description / active). File replace is a separate step.""" + _require_db() + _require_admin_uid(authorization) + async with get_session() as session: + row = await _get_row_or_404(session, template_id, is_admin=True) + if payload.name is not None: + if not payload.name.strip(): + raise HTTPException(status_code=422, detail="Tên mẫu không được để trống.") + row.name = payload.name.strip() + if payload.description is not None: + row.description = payload.description.strip() or None + if payload.isActive is not None: + row.is_active = payload.isActive + row.updated_at = datetime.now(tz=timezone.utc) + await session.commit() + await session.refresh(row) + return _to_out(row) + + +@router.delete("/{template_id}") +async def delete_template( + template_id: str, + hard: bool = False, + authorization: Optional[str] = Header(None), +) -> dict[str, Any]: + """Admin: soft-delete (is_active=false) by default; ?hard=true removes the row + MinIO object.""" + _require_db() + _require_admin_uid(authorization) + async with get_session() as session: + row = await _get_row_or_404(session, template_id, is_admin=True) + key = row.storage_key + if hard: + await session.delete(row) + else: + row.is_active = False + row.updated_at = datetime.now(tz=timezone.utc) + await session.commit() + if hard: + try: + storage, bucket = _templates_storage() + async with storage._client() as s3: # noqa: SLF001 — single-object delete + await s3.delete_object(Bucket=bucket, Key=key) + except Exception: # noqa: BLE001 — row already gone; object cleanup is best-effort + pass + return {"ok": True, "hardDeleted": hard} + + +class RenderIn(BaseModel): + values: dict[str, Any] = Field(default_factory=dict) + format: str = "pdf" # "pdf" | "docx" + + +@router.post("/{template_id}/render") +async def render_template( + template_id: str, + payload: RenderIn = Body(...), + authorization: Optional[str] = Header(None), +) -> Response: + """Authed: fill the template with `values` → DOCX (docxtpl), optionally PDF (LibreOffice).""" + _require_db() + _require_authed_uid(authorization) + fmt = (payload.format or "pdf").lower() + if fmt not in ("pdf", "docx"): + raise HTTPException(status_code=422, detail="format phải là 'pdf' hoặc 'docx'.") + + async with get_session() as session: + row = await _get_row_or_404(session, template_id, is_admin="admin" in _jwt_roles(authorization)) + key, base = row.storage_key, (row.original_filename or "document.docx").rsplit(".", 1)[0] + + storage, bucket = _templates_storage() + chunks: list[bytes] = [] + try: + async for chunk in storage.download_stream(bucket, key): + chunks.append(chunk) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Tệp mẫu không tồn tại trong kho.") + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail=f"Không tải được tệp mẫu: {exc}") from exc + template_bytes = b"".join(chunks) + + try: + from docxtpl import DocxTemplate + from jinja2.exceptions import TemplateError + + doc = DocxTemplate(io.BytesIO(template_bytes)) + try: + doc.render(payload.values or {}) + except TemplateError as exc: + raise HTTPException(status_code=400, detail=f"Mẫu có cú pháp không hợp lệ: {exc}") from exc + out = io.BytesIO() + doc.docx.save(out) + docx_bytes = out.getvalue() + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=f"Điền mẫu thất bại: {exc}") from exc + + if fmt == "docx": + return Response( + content=docx_bytes, + media_type=_DOCX_MIME, + headers={"Content-Disposition": f'attachment; filename="{base}.docx"'}, + ) + + try: + from src.be01.docx_to_pdf import convert_docx_bytes_to_pdf + + pdf_bytes = convert_docx_bytes_to_pdf(docx_bytes) + except Exception as exc: # noqa: BLE001 — LibreOffice missing / convert failure + raise HTTPException(status_code=502, detail=f"Chuyển PDF thất bại: {exc}") from exc + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{base}.pdf"'}, + ) diff --git a/be0/src/test/__init__.py b/be0/src/test/__init__.py new file mode 100644 index 0000000..9645e6d --- /dev/null +++ b/be0/src/test/__init__.py @@ -0,0 +1 @@ +# Test helpers and fixtures for be0 (e.g. DOCX fill smoke tests). diff --git a/be0/src/test/__pycache__/__init__.cpython-313.pyc b/be0/src/test/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c3c94dec01780091562ed49ce6f889e34a11a59 GIT binary patch literal 117 zcmey&%ge<80tWHa^HWN5QtgUZfr>ze6oVKanHd=wiY~=b(Xs%mlSDzI+bZz8qx6w?TC^s%dnN&Xe8T(~bwdrU7RU z^WMz7_vXE~?`tO>k0SA{{?Gg%gwWsJO>GH!P}=z?Ad5&t6lszcxG9YWXaEabzA011 zg~8E~181XQs@85IG~(VwX%wJxvtcwwV;D`vl4vSEAbGMg$7*lw0|PZY)4A`khHAl{ z283u+J4zxglt5Z|Dw;{YNH-vRXS8LiRZ|DwL&#`B2xw2gNSj3dix|zwGjb|ooB;Kr zTFIUxm=T8!VoIz$q4+ix-NT53k zgjh(ET`Z_6G&B=Tg?Z~Wu<2db(>K843GjvUZ7ayg)k4da3n7gl6r`O6I9)_p_fRaE z+PlvW07;^(;_owIAKQZv#(~R{AG($_i87JBTOZTpNok-a9ix}fD6c>hd3M=$n`c&d7v2u1E zB9ccKHLMwZ+BA5mWK{INb2WIr2K#I90&kc$EIntG^f|(KoDoZR1nZnhtOud&#C2`r z=P8vdBI|gtXd8Kl2lY|yopBza#K`L+{kUp44xwzs;!zKqpcTSphfyANm_eB%vN;vt zibIM8F6Y>NNBmce`E>6jusXf0Xp}Bp?uAgSl!(QgUP?;l{T{o}Q=0E714WtSn0c4< z6wLwz=zNj%^q=pAtnP*U?G^d!E-&P7x;)Rl%&ujSutawa(G4rFmkgGhC5~5IbhoVV zp*jii17);+RZC z>cozStNPiUg#WULI=%LUMkPzaD60y3@nyUg=WS{$?~HsSBG9X@s;@V$!A<)81tY`2e3D*hWWAVPrsav;v?* zy*edN1`eVAfG-E5E@<9X;H|*;haN0Q1$0I3LInw=Hu*1u)Yo4wH7km%`%ONVT<3+~ z`7dNeG`Szhy&~jlp?WehKl17Jpx=<+gd+p|S>Mij()KhWHJkTrO{wElBrx=+gy@nl zG;uc%AGAS!p2KFTY*T1EIJkyKBdA@^src76Qs@1&&asVZa|OCy9`tW8gCu5^OXZPqPpE9lsDCC znKNQR>9TT!2dC$uzVL_u2BpS4k9#yfg$l@kMJ^I66{JVSyRM27p(0?p3{BnRymU4f({@LZV;{zM&MJObjYNDzpR{EEm zG9a3v9*rAC(TdfwZw(Z*g!Ql@N8R#%V)bW)YL8L zk~O-O<4eZ!$<0`IHP-#?>&D~`k|Ql!jqNMLtJ3m~kKg|2?d9?H#xq+@ZGVjaA-)ScKJSYTwacFmwhgt)Pg{e4KRp_~c1V7D zA~CGUUnp_Fzi7U6Eh2vz2?G8p8i(!^@;uFzaoZv=_^7&W86~3YJgVy@HzWX$>H04! zMzMC&sKXdY9ac0gV%fS*g|q27SomRGhX`S2PB$2%=5z)AED-by0xTBszp0p;+(iVa zBRco#Z&B3!Cr63aOlnm3-_0(Y=)tWD`t_ImcwH| zDbMTAcSm%XZ6UPMUTnfNou=d-H5uWN=SMv3pGAS$jYNoOp&3{GftZKI>{>LZsTkpe zR*;OsNxp!N>Z!FeLsfO? wVQ@FpA)Q@1y_q;$O`P3Du-U$fl-R<9b@|Aa68r7g`(wX;XIn-}!gcch0fG2JZ2$lO literal 0 HcmV?d00001 diff --git a/be0/src/test/__pycache__/test_docx_pseudo_fill.cpython-313.pyc b/be0/src/test/__pycache__/test_docx_pseudo_fill.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..154b695899af4180772e03161edfa06aa2e0bda7 GIT binary patch literal 3311 zcmb7G&2JmW6`$oU$t6XK`f@7MvNWRO584qcN0#9zwSh>s3)vBqawRwbRg6~TQd*1L zWoMW1$H@rLR!$1IIS5rC0pX#C1n9y4LYy3O6d?str^%tnDK`~T(Kf#H&3>4QOasmy z-g`6i=FOY8zxUqk#p6-Lzxx054?_rj?+iQ=bh)s%0m2GW5Jifh1a1m90yKcREZ!8U z=x~Wjz@?kP8zCCPXf~8Wv*A&}wUF!dEFNH^o}BACFzJa(aHtI-8tp|Xq=b@43C~Ko zSDx1!72P=+n~f{-*n0@6U=4Kpp4Uww|3r)y#04=OQBQ(T3RhKdACwI42yD}>C45H9EM)M+q;og3X5$h#RWWOZoz6sm;NeUQtW7mwj}s0n z3uEbBuyp?w&!t0wC>;C;0e2wmodID5%_1rQoC1mf@Chk_B)ZK36jDS-3Mvu}Ed>u}>~h=uh-H`pk6&~oeoa1lU3*jt9#6_j@-ImvnFKAQj`h4PX=&V_xc z$vMQ|5Paww!VJnqnybE~h%>^dXC0##=twW26MPAs&`aoqUqVMdqytHI_{+!rnHflL zUICN{r+z$g34guGm8ie!b4W3iZp;1{tl6cZOP>o&){KH#1VplkO;uxo%`3#@HkHAq3NkR{D#aa%Kp#kVf$nsGmrpT4F{|28c%iDw-PmQA&2u^_pj zypv@Ss;Nc7(~rrjWogu&G+5M?rfE%MqGeMSwQQBz7SHB%fJv5CE-|rS-?jLE#h493 z`u&oppF^HYzfAQWa}(1`0PYBJ{2;1PBo);zENYfp8;DaC*mw&(|4bV_;GDGrx6csWlj4!w8)~ffpVa+?kTfaJG8=!(;bbn4zCnhlLk~CqoA?i{5_~IEW(*e zsd|saU7cS;0kdI?Sk{bmkRIdbIxY$t<$z@(^k|D0X3Rf&oX_?1TIH50_a#H9X4j|z zCCEAE^%oW&9xPDiub-dp0RSV50eOIS17hUjcd_5>%P7&^y6o%*!jZ1OcfIzRve|Wh zd1AK%MdI7?@rHbSweZ(#e|ux&#ORiM5emt+oNUO+^^sL;?NWXE8~N;i_m88FV|z%5 zT-=FuJeqnqwb3{Hxbv~Nk-V@S8*Ri!pYDpV_;f#pItIC3%F|mvv8t|}+Kvr2VuMe= zO{Dga80p$c^sZ0Tg|+LSzWvGDYuU}j*`1D_zr;U|uNUfrYo)D@lRL4_r@LWL{m~kO z>wOS@}D+vOxIBMBt-Ovn^5Xys1 z&jVpc2myrHdV#36P4)R2{8>;K;R=`&{=X@I_BezC)Z&f%4`@Fi!;9iSJ)OaGt|nLF z?o?FWAS9h&VZtHedRD3dKEO-LAJPuane0D4S(v{Q%lbU!&n0dGGKb$NOf}0azpt@S zRRzr0PF66Pr^|Q}qZ2A7CcyvdK{c-Apso%e|D None: + ctx = json.loads(_JSON.read_text(encoding="utf-8")) + self.assertIn("trang_bia", ctx) + self.assertIn("mau_01", ctx) + self.assertIn("mau_02", ctx) + self.assertIn("mau_03", ctx) + self.assertIn("mau_04", ctx) + self.assertIn("ban_cam_ket", ctx) + self.assertTrue(str(ctx["trang_bia"]["ten_sang_kien"]).startswith("[TEST]")) + + def test_docx_render_returns_bytes(self) -> None: + try: + from src.be01.fill_application_form import fill_application_form_docx + except ImportError as e: + self.skipTest(f"be01 import failed: {e}") + + ctx = json.loads(_JSON.read_text(encoding="utf-8")) + try: + out = fill_application_form_docx(ctx) + except FileNotFoundError as e: + self.skipTest(str(e)) + except ModuleNotFoundError as e: + if "docxtpl" in str(e).lower(): + self.skipTest(str(e)) + raise + + self.assertIsInstance(out, (bytes, bytearray)) + self.assertGreater(len(out), 4000) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/src/utils.py b/be0/src/utils.py new file mode 100644 index 0000000..343d686 --- /dev/null +++ b/be0/src/utils.py @@ -0,0 +1,407 @@ +import fitz # PyMuPDF +import base64 +import os +from typing import Dict, List, Tuple, Optional, Union +import ollama # Import the Ollama library +import tempfile +import logging + +from pathlib import Path + +class Section: + """Represents a document section with type, content, and bounding box.""" + + def __init__(self, section_type: str, content: str, bbox: Tuple[float, float, float, float]): + self.type = section_type + self.content = content + self.bbox = bbox + + def to_dict(self) -> Dict: + return { + 'type': self.type, + 'content': self.content, + 'bbox': self.bbox + } + +def get_available_models() -> List[str]: + """ + Get a list of available models from Ollama. + + Returns: + List[str]: A list of available model names. + """ + try: + models_response = client.list() + logger.info(f"Ollama models response: {models_response}") + + if "models" in models_response and isinstance(models_response["models"], list): + model_names = [model["model"] for model in models_response["models"]] + return model_names + else: + logger.warning("No models found in Ollama response") + return [] + except Exception as e: + logger.error(f"Error fetching Ollama models: {e}") + return [] + +def _extract_text_blocks(page: fitz.Page, show_text: bool) -> List[Section]: + """Extract text blocks from a PDF page.""" + sections = [] + blocks = page.get_text("blocks") + + for block in blocks: + if block[6] == 0: # Text block + rect = fitz.Rect(block[:4]) + content = block[4] if block[4] is not None else '' + + if content.strip(): # Only add non-empty text + section = Section('text', content, (rect.x0, rect.y0, rect.x1, rect.y1)) + sections.append(section) + + if show_text: + page.draw_rect(rect, color=(1, 0, 0), width=1.5) + + return sections + +def _extract_image_data(page: fitz.Page, img_index: int) -> Optional[bytes]: + """Extract image data as bytes from PDF page.""" + try: + img_list = page.get_images(full=True) + if img_index < len(img_list): + img_info = img_list[img_index] + xref = img_info[0] + pix = fitz.Pixmap(page.parent, xref) + + # Convert CMYK to RGB if needed + if pix.n - pix.alpha < 4: + img_data = pix.tobytes("png") + else: + pix1 = fitz.Pixmap(fitz.csRGB, pix) + img_data = pix1.tobytes("png") + pix1 = None + + pix = None + return img_data + except Exception as e: + logger.warning(f"Failed to extract image data: {e}") + return None + +def _process_image_with_ai(img_data: bytes, model: str = "qwen2.5vl:7b") -> str: + """Process image with AI to extract text or analyze diagrams.""" + try: + import base64 + + # Encode image to base64 + img_base64 = base64.b64encode(img_data).decode() + + # Create prompt for image analysis + prompt = """Extract all text from this image exactly as it appears. Do not analyze, describe, or interpret the image. Only output the readable text you see, preserving the original formatting and layout as much as possible. If there is no text, respond with "[No text found]".""" + + # Send to Ollama with image + response = client.chat( + model=model, + messages=[ + { + 'role': 'user', + 'content': prompt, + 'images': [img_base64] + } + ] + ) + + if response and 'message' in response and 'content' in response['message']: + return response['message']['content'] + else: + logger.error("Invalid response format from Ollama vision model") + return "[Image: Could not analyze]" + + except Exception as e: + logger.error(f"Error processing image with AI: {e}") + return "[Image: Analysis failed]" + +def _extract_images(page: fitz.Page, show_images: bool, process_with_ai: bool = False) -> List[Section]: + """Extract images from a PDF page and optionally process with AI.""" + sections = [] + + img_list = page.get_images(full=True) + for img_index, img in enumerate(img_list): + try: + bbox = page.get_image_bbox(img) + if bbox: + content = "[Image]" + + # Process image with AI if enabled + if process_with_ai: + logger.info(f"Processing image {img_index + 1} with AI...") + img_data = _extract_image_data(page, img_index) + if img_data: + ai_analysis = _process_image_with_ai(img_data) + content = f"**[Image Analysis]**\n\n{ai_analysis}" + + section = Section('image', content, (bbox.x0, bbox.y0, bbox.x1, bbox.y1)) + sections.append(section) + + if show_images: + page.draw_rect(bbox, color=(0, 0, 1), width=1.5) + except Exception as e: + logger.warning(f"Failed to extract image: {e}") + continue + + return sections + +# Removed table extraction functions - focusing on text and images only + +def process_pdf(pdf_path: str, show_text: bool, show_images: bool) -> Tuple[fitz.Document, Dict[int, List[Dict]]]: + """ + Process the PDF to extract sections (text, images, tables) in reading order. + + Args: + pdf_path (str): Path to the PDF file. + show_text (bool): Whether to draw borders around text blocks. + show_images (bool): Whether to draw borders around images. + + Returns: + Tuple[fitz.Document, Dict[int, List[Dict]]]: The processed PDF document and a dictionary of page sections. + """ + try: + doc = fitz.open(pdf_path) + page_sections: Dict[int, List[Dict]] = {} + + for page_num in range(len(doc)): + page = doc.load_page(page_num) + sections = [] + + # Extract different types of content + text_sections = _extract_text_blocks(page, show_text) + image_sections = _extract_images(page, show_images, process_with_ai=False) + + # Combine all sections + all_sections = text_sections + image_sections + + # Sort sections by reading order (top to bottom, left to right) + all_sections.sort(key=lambda s: (s.bbox[1], s.bbox[0])) + + # Convert to dictionary format + page_sections[page_num] = [section.to_dict() for section in all_sections] + + logger.info(f"Page {page_num + 1}: Found {len(text_sections)} text, " + f"{len(image_sections)} image sections") + + return doc, page_sections + + except Exception as e: + logger.error(f"Error processing PDF: {e}") + raise + +def get_page_images(doc: fitz.Document, zoom_level: float = 1.5) -> Dict[int, str]: + """ + Render each page of the PDF as a base64-encoded PNG image. + + Args: + doc (fitz.Document): The PDF document. + zoom_level (float): The zoom level for rendering (affects image size). + + Returns: + Dict[int, str]: A dictionary of page images as base64 strings. + """ + page_images = {} + + try: + for page_num in range(len(doc)): + page = doc.load_page(page_num) + + # Create transformation matrix for zoom + matrix = fitz.Matrix(zoom_level, zoom_level) + pix = page.get_pixmap(matrix=matrix) + + # Convert to PNG bytes and encode to base64 + img_bytes = pix.tobytes("png") + img_base64 = base64.b64encode(img_bytes).decode() + page_images[page_num] = img_base64 + + logger.debug(f"Generated preview image for page {page_num + 1}") + + except Exception as e: + logger.error(f"Error generating page images: {e}") + + return page_images + +def save_and_encode_pdf(doc: fitz.Document) -> Tuple[bytes, str]: + """ + Save the modified PDF to a temporary file and encode it to base64. + + Args: + doc (fitz.Document): The PDF document to save and encode. + + Returns: + Tuple[bytes, str]: The raw PDF bytes and the base64-encoded string. + """ + try: + # Create temporary file + with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file: + modified_pdf_path = temp_file.name + + # Save document + doc.save(modified_pdf_path) + doc.close() + + # Read and encode + with open(modified_pdf_path, "rb") as f: + modified_pdf_bytes = f.read() + + # Clean up temporary file + try: + os.unlink(modified_pdf_path) + except OSError: + logger.warning(f"Could not delete temporary file: {modified_pdf_path}") + + base64_encoded = base64.b64encode(modified_pdf_bytes).decode("utf-8") + logger.info("PDF successfully saved and encoded") + + return modified_pdf_bytes, base64_encoded + + except Exception as e: + logger.error(f"Error saving and encoding PDF: {e}") + raise + +def format_text_with_llama(text: str, model: str) -> str: + """ + Use the selected Ollama model to format the text into Markdown. + + Args: + text (str): The text to format. + model (str): The model to use for formatting. + + Returns: + str: The formatted Markdown text. + """ + if not text.strip(): + logger.warning("Empty text provided for formatting") + return text + + try: + logger.info(f"Formatting text with model: {model}") + response = client.generate( + model=model, + prompt=text + ) + + if "response" in response: + return response["response"] + else: + logger.error("Invalid response format from Ollama") + return text + + except Exception as e: + logger.error(f"Error formatting text with Ollama: {e}") + return text # Return the original text if formatting fails + +def initialize_a_logger(logger_name: str = "./logs/ChatBot.log"): + logger = logging.getLogger('example_logger') + + # Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + logger.setLevel(logging.DEBUG) + + log_path = Path(logger_name).expanduser().resolve() + log_path.parent.mkdir(parents=True, exist_ok=True) + # Use the same resolved path as mkdir — relative paths + cwd quirks broke FileHandler in Docker. + fh = logging.FileHandler(str(log_path), mode="w") + logger.addHandler(fh) + return logger + +def build_prompt(text: str) -> str: + + + return f""" + As an expert auditor, read the text line by line, analyze semantic chunks, + and infer the compliance rules in the text thoroughly. + Return only valid JSON. If information is missing, leave fields empty. + + Input Text: + {text} + + LLM Tasks: + - Parse text as semantic chunks, analyze input and the Type descriptions and concisely extract compliance rules based on the format. + 1. Ubiquitous Requirements. Description: No pre-condition, Always active + Format: + Example: "The Bank SHALL maintain audit trails for all change requests." + + 2. Event-driven Requirements. Description: Begin with WHEN, Triggered by specific events + Format: WHEN + Example: "WHEN acquiring software packages, the Bank SHALL perform a detailed evaluation to ensure user and business requirements are met." + + 3. Unwanted Behavior Requirements. Description: Begin with IF...THEN, Express undesirable situations to be handled + Format: IF THEN + Example: "IF a supplier's financial stability is in doubt, THEN the Bank SHALL have alternatives to mitigate potential loss of service." + + 4. State-driven Requirements. Description: Begin with WHILE, Active during specific system states + Format: WHILE + Example: "WHILE systems are in production, the Bank SHALL maintain hardware and software requirements at recovery locations." + + 5. Optional Feature Requirements. Description: Begin with WHERE, Apply when certain features are enabled/present + Format: WHERE + Example: "WHERE third-party service providers are involved, the Bank SHALL implement secure remote access mechanisms." + + + 6. Complex Requirements. Description: Combine multiple condition types, Handle more sophisticated scenarios + Example: "WHEN changes are implemented to information systems, IF the change is an emergency change, THEN the Bank SHALL follow formal emergency change management procedures, INCLUDING regularization of implemented emergency changes." + + + - Normalize language → turn “should/must/required/have to” into clear compliance rules. + + + Output JSON: + {{ + "requirements": ["output 1", "output 2", ...] + }} + """ + +def extract_pdf_text(pdf_path: str): + """ + Extracts text page-by-page from a PDF, sends each to the Ollama endpoint, + and returns structured compliance rules in JSON. + """ + + def parse_pdf_to_json(text: str): + prompt = build_prompt(text) + url = "http://localhost:4402/test_ollama_1" + + try: + # Send prompt to your FastAPI Ollama endpoint + response = requests.post(url, json={"prompt": prompt}) + response.raise_for_status() + raw = response.json() + + # Try to clean and parse LLM JSON output + content = raw.get("oss_json", "") + cleaned = content.replace("```json", "").replace("```", "").strip() + + try: + parsed = json.loads(cleaned) + except json.JSONDecodeError: + parsed = {"requirements": [cleaned]} + + return parsed + + except Exception as e: + print(f"Error processing page: {e}") + return {"error": str(e)} + + reader = PdfReader(pdf_path) + all_requirements = [] + + for i, page in enumerate(reader.pages): + text = page.extract_text() + if not text: + continue + + print(f"Processing page {i + 1}...") + result = parse_pdf_to_json(text) + all_requirements.append({"page": i + 1, "result": result}) + + return all_requirements + + + + + diff --git a/be0/template_application_form.docx b/be0/template_application_form.docx new file mode 100644 index 0000000..e69de29 diff --git a/be0/tests/__pycache__/auth_register_staff_fixture.cpython-313.pyc b/be0/tests/__pycache__/auth_register_staff_fixture.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5b032b8ddd8626d9c0d0a71871afa9fc52aea5bc GIT binary patch literal 847 zcmZWn&rj4q6rOIoWdTdTAJM49k)YT_cVpDVgV6*Gi4wt3I0%|%*iPBO{$e^M%brNQ z=|L|f>rIay{0lhx4@f+KbCRG3ZkUaU-h9(t2no&Pn{VEm_xiqhuVq<1NPQpvvA-HZ zyZNC5h4!wst^!#{4&unsoWg|mxWEfoaeYGPx>IzF^JvN7#-eVUjj1OrVj&6e3JI8p zQ$c(m`-}!&iv5`5r&G=hu8@RPR&G{ECTbN<7g;JO$1H*+=prYA#Ss=X6=``-Rbgwg zmqbx4a!)#0ZI{-@B5XQBT5AQ!I+{Q9O?MO=8h{L;JDuaSan#=CN}qwIyN-Jfq9Wwz zv#1ZhO9cnL_o)4kIH^1MNE>Aj&n){tqWONiWhP} z1RGh#JXutDTk17h2cCc;=!KbPzR&6@Obo(uIB!(mOZT9c9YJtOWSo|hRe9!^ON|6q zLX^Dp0l*siX$`!cdp-B&`Ia@fX$>|Pn#*6U%UjmSrZw`-ve(9coV(B*Z{B?uZ4X}A zzF65led%|1@r1dLibb;pDUe-|Ca3V%sL|)Ks+dm&zYb%_U)V+2<+@&6bzRxxx_+ig zb6pOzgLtn3Rm>;EPb%IIAWQec*vkTXpPz-JlAE3a*w-~p`;KORp`l&W`^PM4W*+n( DE$ire literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/security_token_fixture.cpython-313.pyc b/be0/tests/__pycache__/security_token_fixture.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f7b25373b10e6b8c747341cb7d9ac6042488a31 GIT binary patch literal 1609 zcma)6%}*pn6tABCn(6uCW0xR|YcF7=BQs+_M2zUhU4sq@CNvWhM$_BO6wo-+Jyur( zGvUB_jagRKaPg)G{SW*zmT=gyg3$!`u(#P@Jo~DLhK(ny)AfGu)vKyk@BOMS(rFDr zTP+W5uZakqb0unW4+s}+0N*1M5o8KxY)L4^NKEHgToQ>0Qd*Kq5|K(WkxL3u!ZCSC zEyYRv(l0?0;HNBUr6fs$O*KU`4wghgx`qDxnMqR{LEF-HjHEV2Bh~)Q+_DL->7~zC z^>qx8=v&yXf!OefPP~9(-N)5{IJBix>{DN#41DbCc0jkR?Oj@c-vSSU>7-#4p7WC# z0!!Mi>rtCJp6f3gB8%5-imB7UEW!I4u2Y-EIc$3_h6!;Ke;r`A%CUC6CTPlzgLg4$ z4@Yt2c^r3wMzerxL1D)=BqkHDj(sNj!8((1!*=Ry079x^mpXRc+QGz!NUFWGBP0Px z!4Gv2E{MQuP!<-1$;@vj(Un!K^j}%!mB9#K%P6{N#>@T{IA5;;Ph#^z9+dyH zhA9>lSE&p|vsZ>AbS=kFS*eUhdK85znex2os-{v_GiW9@fedx|IX^fQSl;pi;u{J{ zf=+l{2u~A>Rd<-^*p!KQugT<3%<0*8z&&BtH$C9vR->(c)T6ACVNwt{H74`^ZNlrp zMAzFTydF#pXcf3Y36tuM59>KD?eWm3cB4u7bz~xtjw!%E57^HOX2ioVUW$sBhp9k+ zVmUR!56{%5-Ku*w>`c9{uL^A zAc741{@R$NP`=Pqm4IyMJW|4(Gnv z@AMCy_1`?iN28sLeg?LgREr&YcX-oes{QX5V{} zR}TaPdhp_e9EFKtRaQ!mPH_(S5H# zUA_#H-0tJmE0oKh-qZK>Wq22QWEg>Igy){*pkoR=H34x6HydfDT2{@gS{BnRYa`%^ zu`B`?8xhXakP2aedsI+ptLeC#MCD_*pkpbYR148-78X8C;V+21&V9zYPyGFcR}1R+ z12P3Fr>%bwidc5c1)A~_(u n`e$;uD}(GRNE_*@9K}&8+f8t!p;vNU?$u2pCGk%hP#D3#n?iQw literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_admin_audit_routes.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_admin_audit_routes.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec661f176a762f903ff5429f8cfe26699f844c3a GIT binary patch literal 2322 zcmb_d&2JM&6o30+d+j*bAyAT_R7-$@1+jrBB+&2?NKpw&sIqa@DoCTvdhBemcg@Ti zNW=lrFF2J0kwEGpJ+-~HT*{x&atbvqbk#~oRaJ3|2vWtZZ+7kA7OJXJN7^@UX5PH_ z=J!5!wY@z7Vk|wgEd}7$V9_9vaF$lDBeMtwFfb%TJ|&IIOx6XjoKlz?%#raZ$G!0w z(;6pn7Vm;SFw`C}B4^@L8(LL|{2*EsgSDMW8qtIA0GR6mP_`%BZ>4=O*jVaNQc6lD zws71q9ow7NtvPC)<$BR&x>=~$j&9Zpwx=_<=250ITC%yvOwV>5eW&B* zkjXw4N;8t*W;%}R1wwq%YBel}3u$#HGK+8qn1o2khQt&jYREnCo{AVm4JF_rhRUL) zNG9g@j0#^yg)38mCq~6ROQ$LK_+t%KSyGD?v4LPntKSDk0+?Z3awHw{(v;dF*JjK@7#3fxn%UccIXvMzR?-u(j^Ziwns8Y+b&(r?0 zUBvX5FVp^keYsh>C+CT52`C9rA^}PyfRJaa^L~7!L>+pu$`1SQ{&T)%g`U>CjD%Hz zwn-VQgP2Z%6sTv~pRBor^oKnXIv!gmBArtArmSMYf-P*9N{bJHK!m7GpLH)x<~|m2vb9; z@DdizE1X%`7oW>SjWp7S`>_&w>tO&ilOxF9Nykrw+%&6UY)of9qxveReFWTNmdxyU1z1>?+AGzK8#psPqH#=^gSUUD& z$BwUiuSS;BM;>a)Puj1wUw4-1eQmc`rLLuZPNwdqroW^0)O0;L{c!#3ciy-&w46S^ z5`lQv6HwxbXHO!~aa7_W@fSCZZc|?to^3&b z{W=Ky3CvW=x^X8Xv(3%ZMgfKa?g|3@+!C3kbLM&5W&XgwMe`+>I?)E-g+zpI|06B= zKvc-MutN{AtSu0n6^VmW}P1+q~KbiW5pUe zjgB$Q7?hf=*<}ygpr1TiohNInZc?2WSZBk~;O{oe=mlnb)Q_)i`LX6+0xyTPhece& zAGuig+BDMRCI&U|IwK6K!l}PK%GX9hxDK;}C?oy`d<4mgB1zIuaPl|k`yJo|ct7x*lxVcW=wSr+|xHq)$ literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_admin_audit_routes.cpython-313.pyc b/be0/tests/__pycache__/test_admin_audit_routes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b540915b7667023627944b890bf2f25fdccfeb52 GIT binary patch literal 2144 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 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_application_backup.cpython-313.pyc b/be0/tests/__pycache__/test_application_backup.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..85bc0a745fa7900dc57c02dedc2332cbe9d4921f GIT binary patch literal 5074 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 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_application_drafts_get.cpython-313.pyc b/be0/tests/__pycache__/test_application_drafts_get.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3448f65e393c168d804a9820a54541edcb5be583 GIT binary patch literal 1981 zcma)7&2JM&6rc5ecZ1b z_DpVdp&sN|2a%JU(Wmz8bQtS-h@%F#XHt$m@(Dt2CqiJmKi=Oddr-c)#Hg&7^=zvC zUHZ!C2)=M`8hhNGmAF`AUWH;#WsNg|PYev;8@`;wbiOJVv2=?9-p$qH9JgL#>&cj?JyIl}(4;quY4T zs%(i1R8FhwY;{1K`ul>Haz5PC2fz!Ic*PuLWuMWUFGw{IvdpOGI5&8 zf?KK4`aAQk&vK*^xZdlTA&c&OWwO+K?C5%mP_Wh-buA*4A`=?dnBF8)Vq9kiMg_eG z6^uOCsKLyFIhBg_ozgKrjj0Qpd(g?mOM9z;rrLK}#(Kfpjf{>7`+HxvZjltwSW|E9 za^Box$u~qx?uyxJq1?uvPsFYu((br-_mpgT8bGgjIPf}F$1WtdZOSN{n&c`dyk}aG zVXWd)CSj!mz3Yei-KyWeFbLC2uJ^@U@85b1pM_S*74)JP8mhR$RITc{l6s>u+ZJ}t zxz7TU31Ek}{!7*-rR)OctV18-5-5E(Z|_-<6u{r^$X zx$0t=jm~TAIty+vqV7zZO@=^|AyI!JV$QA7aO{nB7PpQ6<3y2Ad=26jdSRi|i9hYb zf9yMSZ|vKnOV(zl^UnD4_-gmiPlX>ZtiF9_J@es`wrOXc*j;OO*S&oYj@&=;P=9#k zhpUgSZrDSc9eeLAEH8X@bII5?+W(;Ie%H$J_3rn+FKpN+|9#NA-kp7TV#ChC!JYQy z_HVAQ4EL?JkvEz`zv8+ z#)}Nnjy**kzoDbQ+lOzN&#gULcEx^z2iNf6a|DZLJ;=x`)z|gzO(XN=rOz*Yarv2! JjDyj&{sBq{>9_y@ literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_applications_db_integration.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_applications_db_integration.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..29ff54f527ef7df931a15ef3c4d36e1b36003a17 GIT binary patch literal 42648 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

fucgw<8G#GKRl40d5;&D%@o_C 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 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_auth_policy_integration.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_auth_policy_integration.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..067e0e9fc74c9070ed2a61943aa28281e37dc1a6 GIT binary patch literal 8929 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 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_backup_e2e.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_backup_e2e.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1e2b7b09b0c2f06427ffd75dd2984cfab0762e07 GIT binary patch literal 16403 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 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_dashboard_lookup_routes.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_dashboard_lookup_routes.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d99c5634426837ea0eaf06bf05a374b1dad05de1 GIT binary patch literal 2130 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 Q%8e@@y|berH5VE92kA1%=>Px# literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_docx_normalize.cpython-311.pyc b/be0/tests/__pycache__/test_docx_normalize.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa908169f767919efdbb1396f727d0494544ab9d GIT binary patch literal 24414 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 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_docx_normalize.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_docx_normalize.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ef949c24cb87bd241016036fba5a4e581cce85c GIT binary patch literal 45095 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 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_evidence_initiative_resolution.cpython-313.pyc b/be0/tests/__pycache__/test_evidence_initiative_resolution.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..198e2109dc1776044cbbcbefaacd5b754fc3ba3a GIT binary patch literal 6347 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 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_evidence_kind_parsing.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_evidence_kind_parsing.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff87990135a7b307dad53a913e053e546ef1b757 GIT binary patch literal 2409 zcmbtW&2Jk;6rc63?X}~!w3IeB5?vKku#i6}iXiC+ZPP-V(9mWZTA|X(#vUh2*Sqe_ zY#QfO@hu@FE|oZP&8?M1dxKz8zCW_qVuY`~1@QIif`%-N~23kW$o5CUvr0Hzvy(Ma5Bp?Q5? zFQ%8jobg?5al$yW8UeLtXE9lH>%^}SeA)Hum9%bo1utUqC%=PE3(qi_G46mq( zA;5HoYWS4x8`{FhLZtb zAhc)*qv^Ol%|IAXamEPcC$5B!Cvs8a6q^csvXT%6BVI${n2g!7M7=Tta8}`-(|moj zay+PoP2zJ_p`__9m4Zg8*)FvJ(IPeOE|QYxHlW6BkCa~BUjdR<6y=re2Nn@NrenF) zmcavCTOLG-8K*!}CSHCVeIjZPfO8Nmy9DMM`pK;PZ02s6!>jO@uiVIX^9MSI&a9bd zZ*QV|sGC37IXt~)+6tXtHS?>5>W$sq!stzNY#nwOByI{i6mgfp*(`A@K;5%ik0hHV zubrwS9yMn^T{M^9I3Bh$EU%A(_igpFAZmx4Yt9bQ|II>3X= z#J8@xJ}WDVQWFkQ)CCgyoKvS#YpJ}d$`fXfep4$t-7dQ&pD1V`qEaqn#?W2h3nfCh zMM(pwfnk5Qj)69`jiJJx%eWZ*kMcf#s@iN6cDaUr8!YyZ)wjC&lbwn8*UU=;)h>Md zUZ;Apn?Kc=ymZsVG!L^MEkG?8*=!yEhXCCtSRfIpNFr4JLzVGZ5PrG#g{pw+{Nuat zd-6b^gD@Y?`mnl2@&dIOUcSm&*nx*-E@+BZo}BGROnDY|VDc4v=t^~}N4oi=o$+_q z%=7nWx!-@&Jg+jmf~JZIk)ni$)E9=-7K57jd4AwML{+~H~<7m<$nOeoC{r#!=(|qBds>> zxAGX?Vvw9McVVfBlI!)Xxm0=r*=X6dg<1l>gt~A29mA2 dWn|<&U+(Jp+eYrQ(;uGx^sDN-%#?VFk3%zN{G z@4eYQaUzA}@B4q8_u~jXQiDjqSpM+tGRS+#LJV2hijCpX7>k)w*Tyuab)s%$=~-mO zr?qiIZD|p`S~z6!>4cT&pF+q!gAia(w!qY&8}&w$CR)%Iv|MuS+eyy}%z*NMoAW+1 zXJ-jrcFNQ%QL^NCWfJ&=`7WKEHQ%^Xqii(~sEX-W)04l$A>n0r2347s?E6LEtr>KGT^Fnx?ckXb?M?OIUH1Tu9^$(K!^|Zw#(?GqgP+ z@Bg?Rjx6EOfRd6T2ru6K85E#M(t}2^hfOqz#v=k1G7^&*$kKYFGc3I~a`%P!_&z>y zEPQewZ?xc(2R#XOZS>D&eu=bqssVq6%Ea%!{EqUdO9`$%C-(3Rj_ z-(M0P0cdleJzZ>@7#bWdjtpM^P@D;9Y0+~^w)=EB?gxvM<#eG}Y{z45;07?xIc34n zoto{6bT}B{Mc<er72Aq&Fm>|Xskm{%XDDS!Vo(PA|jlf16`;dLaL0;Q^pC{cglo%0b3=&uIGQ;)JPq&Pj7PhlLd{QqAH!>sjv6*dyung=zynj?- z<+wclkN$C>-#~>Y!U|XMuc?Sf_5Az#H;Mr@`Q*o+n$^u-1vj6MdZ#KQSwK}z*6#8u zvEf~q^DE-=i=%zWmRG<6myghMB~>rH)5u(}4}QF5+&&oP{rPRS;n`Ly1;S5u$R{bv)|%gs}7SzFksugAgwhXcO@0 zl!zU`ZRcumaX`H$0g_vlgiN-5C1gnXOCY{Pd$C0P%HHXCd=~$GQjbsJ5HF$9Guh?{ z0WXt;l>JiZ8gFzd*p|bQktcHcr>W46{hSwZmmjNQ596kVI zN5dHZ8Pl--R|??^kI<>T#92Irzgv0GRoLt*>>)YY=|Ot>>$QfKd8ntqy7BpqFFxAQ Jke*e3{|n>rAzT0e literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_official_to_data_blank_ban_cam_ket.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_official_to_data_blank_ban_cam_ket.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c3a667918d0458476a8c6d6fc4abbde6e117eca7 GIT binary patch literal 10309 zcmeG?ZEPFIl}mC(Qe08CEX#K6x*ADwET*EqEZMOgC6Og7vfaq8R|=fi^_p6)$gRj- zdOsA)xF5=;cXb*RO_~;P5fruJwzdlxb(<7TrR%i-4WmD~k^t%UatAQI!=e8S`1+&$ z)%RvUNUL#^19Cu7d$F8(^XAQ)H*em&$C=rC@pz2E=gJ@DZ!|E>?`XkILT-NVw*Y*H z;TetL1AOpUKnv15bd=SDggIKHg^5!W(IT`aszo6W9jiSW(_(HNr*VK|kJTNGYjMJy ziM29k>iYvuA19mLzN)f*7d}a@mtj(Wr=9dq)EG;ghzC4OHOE$~4;v_If+Qy88-UGGty+~`4-yp@>%8Z-c}O?N;E zOn`AAP6_fge3*~$(az9BkgwehILKqPF2r*aA-)cAd>o*93T*&rBZV3O+C-rUK#d4N zzL~aV`KAe$-$HA*08 z=_aUTe6tdxI6`qTO4p)r6xT{|9K~@khBkyI0*^C^$Hy+VfaMU+49QXc_!v=CgG{NW zk|rsG?9wmRn8sA{DKNpPq=;%-Rx;)W;IPj0?O;#89q6;!-tOMLiI9n%j%A8hW=*t$ z@oaG(+M#KT-F_`^)_S#My7()z4N6Bk$@pmT+hfm@@guOJM$3yo0z`T7A1kZt57_Nk z#+)rL{*yvpy#1O&2F+H$jTh%dl99^`SIO{7vB-P&YQqmBnEUta>Xmq)52AS z6td-o8+js@7q6v=;_$-Wen`fKj*U{Spt8t?;thj{#rKgRG`XqVH#GAg^!{Q>CWco0 z8H^u#l99_hD61!M`$w1GA~VG+Dk;8Cj61hrTp3bcyrE{ zm;&^1`_1y=+t9C3nXb=NVSUiY5wIiQ9h0&0;^iY`cmk~IuV}y=frdSu%n4Q2$W3VX5m9w;7cR4M-0L|Xw6Ofi4IEmkd` zzixHpqq(E&QdSz5>txL%k-^*7U-=|j;s{S=#?faI`OgSM`PI=*nu)rPn z^%qOz_FKiP5X!-I7#-E4gVjvk+yz`su3y0E2kW{~AfN0KftPD(e+zmz4ok4N6D-CZ zKeZ#pT_m36S0=N{uphh@4}flmiu0hH;=9WK$L!JR_!w{dK#>UiD};ptQ&}~D=`3~a zxO5i3E0Wz{4(J}h3lkk23^DRV%85F5a<>mSQuK$F%yu> z3v6gAqKU2pEUiE$%L_ln0>QaxLkMehJ)*G%&%n36rU04urG8t{>~sd{Od3Yla!|(Z(4>P(<=1aL1ttc+voGB z>Z__t0WSF`B!{C%4P(rQT3GntINt+CAOilaxIrf2&j3Vfcfvn@vJL7_-x1wdl5@N+ zj+ClD3mU-rMutBXJ6xw*9e1GOZKB_qH+x`~`=Boq*9g*IyPs#3A|PnoBnDXN*UWBc zHI|`19n8$PkoIE`gD}c;5KtGR9d%dH7S*bozexU71hG59ZgG80ii^l9nw;-*imn5 zWQQijX*($Q*ufbCa@h`?w}aDAobH8R8h$4H480D-CsmJqzH364G`-8nrSebh?;2J` zGcPHI-la)-`9e~iO6F&i1)wNMq9M;n$(%d|^3LX@ zxkGDR&&?ewMZRFM9cyhp2sT?RS=-t?cWCY(ko4>YM80gXr`NjoBG_TE$+cRpRCmOx zJqiq_Rm5g2HoKNPk6_kfW!l4#RXYp>(O8M&t?2mN;9ZufeTlsrVrn9HqfD*nf?+1! zFgN(>;k7!N-3>Ca<192hmqvD{EcT_+%V#Y1%h>#Ijnh2L91V;Guzt{D5B;XsVn?uA z4SUr?f#+%U0gFBO!6A!%cI}ztb3>&lme;t(x#Ra>1{e_&?}-2qg5SOWf~_TNj$wA3 z=QLvJ<86t(O{Id{%mD6&=;ks=A=s*_y%V7WqYR@C`kdF<~XjyT$I4o!pnj)z4JKot4t^$Tp~L&?_^riiiBaMuthWjoDn)!G*oS zNQpv5PQev4USiK`cubK9{3$pvkO*Adw5z=^+mq1AzyLw8FQF%bb_j5o^j)mSt7#2x z6`^i#LPz9YIMorww1zjdb~vpn!i=oJHw28$8E`{BnUdkBYMT%fQQ%Vjf@ElTu?wY4 zY8FMn{ilXE!ikX0BGRrgvLK!v0(v_#11#Xc(vEwg!?CE69fu3epsZI5E$g1pb{z_!fX$n2-%Dr~XX_tkwM7!RVT`*?_Lsslf24Dp^<`oLr zR6)p@DZ`G#rKl=<9}6TR8t&=o@sAyQ&N&kkQkvZW6uF$BNd-+*^M#Zq*|qQ)!IXp; zyMB;rRaTY5nx<-YqcEjv@Y#VNU4X9(l#~HAiHIFiO~VdM+8i(zoQdeJF6^l;^g0EH zQGaz|pN^*>?%|n+hZlASJ@%?H6JbhI^MYdLCqY6yymLNt02R*a8s3)fgP%SRgN4Jb z7+GmKxZE%qN3cT7p&;^xxqCKj-Wnoam^*RuhiD(q!!n=%DV3o;JId}xKaf# zQ+l7w2LKN|FTMEs{u2Ad<$9V}+!L#;`!2ChT-xuHRx02!()u($0BB@(>h*&qw)Jv5 zO)Rc;m37}Gw)N6Mr?gT5myy<|@c}?1voBlRjuN}S)C>Y*YH=Wr`z`^7+u;;fs^Dcx z>67^Y;F0A^uRmR4TQ2XV2_7J_?z_acTzc9mtyI8er1fch0MI_Okt@Tu_Ixm7?HDPs zBc&Z97B{lWy6+M@f=scrQURAKoloQgfOolG-e7T{!tU$CPQrDsvhKUYc9%F%85UQn z;AN!tseAy?9?yD<+l}n{on$@F-BmnY7DtH+QeJURSv^V-Xi`!LV zlh-#p371@D-FJyimbhI`ait1gMrxnR2LQcJ(9d}E9Tt}?vHiC|QcO6Q0ry>E`%5S| z7FVj^WhC^ed;riWsbFzCL6N1FUMIEI>vN~0FRD)$2E_rruSXvM8tqANRf#QjL=e)d zCOmO^lr{h||=XPq+E1GP8ul7-P#KmccfvvV3ldX{0kpg_? zm6InmJV@4>5Y?<}VkcZJ^mO_c1owUVMPr2>73d8L2nMWmH8&$6FAXm*?3i%!@QLHX z$>YbzwMJygre)Ef;e~qP8w?$tuE^th5~`WzcJ_jhhe_T zyvF@I!~F-d?RQN4zu^O*c*C3RZ=79?w^{MF<@m1Ec+!d|ucxed_grkPVaH|3YG}KX owHi8aZL=Eom!3XrHJqKRzZ>2a*nDO9e;LT`od|3U?4eTp8Q;QkqyPW_ literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_official_to_data_blank_ban_cam_ket.cpython-313.pyc b/be0/tests/__pycache__/test_official_to_data_blank_ban_cam_ket.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c3b39b203f0371c56ecba528852b89a52eb9f7a5 GIT binary patch literal 5630 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 Dp|9D5 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_official_to_data_blank_don_vi.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_official_to_data_blank_don_vi.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..beb30a5661a101c6aee8a0c054979c4b575febbd GIT binary patch literal 3062 zcmb7GU2NOd6}}|(L&>t;q)BGGZE}~iote~VnlbGxYqwIPN2*C^O znCX<`r>7B8_9Fzgc$ZPA9z_G8hEYi<36kjk6$VDEd5z)>o@lN7-No4)p0i9cuVbp& zmfwV|7fVSy2v;s(NbIXMvS1qz9zw*1|LV7+?%=Awz` zTlIIa+W4ng!cJpRbyF~d@h{7bf56BP#vbyxevg}WMVJVOhnECTkPC&oYy;<7^*`hJ z#sVz=*u2{bBl(+)`YxACyvrKkUO1OUA63jUwjpBKkBdusYvpgDX<>3Ge-;ln{xswc zf++y)<0&k+>em_C`Y?D}$b)Rvui*Syi2pVoX}k+7^5DU>n zRrJe!R#KrARZ|^(Uh6aTB9ygeXniMh831<1hp;an4B>RO<{k5qz75wZ)r#Pm zXqT%7w2uS8g{Jd{9*C!)Ssv`71NLSQfw+S1Jds<@f7&y0=Px5aat;N)S@%Wvb?M{gX^ z3kZHB^hWofH<70dPPB~)hE~_dT*TjC(!s=BM54mi%KyNO$>K_7*e?{58DKH(P zk27(Ai9;a5V$*KWz5YElC@O|wlUYT*#CWHuQ(z*+1)iehb&FoA8j7lU=O3XW595$g zwtW^vP@myXdrm=p)?)}CYf7h^CtlcyM-CoZ7h!?K|#o51iGR2gVr(A{z1w??rOi+)t&bdlUi`fOr^{K%p-Ii~QHb zy#@bS!WZ;b{VhjwdwsJ-HK3V{S#I2@?Bbn0t9^L6X||#q@S<*FLuWzC;0^b#UT0oS z3{YsTEb7nUmn({HNG_w3{OZ+;kIFY<<7G?1)1J_u%}Q~~KuRA2;WF6eOO1sZIPjA; zVGXu*vrPA~c?R7Ph_s~QIUq@wL9;*`p8#L3{-ss_0IOxU#BHkE$oz_vBVCOc7xV$Kl=kj;6M579-B=<_&a zWxoyL3WDxAHP{^dc~d&k93E|U9o}e*J<+lL7&`Lfu(6(N=ALh!INAJ()Eqd~e064{ zQ)Eq+WKGu2nrz=8)?{00)cMT9^sSEcHK8e{0#bbc@%JyTbxR-hu63VWp8iaHareyh z2Tv_rUhB>-_udxI_`SG<*u^A9HOHl<7s*?u1{Vd95L2mWgm_6pD%PB9Fg-;GJEy{# zP7;uip_`g%5keVP(8EkfAiRTw*zE4362)<-KI=j$!0H4uU@YJlh{l=51UtKJMuvYx zTYNJ53b)nO5&Z@CHSUQH2KLN~Wd`Ur36b`BaRT=!HAu4P!UiBo%F6GFv#xG9x@mi< zm#Q_g^VUt^W(S92IyiRWUY4fX>r{6%FR?S^#ka?ju-FHXLOpT&Cg>fpshWoKv_E}+ z4EX1`k5Er6K(G-7&TW66tckq{%l|u{!4n6Aulyo?32Ydv*`pxV1&-r3c!7)DO(O2t zmk9j(?dgFn1TX6jha#zKZ`=~Pn~~I86ThE$^ZdGiB0c`c F`xd&bcf9}r literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_official_to_data_blank_don_vi.cpython-313.pyc b/be0/tests/__pycache__/test_official_to_data_blank_don_vi.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..42a372d6a1098193db6b77ebf528772500e09a59 GIT binary patch literal 2884 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 SpPcyGi9ecxZmDddbp0Q;KAvR& literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_registration_stack_alignment.cpython-313.pyc b/be0/tests/__pycache__/test_registration_stack_alignment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..763bafceb531f71a4e0ab78e45b4da5351e4f5d3 GIT binary patch literal 18933 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 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_repair_split_submission.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_repair_split_submission.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e2cd18e4c9153504b7d5a5c84d269b8edb0660c5 GIT binary patch literal 3278 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 B8ASj9 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_security_routes.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_security_routes.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef87e53c56e0633575aa0669fa1873551bee584f GIT binary patch literal 11837 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 Q{lkBGt&A;EPW=wu84bQ?;qhevmIVc#8^OIp?+{ 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 C5)z^S literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_staff_profile_domain.cpython-313-pytest-8.3.4.pyc b/be0/tests/__pycache__/test_staff_profile_domain.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..21646548d018f8249edf4849d0bf000966425ebc GIT binary patch literal 5069 zcmcgwTWk~A89sAyJa)!ONC?8dd;>EXj`FzRF!mhB^Id|NQ6jfB)rpEf$LsEX)6~p0yM5PySIOA-@*Z{sN6#Btt042pMr)NQ+d|uq};C zROWRk-R44HI!u+?NQ6fCNR&pQmdD%EDpk`l8sp=k@s4yS?M!#kuJjhVCEZQC(>=5& zy_IfF_tIXC#K~TgY3n1I@N8uAVDq;8_K4)ybo*>Pqa3?Lh|x_5@D9JqyO)gAIyG`x zx-2Cli^J27@d|ofnV&1Y%Jy$FkmZKeTidxR_j5)(Klc}#!Mp9ty zhT{}H!?TKxJCPKbJnfp4bs^25ln;C%$uQetluGu3PR*+(wekyko)!x_Z(C-L?J``L z;_2C9p=6t$slz^Ww>(#{To+`scx~K-;o^d6>Q+v7=Z%uddTS$PID>9p&)Q|zx@xkW z1;c}d3|se4lebJe=jz#c!?_IW^*TjbFl=kl)SE=I9-o;Oukj5S<(%bZo9|HcKZLap zpmB@L5-I=`#Ed|tOf)0*kt;HQAexbQD+Dc>x7wf;;;k^WK$XC!Kr75!k&Hs4mm|q` zw!Qh*&heX{LcM&w71qlNO;FrHMsF(~P;ncZapb(AXKMlM+*XP5i)}C^{wu10nzquGaG(mks;A zz+~67^Dgc~W7#1Di96)kMq%{m(79r^TreHa9inEzx;|LU4;B^%OTbYwvmR)4(6;h$ z6AQLE_}=i)oOx)71D4a8jyEDURa#(u$fLhr*TZkP>eQR(^uo?vw7(mqs;Ezt#EO!r z^bI|bSNldP$`RfdUs2+fMC!}M)x@!f7al9;)_{Mb>e`@#tL_VE+#)k|#Wj?qHTTxl zz*SaPDa!TBj2yHAg^6T?1Tp9bf5U&x>fA#Pk^?mXIoL~H)W~j_kE%W??Iw8v6&@Zt za~Aac9utkZq(l{%5NLeTuw2t+(GLx~Y@VmINNEoY`--M`X>1$6)FxA_S#N4nCA`9) zK(U8`+#pXCZFy@&`3}~JiZW1*ZFv&gwG!L)`^Cqx{-^5x<*AC=|573y15aZ8e~tBj z<02&gq>c&FBOyB08Qx${cpk2ke?tRw0@N>)HQ)!|ST}sABh1uIXj%Z@E@q@}J2xXA z4LRaWIFJnULz%Xtq9cJ}MuOf(J>y3w=m@mQwHymLF70b*^{uoVp7=)fPL~U%l$k51 zt~yM(&V;G^AtqWm7BV3W*eq58lfG7@Iep%6=UF6Qwr$-p3MLD44{YTYqnb88Uo^B! zng$UeTm~}-cg?!_II@E8H^IkW_aH3Dn>|CrDK~6=68%U^X&@7^cvJ+BQo}DG6=eG5Eu={;(!@7l6m5)` zOWncB0b05Ds-rdFBV?vAXUnT|Al%3kOc+awGzoK9fT46ef0V4!_100R#m&!LQ*WcB&;hWroDOixk4hbo1LF@R^lTMjMez@igKbU z^c;Ec@oM74W91}`!&0wza)03PLWqo$G;xHq;DFO_xU!i#v?kFpwv;B2Nz=k)U40F< zX>k(u(ozB-d~4~1yU4d%!;xn?gXJ$B4q(M0xFzUs)YH-=B+RWP6WTBj16fOfdD~jL z;4?VqCaa-pTFSKCSPekmHbK#H!GkK0cAK6BE>=Oh2-y`=g3LJ?^3CF%Ao=kJvOFcn zyvQrNtUch?4UhHIKjs$nz&UqO^9tr!MF%o;!-b4HTr!=Uf_JDrf7NRgcO5^m-eQdll2%`BL~lstRHaTb(%9Nrk}N7L@#1HOzRfWl)+#q zZ=^8+*axvUg#^LQB)2r&3if?G2>B80jql;(o8x`c$MpAMW%tKG{^vZWx^+94L{&Rb z-PZRa5<>j1t0eN>Wv`+f`U>(O_o2MfadJs&&4c>CJX}#nUd@9Js%Hf0&qDM}XZSwB z{}0-z>6NL&WPC%ydsxwVa;fVqs_TVft_*)j=uFjhd{OdTQfHmIp0}v$*_LBEMO~-p zlIcz)hmagYf`_K~XlK1TcZ;A@!}F*$SBCqBNt4KcZkcD3v;$j6&_}t5pI?x5Q4F1j z=q!f@*46gV!S%jq=+t_=BXr`W))PA7&&D~pZT!8?RnzGlqp#2Ec_+p zU)VGrOU9$O+$U2`_}V)~=Ya?O7jdV6JeLGP_!}Ah7uoxake`vCcK)5TKO@Iqii#k< zBtTvuS>JX+5RTlQf3j<6W!KO;!LR32B9Yal#Z@U$mDOKf{K>_i|LD0y`2UMwTwtYZtg$hzt(%qF{cC>^HZbSLdht5i)#Xe1q_(R7T)G!iF$B-7DD zGQoLiN~^8gl#NK<2i-Z}l~GR55@K`_0)5|Z>QpQCk&#+SBbTMiQZlqWJY!p~=9-S{ zX!#P=9M{O_^(9)$TLn|km5PRCYlrQUmNQ&q!EnrE>KmLRDX>n%wo9(zS|!_=Oo~jN zaZJi~pv>SbA9#}_gKVF%v{bmEQ}e1xt^5r=PfJCemn}2LdJPALxO%o!Tq>BZslz^W zr@T*7OS~LpQvZ*&s$-2GHv~-Pcz$oV|H(OH|&qVJ-*!%zrx5+%A0vKG( z2w>oFM(iP1(8S@4#7hAv$-LA7r2sDlp#-LcehQR=ycEhPG<-Rf>|}eJwe~*O{50C- zn_aM8R`6Em5@82s7dCAu+$J+*s=2k+ajpgo0~^r7{TwG5l3n# z2hfnDG`Xy3nz3;7ondV`Fg6Z(QevuSi61$WCEKLnCIsNY&3fVEvQc;-FxfE+c?b8Q zu|AHsA(S-}Z3kRx=?3fJt*?lFe5tlZc&UE^r#%khCfQK5 z)m;_kZOjuDWw09A`83kI7U})N@{`EGhT6Y6T~P;KNF+M=G&1nF$iTM_BI?iTm>@kC z!eg=EHgCZ5aGU%a3SbkEUYe@m*8j%5L4md~SNDJ!0jOEbNZ)mAMm`m=#krtAGmH;p zI!=kU1b!9rYa98TcZEQOZ%uCHh~H!OP{XQkWaZ%Gx2pSIxww=vbLG@kn+eyMF#RCF zL@UPvCa`#cMV7!Lua#&{Uo@OW7Rr|k1>H7^CJS-`wQ|d0O`BLO8QQF-0ig%Wz{|k4 ztc$l}SDWg2HP%(_(yBfCt9ud~yW+o3|2F-* zA3yKx*b!clx1uE4yPADeSc^=oh^-(zxY}P)2e*Mxg#{k(R>uaU#{=Q9liN{_5p~0yQU!OuAX-N%aA{I}Hz$i8R0#ad4FTKHRQp;@OYcPsF#?x$2K{!Yjg-LL%Dho1ZBp6uA}qBvy%!JT%thXDZ5Q)75k0;m7NV(I?7T z8i%D`O>%$a@Ir`8kTkJ{v|t12H>hl`4y`F9#+K9sGHFJbs++ITZAP3zyR;-92;X^j z!YT5N*0ANdn7{n&aUWJ3f;;{CMm{4=LA=|VGJ$R5@FHtTFm6vv6@2>VY_l4grX|hD zjnx439g`G27Z_B5sM&NapjZXvG6Y9V@dMvvz;lbY{NTrH$nuC9<07~0uui|XZn&(w z{;_aF_sMw&HLsXijKU$nHXI0;gG;8Jv+T>s2t5n?pyyCZUnltL;o)jryQkh&S7-0f|7m_bKD?pz-w%Hp{!FdzKM4FE z4X=pLq9nHWR^gMvos;)Q?~blV55twA^si1;l%sX0ywmkG@$OpU-A53poUccYclz$7 z?xreAa$CQVwZzCn=ZSKrjr;a{w*&0jOaJc}B@@gjr(|22t4Ar#vB$QEhGaqz(a3OH zyn-g?>LlU243B6Uv-US!;*BDj@FdkhMf4zwJ`{&QEO(qAKYHxgJFKH8ak z#NgLUXH(3{x{a)nGmBQ%yKvxfnKLPdpS8<~Ucz#a);*#rg2Ai25ytpnKZ>;}6bN=E zIZMN>U_Zp;81Dvq?Q`}_d%U-O3;z&Sc76)te~xpiyY_-hRJFs^Jw4Au0mT27NvQ4|B>l7)O?niM9#c33HXo_!c zwp-`42u3wrms$&D&^HX4!VXBwJes6YETKS-au7eiAX}msxB$1a92neEI|E0ydcuKo zTk&XM^o7P5?8D#DJW689G^}+V{(kXJH-meS zai1-RXUeFj>T`4f`T*Gw2UYh>5(MGzkRi-r4 z$Y_pXTgqX}RzTHK8r37whkU6BX4RTy*^X;L;}Lw80)>Se@`h#PHB*HhshTQU8JJReD52cIr9M#R&;%kF zVlN~x5fgGkXhN4Hh`O8*UCjrYw^E2%QuqXgb#+2S}8d(U( zjH$OD>x!lHj+o0CSMG{pv@pYtavOCAj8_e3Qr+@h)u)V%Zl!e)pdQjJ~7OG!6*s?HqTUrhcmV9D;)vUBGqhQT_xp7%;oZmN3R^`?b zOnU~_*I;B0y%g4f_KUH;$XVomoSsRcVJkIzJU(syH<>`H!F&f1IhJrzTsjI$8&bf* zBx*os;ij@66O#BO^iQEVpMuVZ5UK`?96l~)aYQKc?c6SiSApPvji?D$lwsPBWW;Mg4(LU1#g3|uz)2XrjnW$Ma#6{TSaoN$ZN08l|VBN3ddL#Gq z1c=>PMp9faLy(^8qtMUnQ0H*K2YwP0p8b)>}Qv$<LQU6zE>g_yc{niS0Fyb{6sI<#9{T5HeCD8%xT-o8YHi0#-GEx0r0SbcW+ek z?mc`MM){|~arm3IgO3nkmyd$WN3j?L!w>KLXe((DZY!O-ENPi zEea+I*zdx|rX6hDu`@gN8t(FK=s6JETwH7Knw4fR+^D^GAhIkUdEN!LNzKvUyH~=a zD~+S8^2bk8lQRxd3YR_srIMQb?TQJTcaeqR<>IOL#FxZlJa?b?yt&@1=iXytoMDaO z6g*md0^yG<3%E6cm5T^u4{wY5bh;90*5XDKdi4S?MAWoKh@n$>S=@|g$Dbe7XHJa3 zZj{p$B3s+B)V4&InpSH2X2mr*~f zUQwl0&2fm4D#H1})J+3^tGKrwk-5eN4Cw_Eo`T7deeExd?S8Z?Ly1Nk^fwa|}4)l&)q{QMy$oZdZY2ez--= zAwG{rv&J-cl14WR3U`|0Rfg`1!Q^o;Jq`6~IXb)|V-i2{6TxG0Um2i&au#g(lS|Kl zSQjzIf1>yQL9PEH^f~&h?vW&7;lC<`2l0l#8F$T3-+!}X`OS_E1da86B!yV^h2dK*(-~a#s literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_submission_readiness.cpython-313.pyc b/be0/tests/__pycache__/test_submission_readiness.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b4ba39695a523944b422c52ecf7769b89baa41f GIT binary patch literal 2946 zcmbVOO-vg{6rR~#+Y6YOgrp%Dk*$f^I7%HzKoJS0BK))g#ibZaQG+yE?6uj%-X$|@ z3OQAg+LB%>RYIat)uuPN(e&0!k#cHIu_y{1_D~hGT$Ya|Fm|ZE}@>gjF1jeAlvl3Yy@?A zlN!p(SvgvBqdRGtj^-H5VOqwf8Y@iZVS*ZZ+O!PDv>82Trgg`(E$z5vYryK0Iy0iN z$3j&U^E%zKY{!*g@u<|?SAsCDq)ET^9&~va;VMZ>)72!ax2)8m}n5Jnw;)eW`pnC=( zHa*8rx~sdQ@P+A2k?o_2F=M6;D`gO{Xz)cIjJ=7P2C$Ev0rDk!7>cZgj?F6%L+u;E zhK(bwOM|OQF*sQA$qlvh%BG5H>K~|0MYU=1;38dDBPH1Of>7Onl?C)#p@Lw&I#gE# z2;5JgD=9T@XI7U7hswVaNLmNtyKp2z0XM~^lfbkg4Gc`8M)V0hCl&xXiO<9Q3|jDM z=u!}&I*`cXvvLMUrGhv;d~kYRnZJB%-~GeUqI%+G4_pPBqknd;g-6$#M%UHPo`pTj?dZ?mQT+gI1s4%cxm97Ur7IpY+l#g#bZ4730$A(oyu2;sGaH+aHf&-^YaH0|%!mUCaKtkeQXcbVcp%n*^P&g$@5#q+1wH>D|NOWzz-S=kR zn{VEH@6C2^Zybr|*5B6GVT67Sl(qm}Jks_zAa5caQKUCwxv(r~cgBVtl+ zskHBMzphSQLde*I5X?RJ$h1HiMU!o#h8E>TIU8$yd7e7gi0N65tvOZgif5Er-f-QT zWg5bmr-TuMnpbC*tO~JBGHcnDE1F|_j#jgnr%`8_X-8~FJN;@lACH%OdrC7aTA38I zLx(hX#k&fTb9K$PEzcw1AOy<`iJ4!nTa3Xn47)oxaA!&UCak69mf{-%byng%hHX1u zfQ!v#LtL#G9`UR?;i`}g$0jf2Hcwn*hobQw_z&Ml-% zRMx|KXb4?X;KXDaUR1IXp4YnwOaGFHZt^aQU2P;Gs42CU2xJN*9FVkq6s$lIK@U`v zY)L~4ZTcXz5b1c7)XrD;-90p2)|G{x&IrJbz%L3hrG-StS`rW*dvx`9RF553?2sPU zd-R0fo0s*Zp3?jD{(Pt)>3i}aTb`Fj(WL{E?F8A%JSz1+76{aNrIdcss;4L0yVm*!t~ASI0i%Ft73+^E zLK_0qhR6-LN4$n*SDmcFLso^yOoI_@Iu*hr3@GI@p4f55hS%r=Emt8%rA$cGG^(UA zI8m5*F;^Ie|Hbi%sX_t%`9h)a23N%CXJy(4EAif)FsFf?T(Nw*1!dxUIZBo+vJA`~w4hupVEPi7t{G4w)pkAdy&dP>1qmJkZ@ZXE#B^wdE25U^ z5MZJik%Bni�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 literal 0 HcmV?d00001 diff --git a/be0/tests/__pycache__/test_user_notifications_merit.cpython-313.pyc b/be0/tests/__pycache__/test_user_notifications_merit.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4f3959ee0dcec7df8b067686be8f52a09aba819 GIT binary patch literal 2524 zcmdT`&1)M+6rb5$No(0kOdL11+myvFjs>x5>beCtO$ts5jj;n>8`9J;U9DEu)~j80 zW>$%PDuuTB2YgQtJs1kT=Tyk0J%(Un266~F<)&bV(p%r#l~UKK9QPC|#M7IZ_xXEo z-e|8-$P@hB`P2X1B;-l5Xp>C(OZS?Z_DXeP5t-8ly z9XMq#uqwbe9qC7*Rpn9Ls&J<&t*iF6MQb9Aq+j*j^t2pRHj1Tuekl%TEw^Hoy(w#a z+-hvd8t6{etr(o3kJM2KSr!YAvx8;?OPxOvx^3Fp2>Ht325@@9Wq<<=VJ{0@CgE~+mYZw2Vvl-r0h69{q_-CFjZY!f z%b4jhg7UN%?7Q@UYpfe!PWP4)&q>Y5RT@&LnQj|)#?IQgk~T%BXsE4_rFNYUSRgBO zl&okoZL799)6NE}LW(_$9GBZvolEdw0CEx-ORB=K6f7wPYd9N0E){O9?*l}1k_tz`8sw2&@_Y>RMV4O zI}3r0Y+05XPrC8lBb%hu*iaLRM4BCIXUEd)m@k;bP+8dzYwGe51x}ms7*_fqJb~Tz z4S8gK*~*f_z;6H9-Kp8#^K;KKx^=3hX9iES^xQ!HcCOV=dI}HCq5JS1{^Zv)4~9Sc z?c*Ku^F7diE$zf{ba0$T9P7w28&&W_=!`JAUrxXau=gAfuE&q>{v<8&`0g(*??>?g zAe{p7H(;U067>IJ0Kl(1ekjX zc-rOJ^l~Td9UG9}eC^(k-JpGGsB|WZkbV%b^_s)VvAgcc|29;*S()w*_xs0!`-2^G zDQWO+vRKqqmU}Yhp)wYu(1WMVWGr;*9%CxcSUsx50rGu};T&$CoMf!(b0Gsi^umZS zeg<3bERr!K=YXg|#sogZE_0;hemRB~1koZ2;QdO50>`&5Avj~i2$1i|zSf($LH9>` zGhejnAHc&yov5J(Mu~%iv7)JRj8!5x4Y(RZG7vDwr$L#We-X-DhNpSXfd?gVcpgs; zv~VaMWnM^gzQ{iW8GLWWB_J)GQu<8Osqs8d=-3l-az8swZF*2#?ZS80S~@XClF0u8^z51J literal 0 HcmV?d00001 diff --git a/be0/tests/auth_register_staff_fixture.py b/be0/tests/auth_register_staff_fixture.py new file mode 100644 index 0000000..406b022 --- /dev/null +++ b/be0/tests/auth_register_staff_fixture.py @@ -0,0 +1,16 @@ +"""Minimal valid staff fields for POST /api/v1/auth/register in integration tests.""" + +from __future__ import annotations + +import uuid + + +def register_staff_fields() -> dict[str, str]: + """Unique employee_id per call (DB partial unique index on user_staff_profiles.employee_id).""" + suffix = uuid.uuid4().hex[:8].upper() + return { + "employeeId": f"CB-{suffix}", + "academicTitleCode": "master", + "unitNameFreetext": "Khoa kiểm thử", + "jobTitle": "Cán bộ", + } diff --git a/be0/tests/fixtures/__pycache__/minimal_submit_bundle.cpython-313.pyc b/be0/tests/fixtures/__pycache__/minimal_submit_bundle.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3d12d6d1262a3ce5b1663565aa1e53e91d670ca7 GIT binary patch literal 3559 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 Og|n9cn*m7# Dict[str, Any]: + return { + "introduction": "Mở đầu đủ.", + "initiativeName": initiative_name, + "representativeAuthor": "Nguyễn Văn A", + "representativePhone": "0900000000", + "representativeEmail": "a@ump.edu.vn", + "applicationField": "Y tế", + "currentStatus": "Hiện trạng.", + "purpose": "Mục đích.", + "solutionContent": "Nội dung giải pháp.", + "implementationSteps": "Các bước.", + "firstAppliedUnit": "Đơn vị.", + "achievedResult": "Kết quả.", + "conditions": "Điều kiện.", + "trialUnits": [], + "novelty": "Tính mới.", + "effectiveness": { + "economic": "Kinh tế.", + "social": "Xã hội.", + "teaching": "Giảng dạy.", + "productivity": "", + "quality": "", + "environment": "", + "safety": "An toàn.", + }, + "confidentialInfo": "", + "submissionDate": "05/05/2026", + "authorName": "Nguyễn Văn A", + "honestyConfirmed": True, + } + + +def minimal_application_tab_technical(*, initiative_name: str = "Test initiative") -> Dict[str, Any]: + return { + "unitName": "Đơn vị A", + "authors": [ + { + "id": 1, + "name": "Nguyễn Văn A", + "dob": "01/01/1980", + "workplace": "UMP", + "title": "GV", + "qualification": "TS", + "contributionPercent": 100, + } + ], + "initiativeName": initiative_name, + "investorName": "Chủ đầu tư", + "applicationField": "Y tế", + "firstApplyDate": "15/04/2025", + "initiativeClassification": "technical", + "textbookEvidenceKind": "", + "researchEvidenceKind": "", + "researchEvidenceFile": None, + "textbookEvidenceFile": None, + "technicalEvidenceFile": None, + "internationalJournalDeclaration": "", + "banCamKet": {}, + "referenceMaterialHonesty": {}, + "researchDomesticHonesty": {}, + "contentSummary": "Tóm tắt nội dung.", + "confidentialInfo": "", + "conditions": "Điều kiện đơn.", + "authorEvaluation": "Đánh giá tác giả.", + "trialEvaluation": "Đánh giá thử.", + "supportStaff": [], + "honestyConfirmed": True, + "submissionDay": 5, + "submissionMonth": 5, + "submissionYear": "2026", + } + + +def minimal_contribution_tab(*, initiative_name: str = "Test initiative") -> Dict[str, Any]: + return { + "initiativeName": initiative_name, + "mainAuthor": "Nguyễn Văn A", + "position": "UMP", + "representativePercent": 100, + "submissionDate": "2026-05-05T00:00:00.000Z", + "participants": [], + "digitalSignatureConfirmed": True, + } + + +def minimal_tabs_bundle(*, initiative_name: str = "Test initiative") -> Dict[str, Dict[str, Any]]: + return { + "report": minimal_report_tab(initiative_name=initiative_name), + "application": minimal_application_tab_technical(initiative_name=initiative_name), + "contribution": minimal_contribution_tab(initiative_name=initiative_name), + } diff --git a/be0/tests/security_token_fixture.py b/be0/tests/security_token_fixture.py new file mode 100644 index 0000000..2490a87 --- /dev/null +++ b/be0/tests/security_token_fixture.py @@ -0,0 +1,32 @@ +"""Shared JWT bearer header for route security tests (uses auth_jwt.jwt_secret()).""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from typing import Sequence + +import jwt + +from src.auth_jwt import jwt_secret + + +def mint_bearer_token( + *, + roles: Sequence[str] = ("viewer",), + sub: uuid.UUID | None = None, + email: str = "security-test@ump.edu.vn", + credential_version: int = 0, +) -> str: + user_id = sub or uuid.uuid4() + now = datetime.now(timezone.utc) + payload = { + "sub": str(user_id), + "email": email, + "roles": list(roles), + "cv": credential_version, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(hours=1)).timestamp()), + } + token = jwt.encode(payload, jwt_secret(), algorithm="HS256") + return f"Bearer {token}" diff --git a/be0/tests/test_admin_audit_routes.py b/be0/tests/test_admin_audit_routes.py new file mode 100644 index 0000000..f282a55 --- /dev/null +++ b/be0/tests/test_admin_audit_routes.py @@ -0,0 +1,27 @@ +"""Sanity checks for admin audit router registration (no DB required).""" + +from __future__ import annotations + +import unittest + + +class AdminAuditRouterSmokeTests(unittest.TestCase): + def test_audit_router_registers_list_and_detail(self) -> None: + from src.admin_audit_routes import router + + paths = [getattr(r, "path", "") for r in router.routes] + self.assertIn("/admin/audit", paths) + self.assertTrue( + any(isinstance(p, str) and p.startswith("/admin/audit/") for p in paths), + msg=f"detail route missing under router, paths={paths}", + ) + + def test_parse_sort_behavior(self) -> None: + from src.admin_audit_routes import _parse_sort + + self.assertFalse(_parse_sort("occurred_at:desc")) + self.assertTrue(_parse_sort("occurred_at:asc")) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_application_backup.py b/be0/tests/test_application_backup.py new file mode 100644 index 0000000..27c0aa1 --- /dev/null +++ b/be0/tests/test_application_backup.py @@ -0,0 +1,99 @@ +"""Unit tests for backup ZIP helpers (no database).""" + +import unittest + +from src.initiative_db.backup_naming import backup_zip_attachment_filename, official_form_pdf_backup_zip_path +from src.initiative_db.application_storage import ( + EVIDENCE_ROLE_RESEARCH, + STORAGE_FILESYSTEM, + STORAGE_MINIO_ATTACHMENTS, + STORAGE_MINIO_EXPORTS, + effective_storage_kind, +) + + +class OfficialFormPdfZipPathTests(unittest.TestCase): + def test_trang_bia_fields(self) -> None: + obm = { + "TRANG BÌA": { + "Tên sáng kiến (Tiếng Việt)": " Khảo sát thảo dược ", + "Tác giả/nhóm tác giả sáng kiến": "Lê Thị A", + "Thông tin liên hệ (Điện thoại, Email)": "0909, a.b@ump.edu.vn", + } + } + p = official_form_pdf_backup_zip_path(obm) + self.assertEqual(p, "submitted/Khảo_sát_thảo_dược_Lê_Thị_A_a.b@ump.edu.vn.pdf") + + def test_no_trang_bia_returns_none(self) -> None: + self.assertIsNone(official_form_pdf_backup_zip_path({})) + self.assertIsNone(official_form_pdf_backup_zip_path({"OTHER": {}})) + + def test_empty_cover_returns_none(self) -> None: + self.assertIsNone( + official_form_pdf_backup_zip_path({"TRANG BÌA": {"Tên sáng kiến (Tiếng Việt)": " "}}) + ) + + +class BackupZipFilenameTests(unittest.TestCase): + def test_email_local_part_and_sub_id(self) -> None: + self.assertEqual( + backup_zip_attachment_filename( + owner_email=" nguyen.van.a@ump.edu.vn ", + owner_full_name="Nguyễn Văn A", + public_application_id="sub-deadbeef", + ), + "nguyen.van.a_sub-deadbeef.zip", + ) + + def test_fallback_name_when_no_email(self) -> None: + fn = backup_zip_attachment_filename( + owner_email=None, + owner_full_name=" Lê Thị B ", + public_application_id="sub-001", + ) + self.assertTrue(fn.endswith("_sub-001.zip")) + self.assertIn("Lê", fn) + + def test_applicant_fallback(self) -> None: + self.assertEqual( + backup_zip_attachment_filename( + owner_email="", + owner_full_name="", + public_application_id="sub-x", + ), + "applicant_sub-x.zip", + ) + + +class EffectiveStorageKindTests(unittest.TestCase): + def test_full_pdf_minio_key(self) -> None: + self.assertEqual( + effective_storage_kind("full_pdf", "initiatives/abcd/2025/01/x-file.pdf", None), + STORAGE_MINIO_EXPORTS, + ) + + def test_full_pdf_filesystem(self) -> None: + self.assertEqual( + effective_storage_kind("full_pdf", "/submitted-initiatives/sub-abc.pdf", None), + STORAGE_FILESYSTEM, + ) + + def test_evidence_attachments(self) -> None: + self.assertEqual( + effective_storage_kind( + EVIDENCE_ROLE_RESEARCH, + "initiatives/abcd/2025/01/x-file.pdf", + None, + ), + STORAGE_MINIO_ATTACHMENTS, + ) + + def test_respects_declared(self) -> None: + self.assertEqual( + effective_storage_kind("full_pdf", "/any", STORAGE_MINIO_EXPORTS), + STORAGE_MINIO_EXPORTS, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_application_drafts_get.py b/be0/tests/test_application_drafts_get.py new file mode 100644 index 0000000..4c5475c --- /dev/null +++ b/be0/tests/test_application_drafts_get.py @@ -0,0 +1,33 @@ +""" +QA: GET draft bundle returns 200 with empty tabs when nothing is stored yet. + +Run: cd be0 && python -m unittest tests.test_application_drafts_get -v +""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +_CASE = "CASE-1776577845956" + + +class ApplicationDraftsGetTests(unittest.TestCase): + @patch("src.initiative_db.engine.is_postgres_enabled", return_value=False) + @patch("main._load_application_draft_yaml", return_value=None) + def test_unknown_case_returns_200_empty_shape(self, _mock_yaml, _mock_pg) -> None: + from fastapi.testclient import TestClient + + from main import app + + client = TestClient(app) + r = client.get(f"/api/v1/application-drafts/{_CASE}") + self.assertEqual(r.status_code, 200, r.text) + body = r.json() + self.assertEqual(body.get("caseId"), _CASE) + self.assertEqual(body.get("tabs"), {}) + self.assertIn("updatedAt", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_applications_db_integration.py b/be0/tests/test_applications_db_integration.py new file mode 100644 index 0000000..c1655b8 --- /dev/null +++ b/be0/tests/test_applications_db_integration.py @@ -0,0 +1,797 @@ +""" +PostgreSQL integration tests for submitted applications (update / delete). + +These tests exercise `src.initiative_db.submissions` against a real database. +They are skipped unless INITIATIVE_DATABASE_URL points at PostgreSQL (asyncpg). + +Example (host port from repo docker-compose.yml): + + export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives" + cd be0 && python -m unittest tests.test_applications_db_integration -v + +Prerequisites: + - Schema applied: `001_initiative_schema.sql` and `002_application_storage_extensions.sql` (see docker-compose postgres init mounts), or equivalent. + - Network reachable from the machine running tests. +""" + +from __future__ import annotations + +import os +import unittest +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select + +_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql") + +_HAS_MINIO = all( + os.getenv(k, "").strip() + for k in ( + "S3_ENDPOINT_URL", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_BUCKET_ATTACHMENTS", + "S3_BUCKET_EXPORTS", + "S3_BUCKET_QUARANTINE", + ) +) + + +@unittest.skipUnless( + _RUN_DB, + "Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests", +) +class ApplicationsDbIntegrationTests(unittest.IsolatedAsyncioTestCase): + """End-to-end persistence for applicant submission update + delete.""" + + async def asyncSetUp(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + await eng.init_engine() + + async def asyncTearDown(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + + async def test_update_then_delete_submission_round_trip(self) -> None: + from src.initiative_db.engine import get_session + from src.initiative_db.models import Draft, Initiative, User + from src.initiative_db.submissions import ( + delete_my_submitted_application, + get_application_by_id, + update_my_submitted_application, + ) + + owner_id = uuid.uuid4() + owner_email = f"dbtest-{owner_id.hex[:8]}@ump.edu.vn" + case_code = f"TESTCASE-{uuid.uuid4().hex[:10]}" + submission_id = f"sub-{uuid.uuid4().hex[:16]}" + + # --- seed owner + submitted initiative + draft payload --- + async with get_session() as session: + session.add( + User( + id=owner_id, + email=owner_email, + password_hash="-", + full_name="DB Test Applicant", + ) + ) + await session.flush() + ini = Initiative( + case_code=case_code, + owner_id=owner_id, + status="submitted", + submitted_at=datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + session.add(ini) + await session.flush() + payload = { + "caseId": case_code, + "updatedAt": "2026-01-01T12:00:00Z", + "tabs": {}, + "submissionRecord": { + "id": submission_id, + "submittedDate": "2026-01-01T12:00:00.000Z", + "name": "Original title", + "author": { + "id": case_code, + "name": "DB Test Applicant", + "email": owner_email, + }, + "status": "submitted", + "reviewStatus": "not_reviewed", + }, + } + session.add( + Draft( + draft_code=f"DRAFT-{case_code}", + initiative_id=ini.id, + payload=payload, + version=1, + ) + ) + + # --- update (same semantics as PUT /api/applications/{id}) --- + async with get_session() as session: + row = await update_my_submitted_application( + session, + owner_id, + owner_email, + submission_id, + "Renamed via DB test", + "2026-06-15", + ) + + self.assertEqual(row.get("name"), "Renamed via DB test") + self.assertIn("2026-06-15", str(row.get("submittedDate") or "")) + + async with get_session() as session: + loaded = await get_application_by_id(session, submission_id) + self.assertIsNotNone(loaded) + assert loaded is not None + self.assertEqual(loaded.get("name"), "Renamed via DB test") + + # --- delete (same semantics as DELETE /api/applications/{id}) --- + async with get_session() as session: + await delete_my_submitted_application(session, owner_id, owner_email, submission_id) + + async with get_session() as session: + gone = await get_application_by_id(session, submission_id) + ini_row = (await session.execute(select(Initiative).where(Initiative.case_code == case_code))).scalar_one_or_none() + + self.assertIsNone(gone) + self.assertIsNone(ini_row) + + # --- cleanup user (initiative already cascade-deleted) --- + async with get_session() as session: + u = await session.get(User, owner_id) + if u is not None: + await session.delete(u) + + async def test_get_application_by_id_matches_fallback_sub_id_when_record_has_no_id(self) -> None: + """List rows use sub-{initiative.id[:16]} when submissionRecord.id is absent; GET must accept the same id.""" + from src.initiative_db.engine import get_session + from src.initiative_db.models import Draft, Initiative, User + from src.initiative_db.submissions import get_application_by_id + + owner_id = uuid.uuid4() + owner_email = f"dbtest-fallback-{owner_id.hex[:8]}@ump.edu.vn" + case_code = f"TESTCASE-{uuid.uuid4().hex[:10]}" + + async with get_session() as session: + session.add( + User( + id=owner_id, + email=owner_email, + password_hash="-", + full_name="Fallback Id Test", + ) + ) + await session.flush() + ini = Initiative( + case_code=case_code, + owner_id=owner_id, + status="submitted", + submitted_at=datetime(2026, 3, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + session.add(ini) + await session.flush() + # Must match `_submission_display_id`: first 16 hex digits of UUID, not `str(uuid)[:16]`. + expected_list_id = f"sub-{ini.id.hex[:16]}" + payload = { + "caseId": case_code, + "updatedAt": "2026-03-01T12:00:00Z", + "tabs": {}, + "submissionRecord": { + "submittedDate": "2026-03-01T12:00:00.000Z", + "name": "No explicit submission id", + "author": { + "id": case_code, + "name": "Fallback Id Test", + "email": owner_email, + }, + "status": "submitted", + "reviewStatus": "not_reviewed", + }, + } + session.add( + Draft( + draft_code=f"DRAFT-{case_code}", + initiative_id=ini.id, + payload=payload, + version=1, + ) + ) + + async with get_session() as session: + loaded = await get_application_by_id(session, expected_list_id) + self.assertIsNotNone(loaded) + assert loaded is not None + self.assertEqual(loaded.get("id"), expected_list_id) + self.assertEqual(loaded.get("name"), "No explicit submission id") + + async with get_session() as session: + ini_row = (await session.execute(select(Initiative).where(Initiative.case_code == case_code))).scalar_one() + await session.delete(ini_row) + u = await session.get(User, owner_id) + if u is not None: + await session.delete(u) + + async def test_update_forbidden_for_non_owner_mismatched_email(self) -> None: + from src.initiative_db.engine import get_session + from src.initiative_db.models import Draft, Initiative, User + from src.initiative_db.submissions import update_my_submitted_application + + owner_id = uuid.uuid4() + owner_email = f"owner-{owner_id.hex[:8]}@ump.edu.vn" + intruder_id = uuid.uuid4() + intruder_email = f"other-{intruder_id.hex[:8]}@ump.edu.vn" + case_code = f"TESTCASE-{uuid.uuid4().hex[:10]}" + submission_id = f"sub-{uuid.uuid4().hex[:16]}" + + async with get_session() as session: + session.add( + User( + id=owner_id, + email=owner_email, + password_hash="-", + full_name="Owner", + ) + ) + session.add( + User( + id=intruder_id, + email=intruder_email, + password_hash="-", + full_name="Intruder", + ) + ) + await session.flush() + ini = Initiative( + case_code=case_code, + owner_id=owner_id, + status="submitted", + submitted_at=datetime(2026, 2, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + session.add(ini) + await session.flush() + session.add( + Draft( + draft_code=f"DRAFT-{case_code}", + initiative_id=ini.id, + payload={ + "caseId": case_code, + "submissionRecord": { + "id": submission_id, + "submittedDate": "2026-02-01T12:00:00.000Z", + "name": "Sealed", + "author": {"id": case_code, "name": "Owner", "email": owner_email}, + }, + }, + version=1, + ) + ) + + async with get_session() as session: + with self.assertRaises(PermissionError): + await update_my_submitted_application( + session, + intruder_id, + intruder_email, + submission_id, + "Should not apply", + "2026-02-02", + ) + + async with get_session() as session: + ini = (await session.execute(select(Initiative).where(Initiative.case_code == case_code))).scalar_one() + d = ( + await session.execute(select(Draft).where(Draft.initiative_id == ini.id).limit(1)) + ).scalar_one() + name = (d.payload or {}).get("submissionRecord", {}).get("name") + + self.assertEqual(name, "Sealed") + + async with get_session() as session: + ini = (await session.execute(select(Initiative).where(Initiative.case_code == case_code))).scalar_one_or_none() + if ini is not None: + await session.delete(ini) + u1 = await session.get(User, owner_id) + u2 = await session.get(User, intruder_id) + if u1 is not None: + await session.delete(u1) + if u2 is not None: + await session.delete(u2) + + async def test_save_submitted_application_rejects_when_readiness_not_met(self) -> None: + """Server readiness runs before official-form MinIO work (no S3_* required).""" + from src.initiative_db.engine import get_session + from src.initiative_db.models import Draft, Initiative, User + from src.initiative_db.submission_readiness import ApplicationSubmissionNotReadyError + from src.initiative_db.submissions import save_submitted_application + + owner_id = uuid.uuid4() + owner_email = f"ready-{owner_id.hex[:8]}@ump.edu.vn" + case_code = f"READYCASE-{uuid.uuid4().hex[:10]}" + + async with get_session() as session: + session.add( + User( + id=owner_id, + email=owner_email, + password_hash="-", + full_name="Readiness tester", + ) + ) + await session.flush() + ini = Initiative(case_code=case_code, owner_id=owner_id, status="draft") + session.add(ini) + await session.flush() + session.add( + Draft( + draft_code=f"DRAFT-{case_code}", + initiative_id=ini.id, + payload={ + "caseId": case_code, + "tabs": {}, + "updatedAt": datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z"), + }, + ) + ) + await session.flush() + with self.assertRaises(ApplicationSubmissionNotReadyError) as ctx: + await save_submitted_application( + session, + metadata={ + "caseId": case_code, + "initiativeName": "Incomplete", + "authorName": "X", + "authorEmail": owner_email, + }, + file_url="/submitted-initiatives/x.pdf", + owner_user_id=owner_id, + pdf_byte_size=50, + pdf_sha256="ab" * 32, + pdf_original_name="x.pdf", + ) + self.assertTrue(len(ctx.exception.missing) > 0) + + async with get_session() as session: + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == case_code)) + ).scalar_one_or_none() + if ini is not None: + await session.delete(ini) + u = await session.get(User, owner_id) + if u is not None: + await session.delete(u) + + @unittest.skipUnless( + _HAS_MINIO, + "Needs S3_* env — submit persists official forms via MinIO after snapshots.", + ) + async def test_save_submitted_application_writes_storage_tables(self) -> None: + """Requires migration 002 (submit snapshots, taxonomy, workflow, artifacts).""" + from src.initiative_db.engine import get_session + from src.initiative_db.models import ( + ApplicationArtifact, + ApplicationSubmitSnapshot, + ApplicationTaxonomy, + ApplicationWorkflow, + Draft, + Initiative, + User, + ) + from src.initiative_db.submissions import save_submitted_application + + from tests.fixtures.minimal_submit_bundle import minimal_tabs_bundle + + owner_id = uuid.uuid4() + owner_email = f"snaps-{owner_id.hex[:8]}@ump.edu.vn" + case_code = f"SNAPCASE-{uuid.uuid4().hex[:10]}" + sha = "ab" * 32 + + async with get_session() as session: + session.add( + User( + id=owner_id, + email=owner_email, + password_hash="-", + full_name="Snap tester", + ) + ) + await session.flush() + ini = Initiative(case_code=case_code, owner_id=owner_id, status="draft") + session.add(ini) + await session.flush() + tabs_payload = minimal_tabs_bundle(initiative_name="Storage ext test") + session.add( + Draft( + draft_code=f"DRAFT-{case_code}", + initiative_id=ini.id, + payload={ + "caseId": case_code, + "tabs": tabs_payload, + "updatedAt": datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z"), + }, + ) + ) + session.add( + ApplicationArtifact( + initiative_id=ini.id, + role="technical_evidence", + storage_uri="initiatives/test/evidence-key.pdf", + mime_type="application/pdf", + byte_size=900, + ) + ) + await session.flush() + await save_submitted_application( + session, + metadata={ + "caseId": case_code, + "initiativeName": "Storage ext test", + "authorName": "Snap", + "authorEmail": owner_email, + "subjectId": "math", + "groupId": "g1", + "topicType": "Hồ sơ PDF", + }, + file_url="/submitted-initiatives/t.pdf", + owner_user_id=owner_id, + pdf_byte_size=123, + pdf_sha256=sha, + pdf_original_name="t.pdf", + ) + + async with get_session() as session: + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == case_code)) + ).scalar_one() + snaps = ( + await session.execute( + select(ApplicationSubmitSnapshot).where( + ApplicationSubmitSnapshot.initiative_id == ini.id + ) + ) + ).scalars().all() + arts = ( + await session.execute( + select(ApplicationArtifact).where(ApplicationArtifact.initiative_id == ini.id) + ) + ).scalars().all() + tax = ( + await session.execute( + select(ApplicationTaxonomy).where(ApplicationTaxonomy.initiative_id == ini.id) + ) + ).scalar_one() + wf = ( + await session.execute( + select(ApplicationWorkflow).where(ApplicationWorkflow.initiative_id == ini.id) + ) + ).scalar_one() + + self.assertEqual(len(snaps), 1) + self.assertEqual(snaps[0].submission_record_id[:4], "sub-") + full_pdf_rows = [a for a in arts if a.role == "full_pdf"] + self.assertEqual(len(full_pdf_rows), 1) + self.assertEqual(full_pdf_rows[0].byte_size, 123) + self.assertEqual(tax.subject_id, "math") + self.assertEqual(wf.review_status, "not_reviewed") + + async with get_session() as session: + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == case_code)) + ).scalar_one_or_none() + if ini is not None: + await session.delete(ini) + u = await session.get(User, owner_id) + if u is not None: + await session.delete(u) + + async def test_draft_tab_save_records_tab_snapshot(self) -> None: + """Requires migration 002 (`draft_tab_snapshots`).""" + from src.initiative_db.drafts import save_application_draft_tab + from src.initiative_db.engine import get_session + from src.initiative_db.models import DraftTabSnapshot, Initiative, User + + owner_id = uuid.uuid4() + owner_email = f"tabsnap-{owner_id.hex[:8]}@ump.edu.vn" + case_code = f"TABCASE-{uuid.uuid4().hex[:10]}" + + async with get_session() as session: + session.add( + User( + id=owner_id, + email=owner_email, + password_hash="-", + full_name="Tab snap", + ) + ) + await session.flush() + await save_application_draft_tab( + session, case_code, "report", {"title": "Chapter 1"}, owner_id=owner_id + ) + await save_application_draft_tab( + session, case_code, "report", {"title": "Chapter 2"}, owner_id=owner_id + ) + + async with get_session() as session: + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == case_code)) + ).scalar_one() + rows = ( + await session.execute( + select(DraftTabSnapshot) + .where(DraftTabSnapshot.initiative_id == ini.id) + .where(DraftTabSnapshot.tab == "report") + .order_by(DraftTabSnapshot.tab_version) + ) + ).scalars().all() + + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0].tab_version, 1) + self.assertEqual(rows[0].payload.get("title"), "Chapter 1") + self.assertEqual(rows[1].tab_version, 2) + self.assertEqual(rows[1].payload.get("title"), "Chapter 2") + + async with get_session() as session: + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == case_code)) + ).scalar_one_or_none() + if ini is not None: + await session.delete(ini) + u = await session.get(User, owner_id) + if u is not None: + await session.delete(u) + + async def test_admin_result_upsert_appears_in_decided_list(self) -> None: + """PUT-style upsert updates initiative status; decided lifecycle list includes the row.""" + from src.initiative_db.application_admin_results import upsert_admin_result + from src.initiative_db.engine import get_session + from src.initiative_db.models import Draft, Initiative, User + from src.initiative_db.submissions import list_submitted_applications + + admin_id = uuid.uuid4() + owner_id = uuid.uuid4() + owner_email = f"admin-upsert-{owner_id.hex[:8]}@ump.edu.vn" + admin_email = f"admin-{admin_id.hex[:8]}@ump.edu.vn" + case_code = f"ADM-{uuid.uuid4().hex[:10]}" + submission_id = f"sub-{uuid.uuid4().hex[:16]}" + + async with get_session() as session: + session.add( + User( + id=owner_id, + email=owner_email, + password_hash="-", + full_name="Owner upsert", + ) + ) + session.add( + User( + id=admin_id, + email=admin_email, + password_hash="-", + full_name="Admin upsert", + ) + ) + await session.flush() + ini = Initiative( + case_code=case_code, + owner_id=owner_id, + status="submitted", + submitted_at=datetime(2026, 4, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + session.add(ini) + await session.flush() + session.add( + Draft( + draft_code=f"DRAFT-{case_code}", + initiative_id=ini.id, + payload={ + "caseId": case_code, + "updatedAt": "2026-04-01T12:00:00Z", + "tabs": {}, + "submissionRecord": { + "id": submission_id, + "submittedDate": "2026-04-01T12:00:00.000Z", + "name": "Upsert list test", + "author": { + "id": case_code, + "name": "Owner upsert", + "email": owner_email, + }, + "status": "submitted", + "reviewStatus": "not_reviewed", + }, + }, + version=1, + ) + ) + + async with get_session() as session: + await upsert_admin_result( + session, + submission_id, + admin_id, + decision="approved", + feedback="ok", + rationale=None, + ) + + async with get_session() as session: + out = await list_submitted_applications( + session=session, + page=1, + page_size=50, + name="", + author_name="", + reviewer_name="", + status="", + review_status="", + date_from="", + date_to="", + sort_by="submittedDate", + sort_order="desc", + lifecycle="decided", + ) + ids = {str(r.get("id")) for r in out.get("data") or []} + self.assertIn(submission_id, ids) + decided_row = next( + (r for r in (out.get("data") or []) if str(r.get("id")) == submission_id), + None, + ) + self.assertIsNotNone(decided_row) + assert decided_row is not None + self.assertEqual(decided_row.get("nhan_xet"), "ok", "Admin feedback must surface as nhan_xet for «Nhận xét» in admin list") + reviewer = decided_row.get("reviewer") or {} + self.assertEqual( + reviewer.get("name"), + "Admin upsert", + "Người đánh giá should show adjudicating admin users.full_name (updated_by)", + ) + self.assertEqual(str(reviewer.get("id")), str(admin_id)) + + async with get_session() as session: + ini_row = ( + await session.execute(select(Initiative).where(Initiative.case_code == case_code)) + ).scalar_one_or_none() + if ini_row is not None: + await session.delete(ini_row) + for uid in (owner_id, admin_id): + u = await session.get(User, uid) + if u is not None: + await session.delete(u) + + async def test_notification_inbox_after_admin_upsert(self) -> None: + """Requires migration 006 (`user_notifications`). Applicant receives inbox row after admin upsert + best_effort.""" + from src.initiative_db.application_admin_results import upsert_admin_result + from src.initiative_db.engine import get_session + from src.initiative_db.models import Draft, Initiative, User + from src.initiative_db.user_notifications import ( + best_effort_notify_applicant_after_admin_decision, + count_unread_notifications, + list_notifications_for_user, + mark_notification_read, + ) + + admin_id = uuid.uuid4() + owner_id = uuid.uuid4() + owner_email = f"notif-{owner_id.hex[:8]}@ump.edu.vn" + admin_email = f"notif-adm-{admin_id.hex[:8]}@ump.edu.vn" + case_code = f"NTF-{uuid.uuid4().hex[:10]}" + submission_id = f"sub-{uuid.uuid4().hex[:16]}" + + async with get_session() as session: + session.add( + User( + id=owner_id, + email=owner_email, + password_hash="-", + full_name="Notif owner", + ) + ) + session.add( + User( + id=admin_id, + email=admin_email, + password_hash="-", + full_name="Notif admin", + ) + ) + await session.flush() + ini = Initiative( + case_code=case_code, + owner_id=owner_id, + status="submitted", + submitted_at=datetime(2026, 5, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + session.add(ini) + await session.flush() + session.add( + Draft( + draft_code=f"DRAFT-{case_code}", + initiative_id=ini.id, + payload={ + "caseId": case_code, + "tabs": { + "application": { + "initiativeClassification": "research", + "researchEvidenceKind": "international", + } + }, + "submissionRecord": { + "id": submission_id, + "submittedDate": "2026-05-01T12:00:00.000Z", + "name": "Notif seed", + "author": { + "id": case_code, + "name": "Notif owner", + "email": owner_email, + }, + "status": "submitted", + "reviewStatus": "not_reviewed", + }, + }, + version=1, + ) + ) + + result: dict + async with get_session() as session: + result = await upsert_admin_result( + session, + submission_id, + admin_id, + decision="approved", + feedback="Kết quả thử nghiệm thông báo.", + rationale=None, + ) + + await best_effort_notify_applicant_after_admin_decision(result) + + async with get_session() as session: + unread_before = await count_unread_notifications(session, owner_id) + inbox = await list_notifications_for_user(session, owner_id, page=1, page_size=10) + + self.assertEqual(unread_before, 1) + self.assertEqual(inbox["pagination"]["totalItems"], 1) + row = inbox["data"][0] + self.assertEqual(row["applicationId"], submission_id) + self.assertEqual(row["decision"], "approved") + self.assertEqual(row["meritCategoryLabel"], "Xuất sắc") + self.assertIn("Kết quả thử nghiệm", row["feedback"]) + self.assertIsNone(row["readAt"]) + + nid = uuid.UUID(row["id"]) + async with get_session() as session: + ok = await mark_notification_read(session, owner_id, nid) + self.assertTrue(ok) + unread_after = await count_unread_notifications(session, owner_id) + self.assertEqual(unread_after, 0) + + async with get_session() as session: + ini_row = ( + await session.execute(select(Initiative).where(Initiative.case_code == case_code)) + ).scalar_one_or_none() + if ini_row is not None: + await session.delete(ini_row) + for uid in (owner_id, admin_id): + u = await session.get(User, uid) + if u is not None: + await session.delete(u) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_auth_password_reset_integration.py b/be0/tests/test_auth_password_reset_integration.py new file mode 100644 index 0000000..1895656 --- /dev/null +++ b/be0/tests/test_auth_password_reset_integration.py @@ -0,0 +1,217 @@ +""" +Password reset + credential_version JWT integration. + +Requires Postgres, migrations through 013 (email_verified) and **014** (registration_otp_codes). + + export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives" + docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/012_password_reset.sql + docker exec -i initiative-postgres psql -U initiative -d initiatives < be0/migrations/013_email_verification.sql + cd be0 && python -m unittest tests.test_auth_password_reset_integration -v +""" + +from __future__ import annotations + +import asyncio +import os +import unittest +import uuid +from unittest.mock import patch + +from sqlalchemy import delete + +from tests.auth_register_staff_fixture import register_staff_fields + +_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql") + +_TEST_PASSWORD = "Testpass1!" +_NEW_PASSWORD = "Newpass1!" + + +@unittest.skipUnless( + _RUN_DB, + "Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests", +) +class AuthPasswordResetIntegrationTests(unittest.TestCase): + def _delete_user_email(self, email: str) -> None: + """Cleanup after TestClient — use a fresh engine (TestClient tears down the app engine).""" + + async def go() -> None: + from src.initiative_db.engine import dispose_engine, get_session, init_engine, is_postgres_enabled + + if not is_postgres_enabled(): + return + await init_engine() + try: + from sqlalchemy import delete + + from src.initiative_db.models import User + + async with get_session() as session: + await session.execute(delete(User).where(User.email == email)) + finally: + await dispose_engine() + + asyncio.run(go()) + + def _register(self, email: str) -> object: + from fastapi.testclient import TestClient + + from main import app + + captured: list[str] = [] + + async def grab(_to: str, raw: str) -> None: + captured.append(raw) + + with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}): + with patch("src.auth_api.deliver_registration_otp_email", side_effect=grab): + with TestClient(app) as client: + r = client.post( + "/api/v1/auth/register", + json={ + "fullName": "Reset Test", + "email": email, + "password": _TEST_PASSWORD, + "passwordConfirm": _TEST_PASSWORD, + **register_staff_fields(), + }, + ) + if r.status_code == 200 and captured: + client.post( + "/api/v1/auth/verify-otp", + json={"email": email, "otp": captured[0]}, + ) + return r + + def test_forgot_password_unknown_email_same_message(self) -> None: + from fastapi.testclient import TestClient + + from main import app + + with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}): + with TestClient(app) as client: + r = client.post( + "/api/v1/auth/forgot-password", + json={"email": f"nope-{uuid.uuid4().hex[:8]}@ump.edu.vn"}, + ) + self.assertEqual(r.status_code, 200, r.text) + msg = r.json().get("message", "") + self.assertIn("Nếu email", msg) + + def test_forgot_password_invalid_domain_400(self) -> None: + from fastapi.testclient import TestClient + + from main import app + + with TestClient(app) as client: + r = client.post( + "/api/v1/auth/forgot-password", + json={"email": "x@gmail.com"}, + ) + self.assertEqual(r.status_code, 400, r.text) + + def test_reset_flow_login_and_stale_jwt(self) -> None: + email = f"reset-{uuid.uuid4().hex[:12]}@ump.edu.vn" + try: + reg = self._register(email) + self.assertEqual(reg.status_code, 200, reg.text) + from fastapi.testclient import TestClient + + from main import app + + with TestClient(app) as client_pre: + lg0 = client_pre.post( + "/api/v1/auth/login", + json={"email": email, "password": _TEST_PASSWORD}, + ) + self.assertEqual(lg0.status_code, 200, lg0.text) + access_before = lg0.json()["accessToken"] + + captured: list[str] = [] + + async def grab(_to: str, raw: str) -> None: + captured.append(raw) + + with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}): + with patch("src.auth_api.deliver_password_reset_email", side_effect=grab): + with TestClient(app) as client: + fr = client.post( + "/api/v1/auth/forgot-password", + json={"email": email}, + ) + self.assertEqual(fr.status_code, 200, fr.text) + + rr = client.post( + "/api/v1/auth/reset-password", + json={ + "token": captured[0], + "newPassword": _NEW_PASSWORD, + "newPasswordConfirm": _NEW_PASSWORD, + }, + ) + self.assertEqual(rr.status_code, 200, rr.text) + + bad = client.post( + "/api/v1/auth/login", + json={"email": email, "password": _TEST_PASSWORD}, + ) + self.assertEqual(bad.status_code, 401, bad.text) + + ok = client.post( + "/api/v1/auth/login", + json={"email": email, "password": _NEW_PASSWORD}, + ) + self.assertEqual(ok.status_code, 200, ok.text) + + me_old = client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {access_before}"}, + ) + self.assertEqual(me_old.status_code, 401, me_old.text) + finally: + self._delete_user_email(email) + + def test_reset_token_single_use(self) -> None: + email = f"reset2-{uuid.uuid4().hex[:12]}@ump.edu.vn" + try: + reg = self._register(email) + self.assertEqual(reg.status_code, 200, reg.text) + + captured: list[str] = [] + + async def grab(_to: str, raw: str) -> None: + captured.append(raw) + + with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}): + with patch("src.auth_api.deliver_password_reset_email", side_effect=grab): + from fastapi.testclient import TestClient + + from main import app + + with TestClient(app) as client: + client.post("/api/v1/auth/forgot-password", json={"email": email}) + tok = captured[0] + r1 = client.post( + "/api/v1/auth/reset-password", + json={ + "token": tok, + "newPassword": _NEW_PASSWORD, + "newPasswordConfirm": _NEW_PASSWORD, + }, + ) + self.assertEqual(r1.status_code, 200, r1.text) + r2 = client.post( + "/api/v1/auth/reset-password", + json={ + "token": tok, + "newPassword": _NEW_PASSWORD, + "newPasswordConfirm": _NEW_PASSWORD, + }, + ) + self.assertEqual(r2.status_code, 400, r2.text) + finally: + self._delete_user_email(email) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_auth_policy_integration.py b/be0/tests/test_auth_policy_integration.py new file mode 100644 index 0000000..90b37b0 --- /dev/null +++ b/be0/tests/test_auth_policy_integration.py @@ -0,0 +1,126 @@ +""" +Auth policy integration: server-derived admin vs viewer, ignored client role, UMC domain. + +Requires Postgres + migration 007 (admin_from_email_policy column) and 013 (email_verified). + + export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives" + cd be0 && python -m unittest tests.test_auth_policy_integration -v +""" + +from __future__ import annotations + +import os +import unittest +import uuid +from unittest.mock import patch + +from sqlalchemy import select + +from tests.auth_register_staff_fixture import register_staff_fields + +_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql") + +_TEST_PASSWORD = "Testpass1!" + + +@unittest.skipUnless( + _RUN_DB, + "Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests", +) +class AuthPolicyIntegrationTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + await eng.init_engine() + + async def asyncTearDown(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + + async def _delete_user_email(self, email: str) -> None: + from src.initiative_db.engine import get_session + from src.initiative_db.models import User + + async with get_session() as session: + user = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + if user is not None: + await session.delete(user) + + def _register(self, email: str, full_name: str = "Policy Test", extra: dict | None = None): + from fastapi.testclient import TestClient + + from main import app + + body = { + "fullName": full_name, + "email": email, + "password": _TEST_PASSWORD, + "passwordConfirm": _TEST_PASSWORD, + **register_staff_fields(), + } + if extra: + body.update(extra) + with TestClient(app) as client: + return client.post("/api/v1/auth/register", json=body) + + async def test_register_default_viewer_ump(self) -> None: + email = f"applicant-{uuid.uuid4().hex[:12]}@ump.edu.vn" + try: + r = self._register(email) + self.assertEqual(r.status_code, 200, r.text) + data = r.json() + self.assertTrue(data.get("emailVerificationRequired")) + self.assertNotIn("accessToken", data) + roles = data["user"]["roles"] + self.assertIn("viewer", roles) + self.assertNotIn("admin", roles) + finally: + await self._delete_user_email(email) + + async def test_register_default_viewer_umc(self) -> None: + email = f"applicant-{uuid.uuid4().hex[:12]}@umc.edu.vn" + try: + r = self._register(email) + self.assertEqual(r.status_code, 200, r.text) + self.assertTrue(r.json().get("emailVerificationRequired")) + roles = r.json()["user"]["roles"] + self.assertIn("viewer", roles) + self.assertNotIn("admin", roles) + finally: + await self._delete_user_email(email) + + async def test_register_ignores_client_admin_role(self) -> None: + email = f"privesc-{uuid.uuid4().hex[:12]}@ump.edu.vn" + try: + r = self._register( + email, + extra={"role": "admin"}, + ) + self.assertEqual(r.status_code, 200, r.text) + self.assertTrue(r.json().get("emailVerificationRequired")) + roles = r.json()["user"]["roles"] + self.assertIn("viewer", roles) + self.assertNotIn("admin", roles) + finally: + await self._delete_user_email(email) + + async def test_policy_env_makes_admin(self) -> None: + email = f"stub-admin-{uuid.uuid4().hex[:12]}@ump.edu.vn" + try: + with patch.dict(os.environ, {"AUTH_ADMIN_EMAILS": email}): + r = self._register(email) + self.assertEqual(r.status_code, 200, r.text) + self.assertTrue(r.json().get("emailVerificationRequired")) + roles = r.json()["user"]["roles"] + self.assertIn("admin", roles) + self.assertNotIn("viewer", roles) + finally: + await self._delete_user_email(email) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_authenticate_user.py b/be0/tests/test_authenticate_user.py new file mode 100644 index 0000000..4845813 --- /dev/null +++ b/be0/tests/test_authenticate_user.py @@ -0,0 +1,146 @@ +"""Unit tests for the AuthenticateUser use case using fakes (no DB, no FastAPI). + +Async use case is driven via ``asyncio.run`` so no pytest-asyncio plugin is needed. +""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest + +from src.application.identity.dto import LoginCommand +from src.application.identity.use_cases.authenticate_user import AuthenticateUser +from src.domain.identity.entities import User +from src.domain.identity.errors import ( + EmailNotVerified, + InvalidCredentials, + InvalidInstitutionalEmail, +) +from src.shared_kernel.errors import RateLimited + + +class FakeUsers: + def __init__(self, user: User | None = None, roles: list[str] | None = None) -> None: + self._user = user + self._roles = roles or [] + self.reconciled = False + + async def get_by_email(self, email: str) -> User | None: + return self._user if (self._user and self._user.email == email) else None + + async def get_by_id(self, user_id): # pragma: no cover - unused here + return self._user + + async def roles_after_reconcile(self, user: User) -> list[str]: + self.reconciled = True + return self._roles + + +class FakeHasher: + def __init__(self, ok: bool = True) -> None: + self.ok = ok + + def hash(self, plain: str) -> str: + return "h:" + plain + + def verify(self, plain: str, hashed: str) -> bool: + return self.ok + + +class FakeTokens: + def issue(self, user_id, email, roles, credential_version) -> str: + return f"tok:{user_id}:{credential_version}:{','.join(roles)}" + + +class FakeRateLimiter: + def __init__(self, allow: bool = True) -> None: + self._allow = allow + + def allow(self, email: str, client_ip: str) -> bool: + return self._allow + + +class FakeAudit: + def __init__(self) -> None: + self.events: list[tuple] = [] + + async def login_succeeded(self, *, user_id, email, roles) -> None: + self.events.append(("ok", email, tuple(roles))) + + async def login_failed(self, *, email, user_id, reason) -> None: + self.events.append(("fail", email, reason)) + + +def _user(**kw) -> User: + base = dict( + id=uuid.uuid4(), + email="a@ump.edu.vn", + full_name="Test", + password_hash="x", + email_verified=True, + is_active=True, + credential_version=2, + ) + base.update(kw) + return User(**base) + + +def _build(users: FakeUsers, hasher=None, rate_limiter=None, audit=None) -> tuple: + audit = audit or FakeAudit() + uc = AuthenticateUser( + users=users, + hasher=hasher or FakeHasher(ok=True), + tokens=FakeTokens(), + rate_limiter=rate_limiter or FakeRateLimiter(allow=True), + audit=audit, + ) + return uc, audit + + +def test_login_success_returns_token_and_reconciles_roles() -> None: + users = FakeUsers(user=_user(), roles=["admin", "viewer"]) + uc, audit = _build(users) + result = asyncio.run(uc.execute(LoginCommand("A@ump.edu.vn", "pw", "1.2.3.4"))) + assert result.access_token.endswith(":2:admin,viewer") + assert result.roles == ["admin", "viewer"] + assert users.reconciled is True + assert audit.events == [("ok", "a@ump.edu.vn", ("admin", "viewer"))] + + +def test_wrong_password_raises_401_and_audits_failure() -> None: + users = FakeUsers(user=_user()) + uc, audit = _build(users, hasher=FakeHasher(ok=False)) + with pytest.raises(InvalidCredentials) as exc: + asyncio.run(uc.execute(LoginCommand("a@ump.edu.vn", "bad", "ip"))) + assert exc.value.message == "Email hoặc mật khẩu không đúng." + assert audit.events == [("fail", "a@ump.edu.vn", None)] + + +def test_unknown_email_raises_401() -> None: + uc, _ = _build(FakeUsers(user=None)) + with pytest.raises(InvalidCredentials): + asyncio.run(uc.execute(LoginCommand("nobody@ump.edu.vn", "pw", "ip"))) + + +def test_unverified_email_raises_403_with_reason() -> None: + users = FakeUsers(user=_user(email_verified=False)) + uc, audit = _build(users) + with pytest.raises(EmailNotVerified): + asyncio.run(uc.execute(LoginCommand("a@ump.edu.vn", "pw", "ip"))) + assert audit.events == [("fail", "a@ump.edu.vn", "email_unverified")] + + +def test_rate_limited_raises_429_before_db() -> None: + users = FakeUsers(user=_user()) + uc, _ = _build(users, rate_limiter=FakeRateLimiter(allow=False)) + with pytest.raises(RateLimited): + asyncio.run(uc.execute(LoginCommand("a@ump.edu.vn", "pw", "ip"))) + + +def test_non_institutional_email_rejected_before_lookup() -> None: + users = FakeUsers(user=_user()) + uc, _ = _build(users) + with pytest.raises(InvalidInstitutionalEmail): + asyncio.run(uc.execute(LoginCommand("a@gmail.com", "pw", "ip"))) diff --git a/be0/tests/test_backup_e2e.py b/be0/tests/test_backup_e2e.py new file mode 100644 index 0000000..0e65d8c --- /dev/null +++ b/be0/tests/test_backup_e2e.py @@ -0,0 +1,257 @@ +""" +Full-stack backup E2E: HTTP submit (PDF → Postgres + MinIO) then admin ZIP download. + +Requires PostgreSQL with migrations through **009** and reachable **MinIO (S3 API)**. + +Run (host → docker-compose ports): + + export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives" + export S3_ENDPOINT_URL="http://127.0.0.1:19000" + export S3_PUBLIC_ENDPOINT_URL="http://127.0.0.1:19000" + export S3_ACCESS_KEY="minio_user" + export S3_SECRET_KEY="minio_password" + export S3_BUCKET_ATTACHMENTS="initiative-attachments" + export S3_BUCKET_EXPORTS="initiative-exports" + export S3_BUCKET_QUARANTINE="initiative-quarantine" + export E2E_BACKUP=1 + cd be0 && python -m unittest tests.test_backup_e2e -v + +Browser E2E: see ``fe0/e2e/backup-admin-download.spec.ts`` (requires the same stack plus ``fe0`` + ``E2E_ADMIN_EMAIL`` on ``AUTH_ADMIN_EMAILS``). +""" + +from __future__ import annotations + +import io +import json +import os +import unittest +import uuid +import zipfile +from unittest.mock import patch + +from sqlalchemy import select + +from tests.auth_register_staff_fixture import register_staff_fields + +_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql") +_S3_KEYS = ( + "S3_ENDPOINT_URL", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_BUCKET_ATTACHMENTS", + "S3_BUCKET_EXPORTS", + "S3_BUCKET_QUARANTINE", +) +_HAS_S3 = all(os.getenv(k, "").strip() for k in _S3_KEYS) +_RUN_BACKUP = os.getenv("E2E_BACKUP", "").strip().lower() in ("1", "true", "yes") + +_TEST_PASSWORD = "Testpass1!" + +_MIN_PDF = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n" + b"0" * 120 + + +@unittest.skipUnless( + _RUN_DB and _HAS_S3 and _RUN_BACKUP, + "Need INITIATIVE_DATABASE_URL, full S3_* env, and E2E_BACKUP=1 (see module docstring).", +) +class BackupFullStackApiE2ETests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + await eng.init_engine() + + async def asyncTearDown(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + + async def _delete_users_by_email(self, emails: list[str]) -> None: + from src.initiative_db.engine import get_session + from src.initiative_db.models import Initiative, User + + async with get_session() as session: + for email in emails: + user = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + if user is None: + continue + inis = ( + await session.execute(select(Initiative).where(Initiative.owner_id == user.id)) + ).scalars().all() + for ini in inis: + await session.delete(ini) + await session.flush() + await session.delete(user) + await session.commit() + + async def test_submit_creates_minio_full_pdf_then_backup_zip(self) -> None: + from fastapi.testclient import TestClient + + from main import app + from src.initiative_db.engine import get_session + from src.initiative_db.models import ApplicationArtifact, Initiative + + applicant_email = f"e2e-backup-app-{uuid.uuid4().hex[:10]}@ump.edu.vn" + admin_email = f"e2e-backup-adm-{uuid.uuid4().hex[:10]}@ump.edu.vn" + + try: + with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}): + with TestClient(app) as client: + cap_app: list[str] = [] + + async def grab_app(_t: str, raw: str) -> None: + cap_app.append(raw) + + with patch("src.auth_api.deliver_registration_otp_email", side_effect=grab_app): + r = client.post( + "/api/v1/auth/register", + json={ + "fullName": "E2E Applicant", + "email": applicant_email, + "password": _TEST_PASSWORD, + "passwordConfirm": _TEST_PASSWORD, + **register_staff_fields(), + }, + ) + self.assertEqual(r.status_code, 200, r.text) + self.assertTrue(cap_app) + r = client.post( + "/api/v1/auth/verify-otp", + json={"email": applicant_email, "otp": cap_app[0]}, + ) + self.assertEqual(r.status_code, 200, r.text) + r = client.post( + "/api/v1/auth/login", + json={"email": applicant_email, "password": _TEST_PASSWORD}, + ) + self.assertEqual(r.status_code, 200, r.text) + applicant_token = r.json()["accessToken"] + + r = client.post( + "/api/applications/new", + headers={"Authorization": f"Bearer {applicant_token}"}, + json={"name": "E2E backup row"}, + ) + self.assertEqual(r.status_code, 200, r.text) + shell = r.json().get("application") or {} + case_id = str(shell.get("draft_case_id") or "").strip() + application_id = str(r.json().get("id") or shell.get("id") or "").strip() + self.assertTrue(case_id, shell) + self.assertTrue(application_id, shell) + + from tests.fixtures.minimal_submit_bundle import minimal_tabs_bundle + + bundle = minimal_tabs_bundle(initiative_name="E2E Backup Initiative") + for tab_name in ("report", "application", "contribution"): + r = client.post( + "/api/v1/application-drafts", + headers={"Authorization": f"Bearer {applicant_token}"}, + json={"caseId": case_id, "tab": tab_name, "data": bundle[tab_name]}, + ) + self.assertEqual(r.status_code, 200, r.text) + + r = client.post( + f"/api/v1/application-drafts/{case_id}/evidence", + headers={"Authorization": f"Bearer {applicant_token}"}, + data={"kind": "technical"}, + files={"file": ("mc.pdf", io.BytesIO(_MIN_PDF), "application/pdf")}, + ) + self.assertEqual(r.status_code, 200, r.text) + + meta = { + "initiativeCaseId": case_id, + "initiativeName": "E2E Backup Initiative", + "authorName": "Applicant", + "authorEmail": applicant_email, + "subjectId": "s1", + "groupId": "g1", + "topicType": "Hồ sơ PDF", + } + r = client.post( + "/api/applications/submit", + headers={"Authorization": f"Bearer {applicant_token}"}, + files={"file": ("e2e.pdf", io.BytesIO(_MIN_PDF), "application/pdf")}, + data={"metadata": json.dumps(meta)}, + ) + self.assertEqual(r.status_code, 200, r.text, r.text) + application_id = str((r.json() or {}).get("id") or application_id) + + with patch.dict(os.environ, {"AUTH_ADMIN_EMAILS": admin_email}): + cap_adm: list[str] = [] + + async def grab_adm(_t: str, raw: str) -> None: + cap_adm.append(raw) + + with patch( + "src.auth_api.deliver_registration_otp_email", + side_effect=grab_adm, + ): + r = client.post( + "/api/v1/auth/register", + json={ + "fullName": "E2E Admin", + "email": admin_email, + "password": _TEST_PASSWORD, + "passwordConfirm": _TEST_PASSWORD, + **register_staff_fields(), + }, + ) + self.assertEqual(r.status_code, 200, r.text) + self.assertTrue(cap_adm) + self.assertIn("admin", r.json()["user"]["roles"]) + r = client.post( + "/api/v1/auth/verify-otp", + json={"email": admin_email, "otp": cap_adm[0]}, + ) + self.assertEqual(r.status_code, 200, r.text) + r = client.post( + "/api/v1/auth/login", + json={"email": admin_email, "password": _TEST_PASSWORD}, + ) + self.assertEqual(r.status_code, 200, r.text) + admin_token = r.json()["accessToken"] + + r = client.get( + f"/api/applications/{application_id}/backup", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(r.status_code, 200, r.text) + self.assertEqual(r.headers.get("content-type", "").split(";")[0], "application/zip") + + buf = io.BytesIO(r.content) + with zipfile.ZipFile(buf, "r") as zf: + names = zf.namelist() + self.assertIn("manifest.json", names) + self.assertIn("submitted/full-package.pdf", names) + manifest = json.loads(zf.read("manifest.json").decode("utf-8")) + self.assertEqual(str(manifest.get("applicationId")), application_id) + self.assertIn("initiative_id", manifest) + packed = {str(x.get("zip_path")): x for x in manifest.get("files") or []} + self.assertIn("submitted/full-package.pdf", packed) + self.assertFalse(packed["submitted/full-package.pdf"].get("skipped")) + + async with get_session() as session: + ini = ( + await session.execute(select(Initiative).where(Initiative.case_code == case_id)) + ).scalar_one() + row = ( + await session.execute( + select(ApplicationArtifact).where( + ApplicationArtifact.initiative_id == ini.id, + ApplicationArtifact.role == "full_pdf", + ) + ) + ).scalar_one_or_none() + self.assertIsNotNone(row) + assert row is not None + uri = (row.storage_uri or "").strip() + self.assertTrue(uri.startswith("initiatives/"), uri) + self.assertEqual(row.storage_kind, "minio_exports") + finally: + await self._delete_users_by_email([applicant_email, admin_email]) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_dashboard_lookup_routes.py b/be0/tests/test_dashboard_lookup_routes.py new file mode 100644 index 0000000..5cc6819 --- /dev/null +++ b/be0/tests/test_dashboard_lookup_routes.py @@ -0,0 +1,31 @@ +""" +GET /api/conferences and /api/supervisors — dashboard filter lookups. + +Run: cd be0 && python -m unittest tests.test_dashboard_lookup_routes -v +""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + + +class DashboardLookupRoutesTests(unittest.TestCase): + def test_no_db_returns_empty_lists(self) -> None: + from fastapi.testclient import TestClient + + from main import app + from src.initiative_db import engine as eng + + with patch.object(eng, "is_postgres_enabled", return_value=False): + client = TestClient(app) + r1 = client.get("/api/conferences") + r2 = client.get("/api/supervisors") + self.assertEqual(r1.status_code, 200, r1.text) + self.assertEqual(r2.status_code, 200, r2.text) + self.assertEqual(r1.json(), []) + self.assertEqual(r2.json(), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_docx_normalize.py b/be0/tests/test_docx_normalize.py new file mode 100644 index 0000000..dc7791c --- /dev/null +++ b/be0/tests/test_docx_normalize.py @@ -0,0 +1,678 @@ +"""Tests for OOXML normalization used after docxtpl render.""" + +from __future__ import annotations + +import io +import re +import unittest +import zipfile + +from src.be01.docx_normalize import ( + collapse_empty_page_break_paragraphs_in_docx, + force_times_new_roman_in_styles_docx, + move_signature_date_to_top_row, + normalize_bo_y_te_header_lines, + relax_justified_softbreak_paragraphs_in_docx, + shift_selected_header_lines_left, + strip_mau_04_evaluation_section_in_docx, + strip_table_row_height_rules_from_docx, +) + + +def _wrap_doc_in_zip(doc_xml: bytes) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + return buf.getvalue() + + +def _read_document_xml(docx_bytes: bytes) -> str: + with zipfile.ZipFile(io.BytesIO(docx_bytes)) as z: + return z.read("word/document.xml").decode("utf-8") + + +class DocxNormalizeTests(unittest.TestCase): + def test_strip_tr_height_removes_self_closing(self) -> None: + xml = ( + b'' + b"" + b'' + b"a" + b"" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + out = strip_table_row_height_rules_from_docx(buf.getvalue()) + with zipfile.ZipFile(io.BytesIO(out)) as z2: + doc = z2.read("word/document.xml").decode("utf-8") + self.assertNotIn("trHeight", doc) + self.assertNotIn("720", doc) + + def test_normalize_bo_y_te_strips_ministry_bold_centers(self) -> None: + doc_xml = """ + + + +BỘ Y TẾ + + +ĐẠI HỘC Y DƯỢCTHÀNH PHỐ HỒ CHÍ MINH + +""".encode( + "utf-8" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + phase1 = shift_selected_header_lines_left(buf.getvalue()) + out = normalize_bo_y_te_header_lines(phase1) + with zipfile.ZipFile(io.BytesIO(out)) as z2: + doc = z2.read("word/document.xml").decode("utf-8") + ministry = re.search(r"<[^>]*:p\b[^>]*>.*?BỘ Y TẾ.*?]*:p>", doc, re.DOTALL | re.IGNORECASE) + self.assertIsNotNone(ministry) + assert ministry is not None + block = ministry.group(0) + self.assertNotIn("ns0:b", block.split("BỘ Y TẾ")[0]) + self.assertIn('val="center"', block) + uni = re.search(r"<[^>]*:p\b[^>]*>.*?ĐẠI HỘC Y DƯỢC.*?]*:p>", doc, re.DOTALL | re.IGNORECASE) + self.assertIsNotNone(uni) + assert uni is not None + self.assertIn("ns0:b", uni.group(0)) + self.assertIn("Times New Roman", uni.group(0)) + + def test_university_letterhead_two_paragraphs_bold_centered(self) -> None: + """Cover may use two paragraphs instead of one line break.""" + doc_xml = """ + + + +ĐẠI HỘC Y DƯỢC + + +THÀNH PHỐ HỒ CHÍ MINH + +""".encode( + "utf-8" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + out = normalize_bo_y_te_header_lines(buf.getvalue()) + with zipfile.ZipFile(io.BytesIO(out)) as z2: + doc = z2.read("word/document.xml").decode("utf-8") + for label, needle in ( + ("dhyd", "ĐẠI HỘC Y DƯỢC"), + ("tphcm", "THÀNH PHỐ HỒ CHÍ MINH"), + ): + blk = re.search( + rf"<[^>]*:p\b[^>]*>.*?{re.escape(needle)}.*?]*:p>", + doc, + re.DOTALL | re.IGNORECASE, + ) + self.assertIsNotNone(blk, msg=label) + assert blk is not None + b = blk.group(0) + self.assertIn("ns0:b", b, msg=label) + self.assertIn('val="center"', b, msg=label) + + def test_university_letterhead_one_paragraph_gets_soft_break_inserted(self) -> None: + """When both letterhead phrases share one paragraph on a single visual line, a + soft is inserted before the city line so the cover renders on two lines. + + Also asserts the runs end up bold + upright (no italic) + Times New Roman.""" + doc_xml = """ + + + +ĐẠI HỘC Y DƯỢC THÀNH PHỐ HỒ CHÍ MINH + +""".encode( + "utf-8" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + out = normalize_bo_y_te_header_lines(buf.getvalue()) + with zipfile.ZipFile(io.BytesIO(out)) as z2: + doc = z2.read("word/document.xml").decode("utf-8") + # A soft break should now sit between the two phrases. + self.assertRegex( + doc, + r"ĐẠI HỘC Y DƯỢC.*?<[^>]*:br[^>]*/?>.*?THÀNH PHỐ HỒ CHÍ MINH", + ) + # Paragraph is centered, runs are bold + not italic + Times New Roman. + self.assertIn('val="center"', doc) + self.assertIn("ns0:b", doc) + self.assertIn('ns0:i ns0:val="0"', doc) + self.assertIn("Times New Roman", doc) + + def test_university_letterhead_soft_break_idempotent(self) -> None: + """Running normalize twice should not stack additional elements between + the letterhead phrases.""" + doc_xml = """ + + + +ĐẠI HỘC Y DƯỢC THÀNH PHỐ HỒ CHÍ MINH + +""".encode( + "utf-8" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + once = normalize_bo_y_te_header_lines(buf.getvalue()) + twice = normalize_bo_y_te_header_lines(once) + with zipfile.ZipFile(io.BytesIO(twice)) as z2: + doc = z2.read("word/document.xml").decode("utf-8") + br_count = len(re.findall(r"<[^>]*:br\b[^>]*/?>", doc)) + self.assertEqual(br_count, 1, msg=f"expected exactly one , got {br_count}: {doc!r}") + + def test_first_page_scope_second_bo_te_unchanged(self) -> None: + """Only the cover « BỘ Y TẾ » is stripped of bold; a later duplicate keeps bold.""" + doc_xml = """ + + +BỘ Y TẾ +ĐẠI HỘC Y DƯỢCTHÀNH PHỐ HỒ CHÍ MINH + +BỘ Y TẾ +""".encode( + "utf-8" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + out = normalize_bo_y_te_header_lines(buf.getvalue()) + with zipfile.ZipFile(io.BytesIO(out)) as z2: + doc = z2.read("word/document.xml").decode("utf-8") + paras = re.findall(r"<[^>]*:p\b[^>]*>.*?]*:p>", doc, re.DOTALL | re.IGNORECASE) + self.assertEqual(len(paras), 4, msg="expected 4 paragraphs") + first_bo = paras[0] + late_bo = paras[3] + self.assertNotIn("ns0:b", first_bo.split("BỘ Y TẾ")[0]) + self.assertIn("ns0:b", late_bo) + + def test_move_signature_date_creates_full_width_top_row(self) -> None: + """The date paragraph is lifted into a single-cell top row spanning every column.""" + doc_xml = """ + + + + + +LÃNH ĐẠO ĐƠN VỊ(Ký, ghi rõ họ tên) +Tp. Hồ Chí Minh, ngày 11 tháng 5 năm 2026Tác giả sáng kiến + + +""".encode( + "utf-8" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + out = move_signature_date_to_top_row(buf.getvalue()) + with zipfile.ZipFile(io.BytesIO(out)) as z2: + doc = z2.read("word/document.xml").decode("utf-8") + + rows = re.findall(r"<[^>]*:tr\b[^>]*>.*?]*:tr>", doc, re.DOTALL) + self.assertEqual(len(rows), 2, msg=f"expected 2 rows after lift, got: {doc!r}") + + first_row, second_row = rows + # Top row: single cell, gridSpan=2, contains the date, right-aligned. + self.assertEqual(first_row.count("") + first_row.count("]*:gridSpan\s+[^>]*:val="2"') + self.assertIn("Tp. Hồ Chí Minh, ngày 11 tháng 5 năm 2026", first_row) + self.assertRegex(first_row, r'<[^>]*:jc\s+[^>]*:val="right"') + + # Second row: original 2 cells. Right cell starts with "Tác giả sáng kiến" + # (no date paragraph anymore), so it aligns with "LÃNH ĐẠO ĐƠN VỊ". + self.assertNotIn("Tp. Hồ Chí Minh, ngày", second_row) + self.assertIn("LÃNH ĐẠO ĐƠN VỊ", second_row) + self.assertIn("Tác giả sáng kiến", second_row) + + def test_move_signature_date_is_idempotent(self) -> None: + """A second pass over an already-lifted table is a no-op (still exactly 2 rows).""" + doc_xml = """ + + + + + +LÃNH ĐẠO ĐƠN VỊ +Tp. Hồ Chí Minh, ngày 11 tháng 5 năm 2026Tác giả sáng kiến + + +""".encode( + "utf-8" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + once = move_signature_date_to_top_row(buf.getvalue()) + twice = move_signature_date_to_top_row(once) + with zipfile.ZipFile(io.BytesIO(twice)) as z2: + doc = z2.read("word/document.xml").decode("utf-8") + rows = re.findall(r"<[^>]*:tr\b[^>]*>.*?]*:tr>", doc, re.DOTALL) + self.assertEqual(len(rows), 2, msg=f"expected 2 rows after second pass, got: {doc!r}") + date_hits = doc.count("Tp. Hồ Chí Minh, ngày") + self.assertEqual(date_hits, 1, msg=f"date should appear exactly once, got {date_hits}") + + def test_move_signature_date_skips_table_without_date(self) -> None: + """Tables that do not contain the date prefix are left untouched.""" + doc_xml = """ + + + + +cell Acell B + +""".encode( + "utf-8" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + out = move_signature_date_to_top_row(buf.getvalue()) + with zipfile.ZipFile(io.BytesIO(out)) as z2: + doc = z2.read("word/document.xml").decode("utf-8") + rows = re.findall(r"<[^>]*:tr\b[^>]*>.*?]*:tr>", doc, re.DOTALL) + self.assertEqual(len(rows), 1, msg="non-signature tables must be left untouched") + + def test_relax_justified_splits_paragraph_at_soft_break_in_run(self) -> None: + """Justified paragraph with a soft mid-run is split into two paragraphs. + Both fragments keep so the layout stays justified, and the + line that used to be followed by the soft break (« first chunk ») becomes the + last line of its own paragraph -> stops being stretched.""" + doc_xml = """ + + + +first chunksecond chunk + +""".encode( + "utf-8" + ) + out = relax_justified_softbreak_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml)) + doc = _read_document_xml(out) + self.assertNotRegex( + doc, r"<[^>]*:br\b(?![^>]*:type=\"page\")[^>]*/?>", + msg="soft should be consumed by the split", + ) + paras = re.findall(r"<[^>]*:p\b[^>]*>.*?]*:p>", doc, re.DOTALL) + self.assertEqual(len(paras), 2, msg=f"expected 2 paragraphs after split: {doc!r}") + for p in paras: + self.assertRegex(p, r'<[^>]*:jc\s+[^>]*:val="both"') + self.assertIn("Arial", p) # run properties preserved on both fragments + self.assertIn("first chunk", paras[0]) + self.assertIn("second chunk", paras[1]) + self.assertNotIn("second chunk", paras[0]) + + def test_relax_justified_distribute_becomes_both(self) -> None: + """`distribute` stretches every line including the last; rewrite it to `both`.""" + doc_xml = """ + + +solo line +""".encode( + "utf-8" + ) + out = relax_justified_softbreak_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml)) + doc = _read_document_xml(out) + self.assertNotIn('val="distribute"', doc) + self.assertRegex(doc, r'<[^>]*:jc\s+[^>]*:val="both"') + + def test_relax_justified_rewrites_distribute_in_styles_xml(self) -> None: + """Paragraph styles may use ``distribute``; rewrite so body text is justified like Word ``both``.""" + doc_xml = """ + +x""".encode("utf-8") + styles_xml = """ + + + + +""".encode("utf-8") + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr("word/styles.xml", styles_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + out = relax_justified_softbreak_paragraphs_in_docx(buf.getvalue()) + with zipfile.ZipFile(io.BytesIO(out)) as z2: + styles = z2.read("word/styles.xml").decode("utf-8") + self.assertNotIn('val="distribute"', styles) + self.assertIn('val="both"', styles) + + def test_relax_justified_merges_do_not_expand_shift_return_in_settings(self) -> None: + """Compatibility flag so lines ending in soft breaks are not fully stretched when justified.""" + doc_xml = """ + +x""".encode("utf-8") + settings_xml = b""" + + +""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/document.xml", doc_xml) + z.writestr("word/settings.xml", settings_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + out = relax_justified_softbreak_paragraphs_in_docx(buf.getvalue()) + with zipfile.ZipFile(io.BytesIO(out)) as z2: + settings = z2.read("word/settings.xml").decode("utf-8") + self.assertIn("doNotExpandShiftReturn", settings) + self.assertRegex(settings, r'doNotExpandShiftReturn[^>]*val="1"') + + def test_relax_justified_preserves_non_justified_paragraphs(self) -> None: + """Soft breaks in non-justified paragraphs are left alone (no surprise splits).""" + doc_xml = """ + + + +line1line2 + +""".encode( + "utf-8" + ) + out = relax_justified_softbreak_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml)) + doc = _read_document_xml(out) + paras = re.findall(r"<[^>]*:p\b[^>]*>.*?]*:p>", doc, re.DOTALL) + self.assertEqual(len(paras), 1, msg="left-aligned paragraphs must not be split") + self.assertRegex(doc, r"<[^>]*:br\b[^>]*/?>", msg="soft break should survive") + + def test_relax_justified_preserves_page_break(self) -> None: + """Page breaks (``) are NOT treated as soft breaks.""" + doc_xml = """ + + + +beforeafter + +""".encode( + "utf-8" + ) + out = relax_justified_softbreak_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml)) + doc = _read_document_xml(out) + paras = re.findall(r"<[^>]*:p\b[^>]*>.*?]*:p>", doc, re.DOTALL) + self.assertEqual(len(paras), 1, msg="page breaks must not trigger paragraph split") + self.assertRegex(doc, r'<[^>]*:br\s+[^>]*:type="page"') + + def test_relax_justified_idempotent(self) -> None: + """Running twice produces the same output as running once.""" + doc_xml = """ + + + +aaabbbccc + +""".encode( + "utf-8" + ) + once = relax_justified_softbreak_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml)) + twice = relax_justified_softbreak_paragraphs_in_docx(once) + self.assertEqual( + _read_document_xml(once), + _read_document_xml(twice), + msg="second pass should be a no-op", + ) + paras = re.findall( + r"<[^>]*:p\b[^>]*>.*?]*:p>", _read_document_xml(once), re.DOTALL + ) + self.assertEqual(len(paras), 3, msg="two soft breaks should yield three paragraphs") + + def test_strip_mau_04_removes_section_between_page_breaks(self) -> None: + """Body order: mau_03 sig, page-break, letterhead table, « Mẫu số 04 », content, + page-break, « Bản cam kết ». Strip should drop everything from the leading + page-break paragraph through the last Mẫu số 04 content paragraph (inclusive), + keeping the trailing page-break paragraph that opens « Bản cam kết ».""" + doc_xml = """ + + +{{ mau_03.tac_gia_chinh_ky }} + +BỘ Y TẾ +Mẫu số 04 +PHIẾU ĐÁNH GIÁ SÁNG KIẾN +1. Tên sáng kiến: {{ mau_04.ten_sang_kien }} +Kết luận: {{ mau_04.ket_luan }} +{{ mau_04.thanh_vien_hoi_dong }} + +CỘNG HOÀ XÃ HỘI CHỦ NGHĨA VIỆT NAM +BẢN CAM KẾT + +""".encode( + "utf-8" + ) + out = strip_mau_04_evaluation_section_in_docx(_wrap_doc_in_zip(doc_xml)) + doc = _read_document_xml(out) + self.assertNotIn("Mẫu số 04", doc) + self.assertNotIn("PHIẾU ĐÁNH GIÁ", doc) + self.assertNotIn("mau_04", doc) + # The leading page break + letterhead + content are gone, but the trailing + # page-break paragraph (now the only page break) must survive so Bản cam kết + # still starts on its own page. + page_breaks = re.findall(r'<[^>]*:br\s+[^>]*:type="page"', doc) + self.assertEqual(len(page_breaks), 1, msg=f"expected 1 page break, got {len(page_breaks)}: {doc!r}") + self.assertIn("mau_03.tac_gia_chinh_ky", doc) + self.assertIn("BẢN CAM KẾT", doc) + self.assertIn("CỘNG HOÀ XÃ HỘI CHỦ NGHĨA VIỆT NAM", doc) + # sectPr must survive the trim. + self.assertRegex(doc, r"<[^>]*:sectPr") + + def test_strip_mau_04_is_idempotent(self) -> None: + """Second pass over an already-stripped document is a no-op.""" + doc_xml = """ + + +mau_03 end + +Mẫu số 04 +content + +BẢN CAM KẾT +""".encode( + "utf-8" + ) + once = strip_mau_04_evaluation_section_in_docx(_wrap_doc_in_zip(doc_xml)) + twice = strip_mau_04_evaluation_section_in_docx(once) + self.assertEqual(_read_document_xml(once), _read_document_xml(twice)) + self.assertNotIn("Mẫu số 04", _read_document_xml(once)) + + def test_strip_mau_04_noop_when_marker_missing(self) -> None: + """Documents that don't carry the « Mẫu số 04 » header are left untouched.""" + doc_xml = """ + + +only mau_03 + +BẢN CAM KẾT +""".encode( + "utf-8" + ) + out = strip_mau_04_evaluation_section_in_docx(_wrap_doc_in_zip(doc_xml)) + before = _read_document_xml(_wrap_doc_in_zip(doc_xml)) + after = _read_document_xml(out) + # Allow whitespace / declaration differences from ElementTree round-trip; the + # human-readable text content must be unchanged. + for needle in ("only mau_03", "BẢN CAM KẾT"): + self.assertIn(needle, after) + self.assertNotIn("Mẫu số 04", after) + + def test_strip_mau_04_bails_out_without_leading_page_break(self) -> None: + """If there's no page break before the Mẫu số 04 header (malformed template), + leave the document alone instead of removing the previous section by mistake.""" + doc_xml = """ + + +previous section content +Mẫu số 04 +{{ mau_04.ten_sang_kien }} +""".encode( + "utf-8" + ) + out = strip_mau_04_evaluation_section_in_docx(_wrap_doc_in_zip(doc_xml)) + doc = _read_document_xml(out) + self.assertIn("previous section content", doc) + self.assertIn("Mẫu số 04", doc, msg="strip must not run when leading page break is missing") + + def test_collapse_empty_pagebreak_before_table_uses_pagebreakbefore(self) -> None: + """An empty paragraph that hosts only ```` followed by a + table is removed; the first paragraph in the first cell of the table gets + ```` so the table anchors to a new page without an + intervening empty body paragraph.""" + doc_xml = """ + + +previous content + + + +letterhead cell + + +next section paragraph +""".encode( + "utf-8" + ) + out = collapse_empty_page_break_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml)) + doc = _read_document_xml(out) + self.assertNotRegex( + doc, r'<[^>]*:br\s+[^>]*:type="page"', + msg="inline should be replaced", + ) + self.assertRegex(doc, r"<[^>]*:pageBreakBefore") + # The empty page-break paragraph is gone but original content survives. + self.assertIn("previous content", doc) + self.assertIn("letterhead cell", doc) + self.assertIn("next section paragraph", doc) + + def test_collapse_empty_pagebreak_before_paragraph(self) -> None: + """Empty page-break paragraph followed by a non-empty paragraph: the empty + paragraph is removed and ```` is added to the next paragraph + so it starts on a new page.""" + doc_xml = """ + + +A + +B +""".encode( + "utf-8" + ) + out = collapse_empty_page_break_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml)) + doc = _read_document_xml(out) + # Exactly two body paragraphs left (empty break paragraph collapsed). + paras = re.findall(r"<[^>]*:p\b[^>]*>.*?]*:p>", doc, re.DOTALL) + self.assertEqual(len(paras), 2, msg=f"expected 2 paragraphs, got {len(paras)}: {doc!r}") + # The B paragraph keeps its center alignment AND gains pageBreakBefore. + b_para = next(p for p in paras if "B]*:jc\s+[^>]*:val="center"') + + def test_collapse_empty_pagebreak_idempotent(self) -> None: + """Second pass produces the same output as first pass.""" + doc_xml = """ + + +A + +B +""".encode( + "utf-8" + ) + once = collapse_empty_page_break_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml)) + twice = collapse_empty_page_break_paragraphs_in_docx(once) + self.assertEqual(_read_document_xml(once), _read_document_xml(twice)) + # And exactly one pageBreakBefore in the result (not double-registered). + pbb_count = len(re.findall(r"<[^>]*:pageBreakBefore", _read_document_xml(once))) + self.assertEqual(pbb_count, 1) + + def test_collapse_empty_pagebreak_preserves_text_carrying_breaks(self) -> None: + """A paragraph that carries real text *and* an inline page break (rare; usually + Word-edited) must not be collapsed: dropping the text would lose content.""" + doc_xml = """ + + +visible text +after +""".encode( + "utf-8" + ) + out = collapse_empty_page_break_paragraphs_in_docx(_wrap_doc_in_zip(doc_xml)) + doc = _read_document_xml(out) + self.assertRegex(doc, r'<[^>]*:br\s+[^>]*:type="page"', msg="break must survive") + self.assertIn("visible text", doc) + self.assertIn("after", doc) + self.assertNotIn("pageBreakBefore", doc) + + def test_force_times_new_roman_styles(self) -> None: + styles_xml = b""" + + + +""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("word/styles.xml", styles_xml) + z.writestr( + "[Content_Types].xml", + b'', + ) + out = force_times_new_roman_in_styles_docx(buf.getvalue()) + with zipfile.ZipFile(io.BytesIO(out)) as z2: + st = z2.read("word/styles.xml").decode("utf-8") + self.assertNotIn("Calibri", st) + self.assertIn("Times New Roman", st) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_evidence_initiative_resolution.py b/be0/tests/test_evidence_initiative_resolution.py new file mode 100644 index 0000000..e0372c6 --- /dev/null +++ b/be0/tests/test_evidence_initiative_resolution.py @@ -0,0 +1,108 @@ +""" +``resolve_initiative_for_draft_case_key`` — evidence URLs may use ``sub-…`` or ``SUB-…`` instead of ``Initiative.case_code``. + +Set INITIATIVE_DATABASE_URL to run (same as tests.test_applications_db_integration). + +Run: + cd be0 && python -m unittest tests.test_evidence_initiative_resolution -v +""" + +from __future__ import annotations + +import os +import unittest +import uuid +from datetime import datetime, timezone + +_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql") + + +@unittest.skipUnless( + _RUN_DB, + "Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests", +) +class EvidenceInitiativeResolutionTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + await eng.init_engine() + + async def asyncTearDown(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + + async def test_resolves_by_public_submission_id_case_insensitive(self) -> None: + from sqlalchemy import delete + + from src.initiative_db.engine import get_session + from src.initiative_db.models import Draft, Initiative, User + from src.initiative_db.submissions import resolve_initiative_for_draft_case_key + + owner_id = uuid.uuid4() + owner_email = f"evtest-{owner_id.hex[:8]}@ump.edu.vn" + case_code = f"EVCASE-{uuid.uuid4().hex[:10]}" + submission_id = f"sub-{uuid.uuid4().hex[:16]}" + + async with get_session() as session: + session.add( + User( + id=owner_id, + email=owner_email, + password_hash="-", + full_name="Evidence resolver test", + ) + ) + await session.flush() + ini = Initiative( + case_code=case_code, + owner_id=owner_id, + status="submitted", + submitted_at=datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + session.add(ini) + await session.flush() + payload = { + "caseId": case_code, + "updatedAt": "2026-01-01T12:00:00Z", + "tabs": {}, + "submissionRecord": { + "id": submission_id, + "submittedDate": "2026-01-01T12:00:00.000Z", + "name": "Test", + "author": {"id": case_code, "name": "T", "email": owner_email}, + "status": "submitted", + "reviewStatus": "not_reviewed", + }, + } + session.add( + Draft( + draft_code=f"DRAFT-{case_code}", + initiative_id=ini.id, + payload=payload, + version=1, + ) + ) + await session.commit() + ini_id = ini.id + + async with get_session() as session: + upper_alias = "SUB-" + submission_id.split("-", 1)[1] + r1 = await resolve_initiative_for_draft_case_key(session, submission_id) + r2 = await resolve_initiative_for_draft_case_key(session, upper_alias) + self.assertIsNotNone(r1) + self.assertIsNotNone(r2) + assert r1 is not None and r2 is not None + self.assertEqual(r1.case_code, case_code) + self.assertEqual(r2.case_code, case_code) + + async with get_session() as session: + await session.execute(delete(Draft).where(Draft.initiative_id == ini_id)) + await session.execute(delete(Initiative).where(Initiative.id == ini_id)) + await session.execute(delete(User).where(User.id == owner_id)) + await session.commit() + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_evidence_kind_parsing.py b/be0/tests/test_evidence_kind_parsing.py new file mode 100644 index 0000000..3be5b40 --- /dev/null +++ b/be0/tests/test_evidence_kind_parsing.py @@ -0,0 +1,35 @@ +"""Unit tests for ``_evidence_kind_to_role`` (query/form normalization). + +Run: cd be0 && python -m unittest tests.test_evidence_kind_parsing -v +""" + +from __future__ import annotations + +import unittest + + +class EvidenceKindParsingTests(unittest.TestCase): + def test_plain_strings(self) -> None: + from main import _evidence_kind_to_role + + self.assertEqual(_evidence_kind_to_role("research"), "research_evidence") + self.assertEqual(_evidence_kind_to_role("TextBook"), "textbook_evidence") + self.assertEqual(_evidence_kind_to_role("TECHNICAL"), "technical_evidence") + self.assertIsNone(_evidence_kind_to_role("other")) + + def test_prefers_valid_entry_in_list(self) -> None: + """Duplicate or noisy ``kind=`` values: first matching token wins.""" + from main import _evidence_kind_to_role + + self.assertEqual(_evidence_kind_to_role(["", "bad", "research"]), "research_evidence") + self.assertEqual(_evidence_kind_to_role(["research", "textbook"]), "research_evidence") + + def test_strips_zwsp_and_bom(self) -> None: + from main import _evidence_kind_to_role + + self.assertEqual(_evidence_kind_to_role("research\u200b"), "research_evidence") + self.assertEqual(_evidence_kind_to_role("\ufeffresearch"), "research_evidence") + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_filename_normalize.py b/be0/tests/test_filename_normalize.py new file mode 100644 index 0000000..8cf00d7 --- /dev/null +++ b/be0/tests/test_filename_normalize.py @@ -0,0 +1,76 @@ +"""Unit tests for the pure filename-normalization helpers in imagehub_routes. + +These cover the {prefix}_{caseID}_0000.{ext} rename convention (caseID = the file's number, +5-digit zero-padded), the case-number extraction, and the double-extension split — with no +Postgres / MinIO dependency. +""" +from __future__ import annotations + +import unittest + +from src.imagehub_routes import _case_number, _normalized_name, _split_name_ext + + +class TestSplitNameExt(unittest.TestCase): + def test_double_extension_nii_gz(self): + self.assertEqual(_split_name_ext("a.nii.gz"), ("a", ".nii.gz")) + + def test_double_extension_tar_gz(self): + self.assertEqual(_split_name_ext("a.tar.gz"), ("a", ".tar.gz")) + + def test_single_extension(self): + self.assertEqual(_split_name_ext("a.png"), ("a", ".png")) + + def test_no_extension(self): + self.assertEqual(_split_name_ext("a"), ("a", "")) + + +class TestCaseNumber(unittest.TestCase): + def test_plain_number(self): + self.assertEqual(_case_number("1"), 1) + self.assertEqual(_case_number("100"), 100) + + def test_strips_channel_tag(self): + self.assertEqual(_case_number("10_0000"), 10) + + def test_prefixed_stem(self): + # year digits in the prefix must NOT be mistaken for the case number + self.assertEqual(_case_number("POLYP25_00001"), 1) + self.assertEqual(_case_number("POLYP25_00123_0000"), 123) + + def test_no_digits(self): + self.assertIsNone(_case_number("frame")) + + +class TestNormalizedName(unittest.TestCase): + def test_image_gets_prefix_padding_and_channel(self): + self.assertEqual(_normalized_name("1.png", "POLYP25", False), "POLYP25_00001_0000.png") + self.assertEqual(_normalized_name("100.png", "POLYP25", False), "POLYP25_00100_0000.png") + + def test_label_gets_prefix_padding_no_channel(self): + self.assertEqual(_normalized_name("1.png", "POLYP25", True), "POLYP25_00001.png") + + def test_image_and_label_share_case_id(self): + img = _normalized_name("7.png", "POLYP25", False) + lbl = _normalized_name("7.png", "POLYP25", True) + self.assertEqual(img, "POLYP25_00007_0000.png") + self.assertEqual(lbl, "POLYP25_00007.png") + + def test_double_extension(self): + self.assertEqual(_normalized_name("5.nii.gz", "RIB25", False), "RIB25_00005_0000.nii.gz") + + def test_idempotent_already_correct(self): + self.assertIsNone(_normalized_name("POLYP25_00001_0000.png", "POLYP25", False)) + self.assertIsNone(_normalized_name("POLYP25_00001.png", "POLYP25", True)) + + def test_reprefix_changes_prefix_keeps_case(self): + self.assertEqual( + _normalized_name("OLD25_00009_0000.png", "POLYP25", False), "POLYP25_00009_0000.png" + ) + + def test_no_case_number_returns_none(self): + self.assertIsNone(_normalized_name("frame.png", "POLYP25", False)) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_identity_domain.py b/be0/tests/test_identity_domain.py new file mode 100644 index 0000000..c0fbef5 --- /dev/null +++ b/be0/tests/test_identity_domain.py @@ -0,0 +1,133 @@ +"""Unit tests for the pure Identity domain layer. + +No DB, no FastAPI — runs anywhere (``python -m pytest tests/test_identity_domain.py``). +Pins the behavior extracted from auth_api.py so the eventual cut-over can't drift. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +import pytest + +from src.domain.identity.entities import User +from src.domain.identity.errors import InvalidInstitutionalEmail, WeakPassword +from src.domain.identity.services import ( + DEFAULT_POLICY_ADMIN_EMAILS, + AdminReconcileAction, + build_access_token_claims, + policy_admin_emails, + reconcile_admin_action, +) +from src.domain.identity.value_objects import ( + InstitutionalEmail, + Role, + assert_password_policy, +) + + +class TestInstitutionalEmail: + @pytest.mark.parametrize("raw", [" ThaoNTT@UMP.edu.vn ", "x@umc.edu.vn"]) + def test_parse_normalizes_and_accepts(self, raw: str) -> None: + assert InstitutionalEmail.parse(raw).value == raw.strip().lower() + + @pytest.mark.parametrize("raw", ["a@gmail.com", "a@ump.edu.vn.evil.com", "", " "]) + def test_parse_rejects_non_institutional(self, raw: str) -> None: + with pytest.raises(InvalidInstitutionalEmail): + InstitutionalEmail.parse(raw) + + def test_value_object_equality_by_value(self) -> None: + assert InstitutionalEmail.parse("A@ump.edu.vn") == InstitutionalEmail.parse("a@ump.edu.vn") + + +class TestPasswordPolicy: + def test_accepts_strong_password(self) -> None: + assert_password_policy("Abcdef1!") # no raise + + @pytest.mark.parametrize( + "pwd, msg", + [ + ("Ab1!", "Mật khẩu tối thiểu 6 ký tự."), + ("abcdef1!", "Mật khẩu phải có ít nhất một chữ cái hoa."), + ("ABCDEF1!", "Mật khẩu phải có ít nhất một chữ cái thường."), + ("Abcdefg!", "Mật khẩu phải có ít nhất một chữ số."), + ("Abcdef12", "Mật khẩu phải có ít nhất một ký tự đặc biệt (không chỉ chữ và số)."), + ], + ) + def test_rejects_with_exact_message(self, pwd: str, msg: str) -> None: + with pytest.raises(WeakPassword) as exc: + assert_password_policy(pwd) + assert exc.value.message == msg + + def test_rejects_overlong(self) -> None: + with pytest.raises(WeakPassword): + assert_password_policy("Ab1!" + "a" * 600) + + +class TestRolePolicy: + def test_env_overrides_defaults(self) -> None: + assert policy_admin_emails("A@ump.edu.vn, b@umc.edu.vn ") == frozenset( + {"a@ump.edu.vn", "b@umc.edu.vn"} + ) + + def test_unset_uses_builtin_allowlist(self) -> None: + assert policy_admin_emails(None) == DEFAULT_POLICY_ADMIN_EMAILS + assert policy_admin_emails(" ") == DEFAULT_POLICY_ADMIN_EMAILS + + @pytest.mark.parametrize( + "email, has_row, from_policy, expected", + [ + ("a@ump.edu.vn", False, False, AdminReconcileAction.add_admin), + ("a@ump.edu.vn", True, True, AdminReconcileAction.mark_policy), + ("b@ump.edu.vn", True, True, AdminReconcileAction.remove_admin), + ("b@ump.edu.vn", True, False, AdminReconcileAction.none), # manual admin preserved + ("b@ump.edu.vn", False, False, AdminReconcileAction.none), + ], + ) + def test_reconcile_decision(self, email, has_row, from_policy, expected) -> None: + policy = frozenset({"a@ump.edu.vn"}) + assert reconcile_admin_action(email, policy, has_row, from_policy) == expected + + +class TestTokenClaims: + def test_claim_shape(self) -> None: + uid = uuid.uuid4() + now = datetime(2026, 6, 13, 12, 0, tzinfo=timezone.utc) + claims = build_access_token_claims(uid, "a@ump.edu.vn", ["admin", "viewer"], 3, now, 12) + assert claims["sub"] == str(uid) + assert claims["email"] == "a@ump.edu.vn" + assert claims["roles"] == ["admin", "viewer"] + assert claims["cv"] == 3 + assert claims["exp"] - claims["iat"] == 12 * 3600 + + +class TestUserAggregate: + def _user(self, **kw) -> User: + base = dict( + id=uuid.uuid4(), + email="a@ump.edu.vn", + full_name="Test", + password_hash="x", + email_verified=True, + is_active=True, + credential_version=0, + ) + base.update(kw) + return User(**base) + + def test_can_authenticate_requires_active(self) -> None: + assert self._user(is_active=True).can_authenticate() + assert not self._user(is_active=False).can_authenticate() + + def test_bump_credential_version(self) -> None: + u = self._user(credential_version=2) + u.bump_credential_version() + assert u.credential_version == 3 + + def test_identity_equality(self) -> None: + uid = uuid.uuid4() + assert self._user(id=uid, full_name="A") == self._user(id=uid, full_name="B") + + def test_role_enum_values(self) -> None: + assert {r.value for r in Role} == {"admin", "editor", "viewer"} diff --git a/be0/tests/test_imagehub_datasets.py b/be0/tests/test_imagehub_datasets.py new file mode 100644 index 0000000..efa9627 --- /dev/null +++ b/be0/tests/test_imagehub_datasets.py @@ -0,0 +1,407 @@ +"""Tests for the ImageHub dataset routes (milestone 1 walking skeleton). + +Pure-helper unit tests always run. The full integration test (create dataset → upload with +content-addressed dedup → version snapshot → owner/admin authz → audit) runs only when BOTH: + - INITIATIVE_DATABASE_URL points at PostgreSQL (asyncpg), and + - S3_ENDPOINT_URL is set (a reachable MinIO; the dev stack maps it to http://localhost:19000). + + export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives" + export S3_ENDPOINT_URL="http://localhost:19000" S3_ACCESS_KEY=minio_user S3_SECRET_KEY=minio_password \\ + S3_BUCKET_ATTACHMENTS=initiative-attachments S3_BUCKET_EXPORTS=initiative-exports \\ + S3_BUCKET_QUARANTINE=initiative-quarantine S3_PUBLIC_ENDPOINT_URL=http://localhost:19000 + cd be0 && python -m unittest tests.test_imagehub_datasets -v + +Prereq for the integration test: migration 017_imagehub_datasets.sql applied (compose init mount +or scripts/apply_initiative_migrations.py). +""" +from __future__ import annotations + +import io +import os +import unittest +import uuid + +_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql") +_RUN_S3 = bool(os.getenv("S3_ENDPOINT_URL", "").strip()) + +# Let the module (which imports src.minio.storage → S3Settings()) import even when not running +# against a real MinIO, so the pure-unit tests below can always run. These defaults match the +# dev stack's host-mapped MinIO; the integration test only fires when S3_ENDPOINT_URL was set. +os.environ.setdefault("S3_ENDPOINT_URL", "http://localhost:19000") +os.environ.setdefault("S3_ACCESS_KEY", "minio_user") +os.environ.setdefault("S3_SECRET_KEY", "minio_password") +os.environ.setdefault("S3_BUCKET_ATTACHMENTS", "initiative-attachments") +os.environ.setdefault("S3_BUCKET_EXPORTS", "initiative-exports") +os.environ.setdefault("S3_BUCKET_QUARANTINE", "initiative-quarantine") +os.environ.setdefault("S3_PUBLIC_ENDPOINT_URL", "http://localhost:19000") + + +class PureHelperTests(unittest.TestCase): + """No DB / no network — string + sniff helpers.""" + + def test_build_blob_key_is_content_addressed(self) -> None: + from src.minio.storage import S3Storage + + key = S3Storage.build_blob_key("AbCdEf0123456789") + self.assertEqual(key, "blobs/ab/cd/abcdef0123456789") + + def test_slugify_strips_diacritics_and_punct(self) -> None: + from src.imagehub_routes import _slugify + + self.assertEqual(_slugify("Bộ dữ liệu CT Ngực!! 2026"), "bo-du-lieu-ct-nguc-2026") + self.assertEqual(_slugify(""), "dataset") + + def test_safe_logical_path_basename_only(self) -> None: + from src.imagehub_routes import _safe_logical_path + + self.assertEqual(_safe_logical_path("/evil/../a b.dcm"), "a_b.dcm") + self.assertEqual(_safe_logical_path("C:\\scans\\series1.nii.gz"), "series1.nii.gz") + self.assertEqual(_safe_logical_path(""), "file") + + def test_safe_folder_path_preserves_dirs_rejects_traversal(self) -> None: + from src.imagehub_routes import _safe_folder_path + + # the directory is kept (basename dropped) so an uploaded tree round-trips + self.assertEqual(_safe_folder_path("imagesTr/ct_001.nii.gz"), "imagesTr") + self.assertEqual(_safe_folder_path("a/b/c/scan.nii.gz"), "a/b/c") + # no directory component → dataset root + self.assertEqual(_safe_folder_path("readme.txt"), "") + self.assertEqual(_safe_folder_path(""), "") + # leading slash + ".." traversal segments are stripped + self.assertEqual(_safe_folder_path("/evil/../x/y.dcm"), "evil/x") + # backslashes normalise to forward slashes + self.assertEqual(_safe_folder_path("labelsTr\\sub\\m.nii.gz"), "labelsTr/sub") + + def test_coerce_tags(self) -> None: + from src.imagehub_routes import _coerce_tags + + self.assertEqual(_coerce_tags(["CT", " MRI ", "", 7]), ["CT", "MRI", "7"]) + self.assertEqual(_coerce_tags("nope"), []) + + def test_coerce_label_map(self) -> None: + from src.imagehub_routes import _coerce_label_map + + # valid entries kept + trimmed; non-positive / non-int keys and empty/non-str values dropped + self.assertEqual( + _coerce_label_map( + {"1": " kidney ", "2": "tumor", "0": "bad", "-3": "bad", "x": "bad", "4": "", "+5": "bad", "1_0": "bad"} + ), + {"1": "kidney", "2": "tumor"}, + ) + # integer keys coerce to strings; non-dict input → {} + self.assertEqual(_coerce_label_map({1: "kidney"}), {"1": "kidney"}) + self.assertEqual(_coerce_label_map("nope"), {}) + self.assertEqual(_coerce_label_map(None), {}) + + def test_sniff_never_raises_on_non_imaging(self) -> None: + from src.imagehub_routes import _sniff_imaging_meta + + # plain bytes → {}; a .dcm name with junk must degrade to {} (never raise) + self.assertEqual(_sniff_imaging_meta("notes.txt", b"hello world", "text/plain"), {}) + self.assertIsInstance(_sniff_imaging_meta("x.dcm", b"DICM" + b"\x00" * 200, "application/dicom"), dict) + + +def _bearer(uid: uuid.UUID, roles: list[str]) -> str: + import jwt + + from src.auth_jwt import jwt_secret + + return "Bearer " + jwt.encode({"sub": str(uid), "roles": roles, "cv": 0}, jwt_secret(), algorithm="HS256") + + +def _upload(name: str, data: bytes, ctype: str = "application/octet-stream"): + from starlette.datastructures import Headers, UploadFile + + return UploadFile(io.BytesIO(data), filename=name, headers=Headers({"content-type": ctype})) + + +@unittest.skipUnless( + _RUN_DB and _RUN_S3, + "Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://… and S3_ENDPOINT_URL=… to run the integration test", +) +class ImagehubDatasetDbTests(unittest.IsolatedAsyncioTestCase): + """End-to-end: create → upload (content-addressed dedup) → version → owner/admin authz → audit.""" + + async def asyncSetUp(self) -> None: + from src.initiative_db import engine as eng + from src.minio.storage import storage + + await eng.dispose_engine() + await eng.init_engine() + try: + await storage.ensure_buckets_exist() + except Exception as exc: # MinIO not reachable → skip rather than error + self.skipTest(f"MinIO not reachable: {exc}") + self._user_ids: list[uuid.UUID] = [] + self._dataset_ids: list[uuid.UUID] = [] + + async def asyncTearDown(self) -> None: + from sqlalchemy import delete + + from src.initiative_db import engine as eng + from src.initiative_db.engine import get_session + from src.initiative_db.models import ImagehubDataset, User + + async with get_session() as session: + for did in self._dataset_ids: + await session.execute(delete(ImagehubDataset).where(ImagehubDataset.id == did)) + for uid in self._user_ids: + await session.execute(delete(User).where(User.id == uid)) + await session.commit() + await eng.dispose_engine() + + async def _seed_user(self, *, admin: bool = False) -> uuid.UUID: + from src.initiative_db.engine import get_session + from src.initiative_db.models import User + + uid = uuid.uuid4() + async with get_session() as session: + session.add( + User( + id=uid, + email=f"ih-{uid.hex[:10]}@ump.edu.vn", + password_hash="x", + full_name=("Quản trị" if admin else "Nhà nghiên cứu") + " Test", + ) + ) + await session.commit() + self._user_ids.append(uid) + return uid + + async def test_dataset_research_project_link(self) -> None: + """A dataset can be created linked to a research project ("workspace"); the list can be + filtered to that project; bad/foreign project ids are rejected (migration 024).""" + from fastapi import HTTPException + + from src.imagehub_routes import DatasetCreateIn, create_dataset, list_datasets + from src.initiative_db.engine import get_session + from src.initiative_db.models import ResearchProject + + owner = await self._seed_user() + owner_tok = _bearer(owner, ["viewer"]) + + # seed a research project ("workspace") owned by the user (cascade-cleaned with the user) + proj_id = uuid.uuid4() + async with get_session() as session: + session.add(ResearchProject(id=proj_id, owner_user_id=owner, title="Đề tài thử nghiệm")) + await session.commit() + + # create a dataset linked to the project → the link is persisted + ds = await create_dataset( + DatasetCreateIn(name="Bộ dữ liệu thuộc đề tài", researchProjectId=str(proj_id)), + owner_tok, + ) + self._dataset_ids.append(uuid.UUID(ds.id)) + self.assertEqual(ds.researchProjectId, str(proj_id)) + + # a standalone dataset (no project) is still allowed and stays unlinked + ds2 = await create_dataset(DatasetCreateIn(name="Bộ dữ liệu độc lập"), owner_tok) + self._dataset_ids.append(uuid.UUID(ds2.id)) + self.assertIsNone(ds2.researchProjectId) + + # a non-existent project id is rejected (422) + with self.assertRaises(HTTPException) as ctx: + await create_dataset( + DatasetCreateIn(name="x", researchProjectId=str(uuid.uuid4())), owner_tok + ) + self.assertEqual(ctx.exception.status_code, 422) + + # ?projectId= filters the list to that project only (3rd positional arg = projectId) + in_proj = await list_datasets("mine", owner_tok, str(proj_id)) + ids_in_proj = [d.id for d in in_proj] + self.assertIn(ds.id, ids_in_proj) + self.assertNotIn(ds2.id, ids_in_proj) + self.assertTrue(all(d.researchProjectId == str(proj_id) for d in in_proj)) + + async def test_update_label_map_sanitizes_and_persists(self) -> None: + """update_dataset accepts a per-value label map, sanitizes it, and round-trips it (migration 027).""" + from src.imagehub_routes import ( + DatasetCreateIn, + DatasetUpdateIn, + create_dataset, + get_dataset, + update_dataset, + ) + + owner = await self._seed_user() + owner_tok = _bearer(owner, ["viewer"]) + + ds = await create_dataset(DatasetCreateIn(name="KiTS labels"), owner_tok) + self._dataset_ids.append(uuid.UUID(ds.id)) + self.assertEqual(ds.labelMap, {}) # empty by default + + # garbage keys/values are dropped; valid ones trimmed + kept + updated = await update_dataset( + ds.id, + DatasetUpdateIn(labelMap={"1": "kidney", "2": "tumor", "3": "cyst", "0": "bad", "x": "bad"}), + owner_tok, + ) + self.assertEqual(updated.labelMap, {"1": "kidney", "2": "tumor", "3": "cyst"}) + + # persisted: a fresh read returns the same map + fresh = await get_dataset(ds.id, owner_tok) + self.assertEqual(fresh.labelMap, {"1": "kidney", "2": "tumor", "3": "cyst"}) + + async def test_review_persists_decision_and_stats(self) -> None: + """review_task writes a structured review event; review-stats tallies it per reviewer (025).""" + from sqlalchemy import select + + from src.imagehub_routes import ReviewIn, review_stats, review_task + from src.initiative_db.engine import get_session + from src.initiative_db.models import ( + ImagehubBlob, + ImagehubDataset, + ImagehubDatasetFile, + ImagehubDatasetStage, + ImagehubTask, + ImagehubTaskReviewEvent, + ) + + owner = await self._seed_user() + owner_tok = _bearer(owner, ["viewer"]) + + # build the minimal chain (no upload): dataset + a Review stage + a file + a task already + # advanced to that Review stage, assigned to the owner. + ds_id, stage_id, file_id, task_id = (uuid.uuid4() for _ in range(4)) + sha = uuid.uuid4().hex + async with get_session() as session: + session.add(ImagehubDataset(id=ds_id, owner_user_id=owner, name="Review demo")) + session.add( + ImagehubDatasetStage(id=stage_id, dataset_id=ds_id, name="Rà soát 1", kind="review", seq=1) + ) + session.add(ImagehubBlob(sha256=sha, size_bytes=1)) + session.add( + ImagehubDatasetFile(id=file_id, dataset_id=ds_id, logical_path="ct.nii.gz", blob_sha256=sha) + ) + session.add( + ImagehubTask( + id=task_id, dataset_id=ds_id, dataset_file_id=file_id, name="ct.nii.gz", + current_stage_id=stage_id, pipeline_state="inReview", queue_status="assigned", + assignee_user_id=owner, + ) + ) + await session.commit() + self._dataset_ids.append(ds_id) # cascade-cleans stage/file/task/events in teardown + + # accept the review → a structured event is persisted (decision + reviewer + stage + note) + await review_task(str(ds_id), str(task_id), ReviewIn(decision="accept", note="Đạt"), owner_tok) + async with get_session() as session: + evs = ( + await session.execute( + select(ImagehubTaskReviewEvent).where(ImagehubTaskReviewEvent.task_id == task_id) + ) + ).scalars().all() + self.assertEqual(len(evs), 1) + self.assertEqual(evs[0].decision, "accept") + self.assertEqual(evs[0].reviewer_user_id, owner) + self.assertEqual(evs[0].stage_id, stage_id) + self.assertEqual(evs[0].note, "Đạt") + + # the stats endpoint tallies it for the reviewer (authorization is the LAST positional arg) + stats = await review_stats(str(ds_id), str(owner), 30, owner_tok) + self.assertEqual(stats.accepted, 1) + self.assertEqual(stats.rejected, 0) + # a foreign reviewer has no tally + empty = await review_stats(str(ds_id), str(uuid.uuid4()), 30, owner_tok) + self.assertEqual(empty.accepted, 0) + + async def test_create_upload_dedup_version_authz_audit(self) -> None: + from fastapi import HTTPException + from sqlalchemy import func, select + + from src.imagehub_routes import ( + DatasetCreateIn, + VersionCreateIn, + create_dataset, + create_version, + get_dataset, + list_audit, + list_datasets, + list_files, + list_versions, + upload_files, + ) + from src.initiative_db.engine import get_session + from src.initiative_db.models import ImagehubBlob, ImagehubDatasetFile + + owner = await self._seed_user() + admin = await self._seed_user(admin=True) + other = await self._seed_user() + owner_tok = _bearer(owner, ["viewer"]) + admin_tok = _bearer(admin, ["admin"]) + other_tok = _bearer(other, ["viewer"]) + + # create + ds = await create_dataset( + DatasetCreateIn(name="CT Ngực thử nghiệm", description="demo", modalityTags=["CT"]), + owner_tok, + ) + self._dataset_ids.append(uuid.UUID(ds.id)) + self.assertEqual(ds.name, "CT Ngực thử nghiệm") + self.assertEqual(ds.modalityTags, ["CT"]) + self.assertEqual(ds.fileCount, 0) + + # upload the SAME content under two names → content-addressed dedup + blob_bytes = uuid.uuid4().bytes * 64 # unique per run + res = await upload_files( + ds.id, [_upload("scan_a.bin", blob_bytes), _upload("scan_b.bin", blob_bytes)], owner_tok + ) + self.assertTrue(res["ok"]) + shas = {f["sha256"] for f in res["files"]} + self.assertEqual(len(shas), 1, "same content must hash to one sha256") + deduped_flags = sorted(f["deduped"] for f in res["files"]) + self.assertEqual(deduped_flags, [False, True], "first stores the blob, second dedups") + + # DB: exactly one blob row for that sha256, two file rows for the dataset + sha = next(iter(shas)) + async with get_session() as session: + blob_count = ( + await session.execute( + select(func.count()).select_from(ImagehubBlob).where(ImagehubBlob.sha256 == sha) + ) + ).scalar_one() + file_count = ( + await session.execute( + select(func.count()) + .select_from(ImagehubDatasetFile) + .where(ImagehubDatasetFile.dataset_id == uuid.UUID(ds.id)) + ) + ).scalar_one() + self.assertEqual(blob_count, 1) + self.assertEqual(file_count, 2) + + # browse files (each carries a presigned download URL) + files = await list_files(ds.id, owner_tok) + self.assertEqual(len(files), 2) + self.assertTrue(all(f.downloadUrl for f in files)) + + # authz: a non-admin other user can't see or read it + owner_list = await list_datasets("mine", owner_tok) + self.assertIn(ds.id, [d.id for d in owner_list]) + other_list = await list_datasets("all", other_tok) # non-admin: scope=all ignored + self.assertNotIn(ds.id, [d.id for d in other_list]) + with self.assertRaises(HTTPException) as ctx: + await get_dataset(ds.id, other_tok) + self.assertEqual(ctx.exception.status_code, 404) + + # admin sees every dataset (the clinical data repository) + admin_list = await list_datasets("all", admin_tok) + self.assertIn(ds.id, [d.id for d in admin_list]) + + # version snapshot freezes the 2-file manifest + ver = await create_version(ds.id, VersionCreateIn(message="phiên bản đầu"), owner_tok) + self.assertEqual(ver.seq, 1) + self.assertEqual(ver.fileCount, 2) + versions = await list_versions(ds.id, owner_tok) + self.assertEqual(len(versions), 1) + + # audit trail recorded each mutation + audit = await list_audit(ds.id, owner_tok) + actions = [a.action for a in audit] + self.assertIn("Tạo bộ dữ liệu", actions) + self.assertIn("Tải tệp lên", actions) + self.assertIn("Tạo phiên bản", actions) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_imagehub_segmentation.py b/be0/tests/test_imagehub_segmentation.py new file mode 100644 index 0000000..1134817 --- /dev/null +++ b/be0/tests/test_imagehub_segmentation.py @@ -0,0 +1,157 @@ +"""Unit tests for the ImageHub segmentation-linking domain service. + +The service was built with INJECTED infrastructure (put_blob / sniff_meta / safe_name) +precisely so the domain rules can be exercised with fakes — no Postgres, no MinIO. +Covers: parent validation (bad uuid / not-found / not-an-image), the mask path +namespacing, organ-label fallback, and the empty-payload guard. +""" +from __future__ import annotations + +import unittest +import uuid + +from src.imagehub_segmentation import MaskUpload, SegmentationError, SegmentationService +from src.initiative_db.models import ImagehubDataset, ImagehubDatasetFile + + +class _FakeResult: + def __init__(self, value): + self._value = value + + def scalar_one_or_none(self): + return self._value + + +class _FakeSession: + """Minimal AsyncSession stand-in. The 1st execute() resolves the parent file; + every later execute() resolves the 'existing mask at this path' lookup (None = + create new). Records added rows.""" + + def __init__(self, parent=None, existing=None): + self._parent = parent + self._existing = existing + self.added: list = [] + self.exec_calls = 0 + self.flushes = 0 + + async def execute(self, _stmt): + self.exec_calls += 1 + return _FakeResult(self._parent if self.exec_calls == 1 else self._existing) + + async def get(self, _model, _key): + return None # blob absent → service will add it + + def add(self, obj): + self.added.append(obj) + + async def flush(self): + self.flushes += 1 + + +def _safe_name(name): + base = (name or "").strip().replace("\\", "/").rsplit("/", 1)[-1] + return base or "file" + + +async def _put_blob(data, media_type): + return { + "sha256": "deadbeef" + str(len(data)), + "size": len(data), + "bucket": "imagehub-blobs", + "key": "blobs/de/ad/deadbeef", + "media_type": media_type or "application/octet-stream", + "deduped": False, + } + + +def _sniff(_filename, _data, _media): + return {"format": "nifti", "shape": [4, 4, 4]} + + +def _service(session): + return SegmentationService(session, put_blob=_put_blob, sniff_meta=_sniff, safe_name=_safe_name) + + +def _image_parent(dataset_id, logical_path="ct.nii.gz"): + return ImagehubDatasetFile( + id=uuid.uuid4(), dataset_id=dataset_id, logical_path=logical_path, file_kind="image" + ) + + +def _mask(filename="liver.nii.gz", organ="Gan", data=b"xyz"): + return MaskUpload(filename=filename, data=data, media_type="application/gzip", organ_label=organ) + + +class TestMaskLogicalPath(unittest.TestCase): + def test_namespaces_under_parent_stem(self): + svc = _service(_FakeSession()) + self.assertEqual(svc._mask_logical_path("ct.nii.gz", "liver.nii.gz"), "ct.seg/liver.nii.gz") + self.assertEqual(svc._mask_logical_path("scan.nii", "a.nii.gz"), "scan.seg/a.nii.gz") + self.assertEqual(svc._mask_logical_path("study.dcm", "k.nii.gz"), "study.seg/k.nii.gz") + + def test_mask_path_cannot_collide_with_a_real_image_path(self): + # A real file's logical_path never contains '/', a mask path always does. + svc = _service(_FakeSession()) + self.assertIn("/", svc._mask_logical_path("ct.nii.gz", "ct.nii.gz")) + + +class TestLinkMasks(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.ds = ImagehubDataset(id=uuid.uuid4(), owner_user_id=uuid.uuid4()) + self.uid = uuid.uuid4() + + async def test_happy_path_links_mask_to_image(self): + parent = _image_parent(self.ds.id) + sess = _FakeSession(parent=parent, existing=None) + rows = await _service(sess).link_masks(self.ds, str(parent.id), [_mask()], self.uid) + self.assertEqual(len(rows), 1) + r = rows[0] + self.assertEqual(r.file_kind, "segmentation") + self.assertEqual(r.parent_file_id, parent.id) + self.assertEqual(r.organ_label, "Gan") + self.assertEqual(r.logical_path, "ct.seg/liver.nii.gz") + self.assertEqual(r.dataset_id, self.ds.id) + + async def test_organ_label_falls_back_to_filename(self): + parent = _image_parent(self.ds.id) + sess = _FakeSession(parent=parent) + rows = await _service(sess).link_masks(self.ds, str(parent.id), [_mask(organ=" ")], self.uid) + self.assertEqual(rows[0].organ_label, "liver.nii.gz") + + async def test_bad_parent_uuid_is_404(self): + with self.assertRaises(SegmentationError) as ctx: + await _service(_FakeSession()).link_masks(self.ds, "not-a-uuid", [_mask()], self.uid) + self.assertEqual(ctx.exception.status, 404) + + async def test_parent_not_found_is_404(self): + sess = _FakeSession(parent=None) + with self.assertRaises(SegmentationError) as ctx: + await _service(sess).link_masks(self.ds, str(uuid.uuid4()), [_mask()], self.uid) + self.assertEqual(ctx.exception.status, 404) + + async def test_attaching_to_a_mask_is_422(self): + mask_parent = ImagehubDatasetFile( + id=uuid.uuid4(), dataset_id=self.ds.id, logical_path="ct.seg/x.nii.gz", file_kind="segmentation" + ) + sess = _FakeSession(parent=mask_parent) + with self.assertRaises(SegmentationError) as ctx: + await _service(sess).link_masks(self.ds, str(mask_parent.id), [_mask()], self.uid) + self.assertEqual(ctx.exception.status, 422) + + async def test_empty_payload_is_422(self): + parent = _image_parent(self.ds.id) + sess = _FakeSession(parent=parent) + with self.assertRaises(SegmentationError) as ctx: + await _service(sess).link_masks(self.ds, str(parent.id), [], self.uid) + self.assertEqual(ctx.exception.status, 422) + + async def test_all_empty_byte_masks_is_422(self): + parent = _image_parent(self.ds.id) + sess = _FakeSession(parent=parent) + with self.assertRaises(SegmentationError) as ctx: + await _service(sess).link_masks(self.ds, str(parent.id), [_mask(data=b"")], self.uid) + self.assertEqual(ctx.exception.status, 422) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_imagehub_tasks.py b/be0/tests/test_imagehub_tasks.py new file mode 100644 index 0000000..8d731ee --- /dev/null +++ b/be0/tests/test_imagehub_tasks.py @@ -0,0 +1,157 @@ +"""Unit tests for the ImageHub task-pipeline domain (project-workflow §3/§4 transitions). + +The pipeline service is pure (plain functions over ``StageInfo`` lists — no Postgres, no FastAPI), +so the whole state machine is exercised here with plain data. The thin HTTP wrappers in +``imagehub_routes`` are verified live; this file owns the transition rules. + +Covers: stage ordering, the new-task start state, TP1 finalize (advance / -> Ground Truth), +TP2 accept + accept-with-corrections, TP3 reject (-> first stage), the guards (wrong-state finalize/ +review, bad decision), and RS1 reference-standard (Ground-Truth-only). +""" +from __future__ import annotations + +import unittest + +from src.imagehub_task_pipeline import ( + StageInfo, + TaskPipelineError, + compute_finalize, + compute_review, + first_stage, + initial_transition, + order_stages, + stage_after, + state_for_stage, + validate_set_reference, +) + +# A canonical pipeline: Label(0) -> Review_1(1) -> Review_2(2). Deliberately unsorted in the list +# so order_stages / first_stage / stage_after are actually exercised, not given pre-sorted input. +LABEL = StageInfo(id="s-label", kind="label", seq=0) +REV1 = StageInfo(id="s-rev1", kind="review", seq=1) +REV2 = StageInfo(id="s-rev2", kind="review", seq=2) +PIPELINE = [REV2, LABEL, REV1] # out of order on purpose + + +class StageOrderingTests(unittest.TestCase): + def test_order_stages_sorts_by_seq(self): + self.assertEqual([s.id for s in order_stages(PIPELINE)], ["s-label", "s-rev1", "s-rev2"]) + + def test_first_stage_is_lowest_seq(self): + self.assertEqual(first_stage(PIPELINE).id, "s-label") + + def test_first_stage_empty_raises_409(self): + with self.assertRaises(TaskPipelineError) as ctx: + first_stage([]) + self.assertEqual(ctx.exception.status, 409) + + def test_stage_after_returns_next(self): + self.assertEqual(stage_after(PIPELINE, "s-label").id, "s-rev1") + self.assertEqual(stage_after(PIPELINE, "s-rev1").id, "s-rev2") + + def test_stage_after_last_is_none(self): + self.assertIsNone(stage_after(PIPELINE, "s-rev2")) + + def test_stage_after_unknown_or_none_is_none(self): + self.assertIsNone(stage_after(PIPELINE, "nope")) + self.assertIsNone(stage_after(PIPELINE, None)) + + def test_state_for_stage(self): + self.assertEqual(state_for_stage(LABEL), "inLabel") + self.assertEqual(state_for_stage(REV1), "inReview") + + +class InitialTransitionTests(unittest.TestCase): + def test_new_task_starts_inlabel_at_first_stage(self): + t = initial_transition(PIPELINE) + self.assertEqual(t.pipeline_state, "inLabel") + self.assertEqual(t.current_stage_id, "s-label") + self.assertEqual(t.queue_status, "assigned") + + def test_new_task_starts_inreview_when_first_stage_is_review(self): + t = initial_transition([REV1]) + self.assertEqual(t.pipeline_state, "inReview") + self.assertEqual(t.current_stage_id, "s-rev1") + + +class FinalizeTests(unittest.TestCase): + def test_finalize_label_advances_to_first_review(self): + t = compute_finalize("inLabel", "s-label", PIPELINE) + self.assertEqual(t.pipeline_state, "inReview") + self.assertEqual(t.current_stage_id, "s-rev1") + self.assertEqual(t.queue_status, "assigned") + + def test_finalize_advances_label_to_next_label(self): + # PreLabel(0,label) -> Label(1,label): finalizing the first stays inLabel at the next. + prelabel = StageInfo(id="s-pre", kind="label", seq=0) + label = StageInfo(id="s-lab", kind="label", seq=1) + t = compute_finalize("inLabel", "s-pre", [prelabel, label]) + self.assertEqual(t.pipeline_state, "inLabel") + self.assertEqual(t.current_stage_id, "s-lab") + + def test_finalize_single_label_goes_to_ground_truth(self): + t = compute_finalize("inLabel", "s-label", [LABEL]) + self.assertEqual(t.pipeline_state, "groundTruth") + self.assertIsNone(t.current_stage_id) + + def test_finalize_when_not_inlabel_raises_409(self): + with self.assertRaises(TaskPipelineError) as ctx: + compute_finalize("inReview", "s-rev1", PIPELINE) + self.assertEqual(ctx.exception.status, 409) + + def test_finalize_when_groundtruth_raises_409(self): + with self.assertRaises(TaskPipelineError): + compute_finalize("groundTruth", None, PIPELINE) + + +class ReviewTests(unittest.TestCase): + def test_accept_advances_to_next_review(self): + t = compute_review("inReview", "s-rev1", PIPELINE, "accept") + self.assertEqual(t.pipeline_state, "inReview") + self.assertEqual(t.current_stage_id, "s-rev2") + + def test_accept_on_last_review_goes_to_ground_truth(self): + t = compute_review("inReview", "s-rev2", PIPELINE, "accept") + self.assertEqual(t.pipeline_state, "groundTruth") + self.assertIsNone(t.current_stage_id) + + def test_accept_with_corrections_advances(self): + t = compute_review("inReview", "s-rev1", PIPELINE, "acceptWithCorrections") + self.assertEqual(t.pipeline_state, "inReview") + self.assertEqual(t.current_stage_id, "s-rev2") + + def test_reject_returns_to_first_stage(self): + t = compute_review("inReview", "s-rev2", PIPELINE, "reject") + self.assertEqual(t.pipeline_state, "inLabel") + self.assertEqual(t.current_stage_id, "s-label") + self.assertEqual(t.queue_status, "assigned") + + def test_review_when_not_inreview_raises_409(self): + with self.assertRaises(TaskPipelineError) as ctx: + compute_review("inLabel", "s-label", PIPELINE, "accept") + self.assertEqual(ctx.exception.status, 409) + + def test_review_invalid_decision_raises_422(self): + with self.assertRaises(TaskPipelineError) as ctx: + compute_review("inReview", "s-rev1", PIPELINE, "maybe") + self.assertEqual(ctx.exception.status, 422) + + +class ReferenceStandardTests(unittest.TestCase): + def test_set_reference_ok_on_ground_truth(self): + # Should not raise. + validate_set_reference("groundTruth", True) + + def test_set_reference_blocked_off_ground_truth(self): + with self.assertRaises(TaskPipelineError) as ctx: + validate_set_reference("inLabel", True) + self.assertEqual(ctx.exception.status, 409) + + def test_unset_reference_allowed_in_any_state(self): + # Removing the flag is always fine, even mid-pipeline. + validate_set_reference("inLabel", False) + validate_set_reference("inReview", False) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_official_to_data_blank_ban_cam_ket.py b/be0/tests/test_official_to_data_blank_ban_cam_ket.py new file mode 100644 index 0000000..07b9879 --- /dev/null +++ b/be0/tests/test_official_to_data_blank_ban_cam_ket.py @@ -0,0 +1,71 @@ +"""`BẢN CAM KẾT` → `ban_cam_ket` matches `bieu_mau_sang_kien_template.json` keys (fe0).""" + +import copy +import json +from pathlib import Path + +import pytest + +from src.be01.official_to_data_blank import official_to_data_blank + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_TEMPLATE_PATH = _REPO_ROOT / "fe0" / "public" / "assets" / "bieu_mau_sang_kien_template.json" + + +def _minimal_official_with_bck() -> dict: + if not _TEMPLATE_PATH.is_file(): + pytest.skip("fe0 template JSON not found at %s" % _TEMPLATE_PATH) + raw = json.loads(_TEMPLATE_PATH.read_text(encoding="utf-8")) + official = {"BẢN CAM KẾT": copy.deepcopy(raw["BẢN CAM KẾT"])} + bck = official["BẢN CAM KẾT"] + bck["Ngày ký"] = {"Ngày": "15", "Tháng": "4", "Năm": "2026"} + i1 = bck["I. THÔNG TIN CHỦ THỂ CAM KẾT"] + i1["Tác giả đăng ký sáng kiến"] = "Nguyễn Văn A" + i1["CCCD/Hộ chiếu số"] = "079012345678" + i1["Đơn vị"] = "Khoa X" + i1["Tên Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH"] = "Bài báo thử nghiệm" + i1["Năm xét công nhận sáng kiến"] = "2026" + vt = i1["Vai trò đối với bài báo (☑ vào ô tương ứng)"] + vt["Tác giả chính Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH"] = True + vt["Đồng tác giả Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH"] = False + ii = bck["II. CAM KẾT NỘI DUNG (☑ vào ô tương ứng)"] + q = ii["1. Quyền sở hữu đối với bài báo trong nước/quốc tế"] + k1 = ( + "Tôi là chủ sở hữu hợp pháp của bài báo hoặc được chủ sở hữu/đồng chủ sở hữu đồng ý cho sử dụng bài báo có tên nêu trên làm sản phẩm đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD" + ) + k2 = ( + "Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH: chủ sở hữu bài báo (cơ quan) đồng ý cho tác giả/nhóm tác giả sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD" + ) + q[k1] = True + q[k2] = False + kd = "Tất cả đồng tác giả đã biết, đồng ý và ký xác nhận cho phép Tác giả đăng ký sáng kiến được sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD" + ii["2. Đồng thuận của đồng tác giả bài báo trong nước/quốc tế"][kd] = True + ku = ( + "Cá nhân đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD đối với bài báo trong nước/quốc tế cam kết bài báo không thuộc 'Tạp chí săn mồi'. Tôi xin chịu trách nhiệm kiểm tra, đối chiếu và cung cấp bằng chứng khi được yêu cầu" + ) + ii["3. Cam kết bài báo trong nước/quốc tế uy tín"][ku] = True + kt = ( + "Tôi cam kết rằng việc sử dụng bài báo đăng ký xét công nhận sáng kiến tại ĐHYD sẽ không gây tranh chấp về: quyền tác giả/quyền liên quan, quyền sở hữu công nghiệp, tiết lộ bí mật kinh doanh, vi phạm bảo mật dữ liệu của bất kỳ bên thứ ba nào. Tôi chịu trách nhiệm trước pháp luật về tính trung thực, hợp pháp của hồ sơ" + ) + ii["4. Tuân thủ pháp luật sở hữu trí tuệ"][kt] = True + bck["Người cam kết (Ký tên, ghi rõ họ tên)"] = "Nguyễn Văn A" + return official + + +def test_ban_cam_ket_from_numbered_template_keys(): + out = official_to_data_blank(_minimal_official_with_bck()) + b = out["ban_cam_ket"] + assert b["tac_gia_dang_ky"] == "Nguyễn Văn A" + assert b["cccd"] == "079012345678" + assert b["don_vi"] == "Khoa X" + assert b["ten_bai_bao"] == "Bài báo thử nghiệm" + assert b["nam_xet"] == "2026" + assert b["ngay_ky"] == {"ngay": "15", "thang": "4", "nam": "2026"} + assert b["vai_tro"]["tac_gia_chinh"] is True + assert b["vai_tro"]["dong_tac_gia"] is False + assert b["cam_ket"]["quyen_so_huu_1"] is True + assert b["cam_ket"]["quyen_so_huu_2"] is False + assert b["cam_ket"]["dong_thuan"] is True + assert b["cam_ket"]["bai_bao_uy_tin"] is True + assert b["cam_ket"]["tuan_thu_phap_luat"] is True + assert b["nguoi_cam_ket"] == "Nguyễn Văn A" diff --git a/be0/tests/test_official_to_data_blank_don_vi.py b/be0/tests/test_official_to_data_blank_don_vi.py new file mode 100644 index 0000000..ee4e313 --- /dev/null +++ b/be0/tests/test_official_to_data_blank_don_vi.py @@ -0,0 +1,72 @@ +"""Cover / Mẫu 02 don_vi resolution for legacy officialBieuMau JSON. + +Run: cd be0 && python -m unittest tests.test_official_to_data_blank_don_vi -v +""" + +from __future__ import annotations + +import unittest + +from src.be01.official_to_data_blank import _resolve_don_vi_cong_tac, official_to_data_blank + + +class OfficialToDataBlankDonViTests(unittest.TestCase): + def test_resolve_prefers_explicit_cover(self) -> None: + official = { + "TRANG BÌA": {"Đơn vị công tác": " Phòng A "}, + "MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN": { + "Đơn vị": "", + "Danh sách tác giả": [ + {"STT": "1", "Họ và tên": "X", "Nơi công tác": "Phòng B"}, + ], + }, + } + self.assertEqual(_resolve_don_vi_cong_tac(official), "Phòng A") + + def test_resolve_falls_back_to_first_author_workplace(self) -> None: + official = { + "TRANG BÌA": {"Đơn vị công tác": ""}, + "MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN": { + "Đơn vị": "", + "Danh sách tác giả": [ + { + "STT": "1", + "Họ và tên": "Nguyễn Văn A", + "Nơi công tác": "Trường Y", + }, + ], + }, + } + self.assertEqual(_resolve_don_vi_cong_tac(official), "Trường Y") + + def test_official_to_data_blank_sets_trang_bia_and_mau02(self) -> None: + official = { + "TRANG BÌA": { + "Tên sáng kiến (Tiếng Việt)": "SK1", + "Tác giả/nhóm tác giả sáng kiến": "A", + "Đơn vị công tác": "", + "Thông tin liên hệ (Điện thoại, Email)": "", + "Năm": "2026", + }, + "MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN": { + "Đơn vị": "", + "Danh sách tác giả": [ + { + "STT": "1", + "Họ và tên": "A", + "Ngày tháng năm sinh": "", + "Nơi công tác": "Khoa X", + "Chức danh": "", + "Trình độ chuyên môn": "", + "Tỷ lệ (%) đóng góp vào việc tạo ra sáng kiến": "100", + }, + ], + }, + } + ctx = official_to_data_blank(official) + self.assertEqual(ctx["trang_bia"]["don_vi"], "Khoa X") + self.assertEqual(ctx["mau_02"]["don_vi"], "Khoa X") + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_registration_otp.py b/be0/tests/test_registration_otp.py new file mode 100644 index 0000000..bbd05ec --- /dev/null +++ b/be0/tests/test_registration_otp.py @@ -0,0 +1,135 @@ +""" +Registration OTP API (PostgreSQL + mocked outbound mail). + + export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives" + # Ensure migrations through 014_registration_otp.sql are applied. + cd be0 && python -m unittest tests.test_registration_otp -v + +Optional live login smoke (**credentials must never be committed**): + + export TEST_LIVE_AUTH_EMAIL="nltanh@ump.edu.vn" + export TEST_LIVE_AUTH_PASSWORD='' + cd be0 && python -m unittest tests.test_registration_otp.LiveAuthLoginOptionalTests.test_login_with_env_credentials -v +""" + +from __future__ import annotations + +import os +import unittest +import uuid +from unittest.mock import patch + +from sqlalchemy import select + +from tests.auth_register_staff_fixture import register_staff_fields + +_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql") + +_TEST_PASSWORD = "Testpass1!" + + +@unittest.skipUnless( + _RUN_DB, + "Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives", +) +class RegistrationOtpApiTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + await eng.init_engine() + + async def asyncTearDown(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + + async def _delete_user(self, email: str) -> None: + from src.initiative_db.engine import get_session + from src.initiative_db.models import User + + async with get_session() as session: + user = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + if user is not None: + await session.delete(user) + + async def test_register_verify_otp_then_login(self) -> None: + from fastapi.testclient import TestClient + + from main import app + + email = f"otp-{uuid.uuid4().hex[:12]}@ump.edu.vn" + captured: list[str] = [] + + async def grab(_to: str, raw: str) -> None: + captured.append(raw) + + try: + with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}): + with patch("src.auth_api.deliver_registration_otp_email", side_effect=grab): + with TestClient(app) as client: + r = client.post( + "/api/v1/auth/register", + json={ + "fullName": "OTP Tester", + "email": email, + "password": _TEST_PASSWORD, + "passwordConfirm": _TEST_PASSWORD, + **register_staff_fields(), + }, + ) + self.assertEqual(r.status_code, 200, r.text) + self.assertTrue(captured) + otp = captured[0] + self.assertEqual(len(otp), 6) + self.assertTrue(otp.isdigit()) + + with TestClient(app) as client: + blocked = client.post( + "/api/v1/auth/login", + json={"email": email, "password": _TEST_PASSWORD}, + ) + self.assertEqual(blocked.status_code, 403, blocked.text) + + bad = client.post("/api/v1/auth/verify-otp", json={"email": email, "otp": "000000"}) + self.assertEqual(bad.status_code, 400, bad.text) + + ok = client.post("/api/v1/auth/verify-otp", json={"email": email, "otp": otp}) + self.assertEqual(ok.status_code, 200, ok.text) + + lg = client.post( + "/api/v1/auth/login", + json={"email": email, "password": _TEST_PASSWORD}, + ) + self.assertEqual(lg.status_code, 200, lg.text) + self.assertTrue(lg.json().get("accessToken")) + finally: + await self._delete_user(email) + + +_LIVE_PW = os.getenv("TEST_LIVE_AUTH_PASSWORD", "").strip() +_LIVE_EMAIL = os.getenv("TEST_LIVE_AUTH_EMAIL", "nltanh@ump.edu.vn").strip().lower() + + +@unittest.skipUnless( + _RUN_DB and bool(_LIVE_PW), + "Set INITIATIVE_DATABASE_URL and TEST_LIVE_AUTH_PASSWORD for optional login smoke " + "(use TEST_LIVE_AUTH_EMAIL to override default nltanh@ump.edu.vn).", +) +class LiveAuthLoginOptionalTests(unittest.TestCase): + """Uses secrets from env only — passwords must never appear in source control.""" + + def test_login_with_env_credentials(self) -> None: + from fastapi.testclient import TestClient + + from main import app + + with TestClient(app) as client: + r = client.post( + "/api/v1/auth/login", + json={"email": _LIVE_EMAIL, "password": _LIVE_PW}, + ) + self.assertEqual(r.status_code, 200, r.text) + self.assertTrue(r.json().get("accessToken")) diff --git a/be0/tests/test_registration_stack_alignment.py b/be0/tests/test_registration_stack_alignment.py new file mode 100644 index 0000000..e0af092 --- /dev/null +++ b/be0/tests/test_registration_stack_alignment.py @@ -0,0 +1,309 @@ +""" +Registration stack alignment: frontend-shaped payloads, API, PostgreSQL, and MinIO. + +Mirrors the JSON body produced by fe0 ``src/lib/auth-service.ts`` (``register()``); +when changing that client, update this test's payload builder if needed. + +Flow: + 1. POST /api/v1/auth/register — expect verification flow (no JWT); DB rows match payload. + 2. Login blocked with 403 until verify-otp. + 3. POST verify-otp — DB email_verified, OTP row consumed. + 4. Login — JWT issued. + 5. Minimal draft save + evidence upload — MinIO attachments bucket contains object at ``storageKey`` (``head_object``). + +Run (same env style as test_backup_e2e): + + export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives" + export S3_ENDPOINT_URL="http://127.0.0.1:19000" + export S3_PUBLIC_ENDPOINT_URL="http://127.0.0.1:19000" + export S3_ACCESS_KEY="minio_user" + export S3_SECRET_KEY="minio_password" + export S3_BUCKET_ATTACHMENTS="initiative-attachments" + export S3_BUCKET_EXPORTS="initiative-exports" + export S3_BUCKET_QUARANTINE="initiative-quarantine" + export REGISTRATION_STACK_TEST=1 + cd be0 && python -m unittest tests.test_registration_stack_alignment -v +""" + +from __future__ import annotations + +import hashlib +import io +import os +import unittest +import uuid +from unittest.mock import patch + +from sqlalchemy import select + +from tests.auth_register_staff_fixture import register_staff_fields +from tests.fixtures.minimal_submit_bundle import minimal_tabs_bundle + +_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql") +_S3_KEYS = ( + "S3_ENDPOINT_URL", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_BUCKET_ATTACHMENTS", + "S3_BUCKET_EXPORTS", + "S3_BUCKET_QUARANTINE", +) +_HAS_S3 = all(os.getenv(k, "").strip() for k in _S3_KEYS) +_RUN_ALIGN = os.getenv("REGISTRATION_STACK_TEST", "").strip().lower() in ("1", "true", "yes") + +_TEST_PASSWORD = "Testpass1!" +_MIN_PDF = ( + b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n" + b"0" * 120 +) + + +def _fe0_style_register_json( + *, + email: str, + full_name: str, + password: str, + staff: dict[str, str], +) -> dict: + """Same keys as fe0 auth-service.register() JSON body (no ``role``, no camelCase drift).""" + return { + "fullName": full_name, + "email": email, + "password": password, + "passwordConfirm": password, + "employeeId": staff["employeeId"], + "academicTitleCode": staff["academicTitleCode"], + "unitNameFreetext": staff["unitNameFreetext"], + "jobTitle": staff["jobTitle"], + } + + +def _token_hash(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _s3_client(): + import boto3 + from botocore.config import Config as BotoConfig + + return boto3.client( + "s3", + endpoint_url=os.environ["S3_ENDPOINT_URL"].strip(), + aws_access_key_id=os.environ["S3_ACCESS_KEY"].strip(), + aws_secret_access_key=os.environ["S3_SECRET_KEY"].strip(), + region_name=os.getenv("S3_REGION", "us-east-1"), + config=BotoConfig(signature_version="s3v4"), + ) + + +@unittest.skipUnless( + _RUN_DB and _HAS_S3 and _RUN_ALIGN, + "Need INITIATIVE_DATABASE_URL, full S3_* env, REGISTRATION_STACK_TEST=1 (see module docstring).", +) +class RegistrationStackAlignmentTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + await eng.init_engine() + + async def asyncTearDown(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + + async def _delete_user_and_initiatives(self, email: str) -> None: + from src.initiative_db.engine import get_session + from src.initiative_db.models import Initiative, User + + async with get_session() as session: + user = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + if user is None: + return + inis = ( + await session.execute(select(Initiative).where(Initiative.owner_id == user.id)) + ).scalars().all() + for ini in inis: + await session.delete(ini) + await session.flush() + await session.delete(user) + await session.commit() + + async def test_register_api_db_login_verify_minio_alignment(self) -> None: + from fastapi.testclient import TestClient + + from main import app + from src.initiative_db.engine import get_session + from src.initiative_db.models import ( + RegistrationOtpCode, + User, + UserRoleRow, + UserStaffProfile, + ) + + email = f"align-reg-{uuid.uuid4().hex[:12]}@ump.edu.vn" + staff = register_staff_fields() + full_name = "Alignment Register User" + body = _fe0_style_register_json( + email=email, + full_name=full_name, + password=_TEST_PASSWORD, + staff=staff, + ) + + captured: list[str] = [] + + async def grab_mail(_to: str, raw: str) -> None: + captured.append(raw) + + bucket = os.environ["S3_BUCKET_ATTACHMENTS"].strip() + s3 = _s3_client() + storage_key: str | None = None + + try: + with patch.dict(os.environ, {"AUTH_MAIL_LOG_ONLY": "1"}): + with patch("src.auth_api.deliver_registration_otp_email", side_effect=grab_mail): + with TestClient(app) as client: + # --- Register (mirrors fe0 fetch body) --- + r = client.post("/api/v1/auth/register", json=body) + self.assertEqual(r.status_code, 200, r.text) + payload = r.json() + self.assertTrue(payload.get("emailVerificationRequired"), payload) + self.assertNotIn("accessToken", payload) + self.assertEqual(payload.get("email"), email) + self.assertIn("message", payload) + + u_out = payload.get("user") or {} + self.assertEqual(u_out.get("email"), email) + self.assertEqual(u_out.get("name"), full_name) + self.assertIs(u_out.get("emailVerified"), False) + self.assertIn("viewer", u_out.get("roles") or []) + sp_out = u_out.get("staffProfile") or {} + self.assertEqual(sp_out.get("employeeId"), staff["employeeId"]) + self.assertEqual(sp_out.get("academicTitleCode"), staff["academicTitleCode"]) + self.assertEqual(sp_out.get("unitNameFreetext"), staff["unitNameFreetext"]) + self.assertEqual(sp_out.get("jobTitle"), staff["jobTitle"]) + + self.assertTrue(captured, "OTP should be issued") + raw_otp = captured[0] + self.assertEqual(len(raw_otp), 6, raw_otp) + self.assertTrue(raw_otp.isdigit()) + + with TestClient(app) as client: + blocked = client.post( + "/api/v1/auth/login", + json={"email": email, "password": _TEST_PASSWORD}, + ) + self.assertEqual(blocked.status_code, 403, blocked.text) + + # --- PostgreSQL: user, profile, role, OTP row --- + async with get_session() as session: + user = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one() + self.assertFalse(user.email_verified) + self.assertEqual(user.full_name, full_name) + + profile = await session.get(UserStaffProfile, user.id) + self.assertIsNotNone(profile) + assert profile is not None + self.assertEqual(profile.employee_id, staff["employeeId"]) + self.assertEqual(profile.academic_title_code, staff["academicTitleCode"]) + self.assertEqual(profile.job_title, staff["jobTitle"]) + + roles = ( + await session.execute( + select(UserRoleRow.role).where(UserRoleRow.user_id == user.id) + ) + ).scalars().all() + self.assertEqual(sorted(roles), ["viewer"]) + + otps = ( + await session.execute( + select(RegistrationOtpCode).where( + RegistrationOtpCode.user_id == user.id + ) + ) + ).scalars().all() + self.assertEqual(len(otps), 1) + o = otps[0] + self.assertIsNone(o.used_at) + self.assertEqual(o.otp_hash, _token_hash(raw_otp)) + + # --- Verify OTP --- + with TestClient(app) as client: + vr = client.post("/api/v1/auth/verify-otp", json={"email": email, "otp": raw_otp}) + self.assertEqual(vr.status_code, 200, vr.text) + + async with get_session() as session: + user = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one() + self.assertTrue(user.email_verified) + otps = ( + await session.execute( + select(RegistrationOtpCode).where( + RegistrationOtpCode.user_id == user.id + ) + ) + ).scalars().all() + self.assertEqual(len(otps), 1) + self.assertIsNotNone(otps[0].used_at) + + # --- Login --- + with TestClient(app) as client: + ok = client.post( + "/api/v1/auth/login", + json={"email": email, "password": _TEST_PASSWORD}, + ) + self.assertEqual(ok.status_code, 200, ok.text) + token = ok.json()["accessToken"] + self.assertTrue(token) + + cr = client.post( + "/api/applications/new", + headers={"Authorization": f"Bearer {token}"}, + json={"name": "Alignment case"}, + ) + self.assertEqual(cr.status_code, 200, cr.text) + shell = cr.json().get("application") or {} + case_id = str(shell.get("draft_case_id") or "").strip() + self.assertTrue(case_id, shell) + + bundle = minimal_tabs_bundle(initiative_name="Align Initiative") + for tab_name in ("report", "application", "contribution"): + dr = client.post( + "/api/v1/application-drafts", + headers={"Authorization": f"Bearer {token}"}, + json={"caseId": case_id, "tab": tab_name, "data": bundle[tab_name]}, + ) + self.assertEqual(dr.status_code, 200, dr.text) + + er = client.post( + f"/api/v1/application-drafts/{case_id}/evidence", + headers={"Authorization": f"Bearer {token}"}, + data={"kind": "technical"}, + files={"file": ("align.pdf", io.BytesIO(_MIN_PDF), "application/pdf")}, + ) + self.assertEqual(er.status_code, 200, er.text, er.text) + ev_body = er.json() + storage_key = str(ev_body.get("storageKey") or "").strip() + self.assertTrue(storage_key, ev_body) + + # MinIO must contain bytes at the key the API recorded. + assert storage_key is not None + head = s3.head_object(Bucket=bucket, Key=storage_key) + self.assertGreater(int(head["ContentLength"]), 0) + + finally: + await self._delete_user_and_initiatives(email) + if storage_key: + try: + s3.delete_object(Bucket=bucket, Key=storage_key) + except Exception: + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_repair_split_submission.py b/be0/tests/test_repair_split_submission.py new file mode 100644 index 0000000..77ff334 --- /dev/null +++ b/be0/tests/test_repair_split_submission.py @@ -0,0 +1,54 @@ +""" +Unit tests for `repair_split_submission` merge logic (no PostgreSQL required). + +DB integration for the full repair is gated on INITIATIVE_DATABASE_URL (see test_applications_db_integration.py). +""" + +from __future__ import annotations + +import unittest + +from src.initiative_db.repair_split_submission import ( + merge_payload_for_case_repair, + tabs_effectively_empty, +) + + +class RepairSplitSubmissionPureTests(unittest.TestCase): + def test_tabs_effectively_empty_true(self) -> None: + self.assertTrue(tabs_effectively_empty({})) + self.assertTrue(tabs_effectively_empty({"report": {}, "application": {}, "contribution": {}})) + self.assertTrue(tabs_effectively_empty(None)) + + def test_tabs_effectively_empty_false(self) -> None: + self.assertFalse(tabs_effectively_empty({"report": {"x": 1}})) + + def test_merge_prefers_good_tabs(self) -> None: + good = { + "tabs": {"application": {"initiativeName": "A"}, "report": {}, "contribution": {}}, + "caseId": "CASE-OLD", + } + bad = { + "tabs": {}, + "submissionRecord": {"id": "sub-abc"}, + "submissionFile": {"url": "/submitted-initiatives/x.pdf", "type": "pdf"}, + } + m = merge_payload_for_case_repair( + target_case_code="CASE-OK", + good_payload=good, + bad_payload=bad, + ) + self.assertEqual(m["caseId"], "CASE-OK") + self.assertEqual(m["submissionRecord"]["id"], "sub-abc") + self.assertEqual(m["submissionFile"]["url"], "/submitted-initiatives/x.pdf") + self.assertEqual(m["tabs"]["application"]["initiativeName"], "A") + + def test_merge_falls_back_to_bad_tabs_when_good_empty(self) -> None: + good = {"tabs": {}, "caseId": "CASE-OK"} + bad = {"tabs": {"application": {"k": "v"}}, "submissionRecord": {"id": "sub-x"}} + m = merge_payload_for_case_repair(target_case_code="CASE-OK", good_payload=good, bad_payload=bad) + self.assertEqual(m["tabs"]["application"]["k"], "v") + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_research_routes.py b/be0/tests/test_research_routes.py new file mode 100644 index 0000000..4c71cd9 --- /dev/null +++ b/be0/tests/test_research_routes.py @@ -0,0 +1,403 @@ +"""Tests for the research-project routes (proposals lifecycle). + +The pure-helper unit tests always run. The full lifecycle integration test runs only when +INITIATIVE_DATABASE_URL points at PostgreSQL (asyncpg), e.g.: + + export INITIATIVE_DATABASE_URL="postgresql+asyncpg://initiative:initiative_secret@127.0.0.1:15432/initiatives" + cd be0 && python -m unittest tests.test_research_routes -v + +Prereq for the DB test: migration 016_research_projects.sql applied (compose init mount or +scripts/apply_initiative_migrations.py). +""" +from __future__ import annotations + +import os +import unittest +import uuid + +_RUN_DB = os.getenv("INITIATIVE_DATABASE_URL", "").strip().lower().startswith("postgresql") + + +class ExtractScalarsTests(unittest.TestCase): + """Pure unit tests for the proposal-content scalar extraction (no DB).""" + + def test_reads_dotted_keys_and_coerces(self) -> None: + from src.research_routes import _extract_scalars + + s = _extract_scalars( + { + "tenDeTai": " AI dự đoán di căn ", + "capDeTai": "Thành phố", + "chuNhiem.hoTen": "TS. Nguyễn Văn A", + "thoiGianThucHienThang": "24", + "tongKinhPhi": "1800.5", + } + ) + self.assertEqual(s["title"], "AI dự đoán di căn") + self.assertEqual(s["level"], "Thành phố") + self.assertEqual(s["pi_name"], "TS. Nguyễn Văn A") + self.assertEqual(s["period_months"], 24) + self.assertAlmostEqual(s["budget_total"], 1800.5) + + def test_missing_and_garbage_values(self) -> None: + from src.research_routes import _extract_scalars + + empty = _extract_scalars({}) + self.assertEqual(empty["title"], "") + self.assertIsNone(empty["period_months"]) + self.assertIsNone(empty["budget_total"]) + + garbage = _extract_scalars({"thoiGianThucHienThang": "abc", "tongKinhPhi": ""}) + self.assertIsNone(garbage["period_months"]) + self.assertIsNone(garbage["budget_total"]) + + def test_non_dict_content(self) -> None: + from src.research_routes import _extract_scalars + + self.assertEqual(_extract_scalars(None)["title"], "") + self.assertEqual(_extract_scalars("nope")["pi_name"], "") + + +def _bearer(uid: uuid.UUID, roles: list[str]) -> str: + import jwt + + from src.auth_jwt import jwt_secret + + return "Bearer " + jwt.encode({"sub": str(uid), "roles": roles, "cv": 0}, jwt_secret(), algorithm="HS256") + + +@unittest.skipUnless( + _RUN_DB, + "Set INITIATIVE_DATABASE_URL=postgresql+asyncpg://.../initiatives to run DB integration tests", +) +class ResearchLifecycleDbTests(unittest.IsolatedAsyncioTestCase): + """End-to-end: draft → submit → approve, owner/admin authz, and the audit trail.""" + + async def asyncSetUp(self) -> None: + from src.initiative_db import engine as eng + + await eng.dispose_engine() + await eng.init_engine() + self._user_ids: list[uuid.UUID] = [] + self._project_ids: list[uuid.UUID] = [] + + async def asyncTearDown(self) -> None: + from sqlalchemy import delete + + from src.initiative_db import engine as eng + from src.initiative_db.engine import get_session + from src.initiative_db.models import ResearchProject, User + + async with get_session() as session: + for pid in self._project_ids: + await session.execute(delete(ResearchProject).where(ResearchProject.id == pid)) + for uid in self._user_ids: + await session.execute(delete(User).where(User.id == uid)) + await session.commit() + await eng.dispose_engine() + + async def _seed_user(self, *, admin: bool = False) -> uuid.UUID: + from src.initiative_db.engine import get_session + from src.initiative_db.models import User + + uid = uuid.uuid4() + async with get_session() as session: + session.add( + User( + id=uid, + email=f"rp-{uid.hex[:10]}@ump.edu.vn", + password_hash="x", + full_name=("Quản trị" if admin else "Chủ nhiệm") + " Test", + ) + ) + await session.commit() + self._user_ids.append(uid) + return uid + + async def test_full_lifecycle_and_authz(self) -> None: + from fastapi import HTTPException + + from src.research_routes import ( + ApproveIn, + ProjectCreateIn, + approve_project, + create_project, + get_project, + list_audit, + submit_project, + ) + + owner = await self._seed_user() + admin = await self._seed_user(admin=True) + other = await self._seed_user() + owner_tok = _bearer(owner, ["viewer"]) + admin_tok = _bearer(admin, ["admin"]) + other_tok = _bearer(other, ["viewer"]) + + # create draft + created = await create_project( + ProjectCreateIn( + content={ + "tenDeTai": "Đề tài thử nghiệm", + "capDeTai": "Cơ sở", + "thoiGianThucHienThang": "12", + "tongKinhPhi": "500", + } + ), + owner_tok, + ) + self._project_ids.append(uuid.UUID(created.id)) + self.assertEqual(created.status, "draft") + self.assertEqual(created.title, "Đề tài thử nghiệm") + self.assertEqual(created.periodMonths, 12) + + # another user cannot read it (404 hides existence) + with self.assertRaises(HTTPException) as ctx_read: + await get_project(created.id, other_tok) + self.assertEqual(ctx_read.exception.status_code, 404) + + # submit (owner) + submitted = await submit_project(created.id, owner_tok) + self.assertEqual(submitted.status, "submitted") + self.assertIsNotNone(submitted.submittedAt) + + # owner cannot approve (admin-only) → 403 + with self.assertRaises(HTTPException) as ctx_appr: + await approve_project(created.id, ApproveIn(), owner_tok) + self.assertEqual(ctx_appr.exception.status_code, 403) + + # admin approves with a code + approved = await approve_project(created.id, ApproveIn(code="ĐTUD-TEST", note="Đạt"), admin_tok) + self.assertEqual(approved.status, "approved") + self.assertEqual(approved.code, "ĐTUD-TEST") + self.assertIsNotNone(approved.reviewedAt) + + # cannot re-approve (not submitted anymore) → 409 + with self.assertRaises(HTTPException) as ctx_reappr: + await approve_project(created.id, ApproveIn(), admin_tok) + self.assertEqual(ctx_reappr.exception.status_code, 409) + + # audit trail recorded each transition (owner can read) + audit = await list_audit(created.id, owner_tok) + actions = [a.action for a in audit] + self.assertIn("Tạo bản thảo đề tài", actions) + self.assertIn("Nộp đề tài", actions) + self.assertIn("Phê duyệt đề tài", actions) + + async def test_reject_path_and_admin_cannot_edit_others_draft(self) -> None: + from fastapi import HTTPException + + from src.research_routes import ( + ProjectCreateIn, + ProjectUpdateIn, + RejectIn, + create_project, + reject_project, + submit_project, + update_project, + ) + + owner = await self._seed_user() + admin = await self._seed_user(admin=True) + owner_tok = _bearer(owner, ["viewer"]) + admin_tok = _bearer(admin, ["admin"]) + + created = await create_project(ProjectCreateIn(content={"tenDeTai": "Đề tài bị từ chối"}), owner_tok) + self._project_ids.append(uuid.UUID(created.id)) + + # admin can load it but cannot update/submit someone else's draft (owner-only) → 403 + with self.assertRaises(HTTPException) as ctx_upd: + await update_project(created.id, ProjectUpdateIn(content={"tenDeTai": "x"}), admin_tok) + self.assertEqual(ctx_upd.exception.status_code, 403) + with self.assertRaises(HTTPException) as ctx_sub: + await submit_project(created.id, admin_tok) + self.assertEqual(ctx_sub.exception.status_code, 403) + + # owner submits, admin rejects with a note + await submit_project(created.id, owner_tok) + rejected = await reject_project(created.id, RejectIn(note="Chưa đạt yêu cầu"), admin_tok) + self.assertEqual(rejected.status, "rejected") + self.assertEqual(rejected.reviewNote, "Chưa đạt yêu cầu") + + # cannot submit a rejected proposal (not draft) → 409 + with self.assertRaises(HTTPException) as ctx_resub: + await submit_project(created.id, owner_tok) + self.assertEqual(ctx_resub.exception.status_code, 409) + + async def test_cockpit_entities_crud_seeding_and_gate(self) -> None: + from fastapi import HTTPException + + from src.research_routes import ( + ApproveIn, + ProjectCreateIn, + approve_project, + create_entity, + create_project, + delete_entity, + get_cockpit, + list_entity, + submit_project, + update_entity, + ) + + owner = await self._seed_user() + admin = await self._seed_user(admin=True) + owner_tok = _bearer(owner, ["viewer"]) + admin_tok = _bearer(admin, ["admin"]) + + created = await create_project( + ProjectCreateIn( + content={ + "tenDeTai": "Đề tài cockpit", + "chuNhiem.hoTen": "TS. PI A", + "thanhVienThucHien": [{"hoTenHocVi": "KS. Lê C", "chucDanh": "Thành viên chính"}], + "tienDoThucHien": [{"noiDungCongViec": "ND1", "ketQua": "Báo cáo", "thoiGian": "T1-T3"}], + } + ), + owner_tok, + ) + pid = created.id + self._project_ids.append(uuid.UUID(pid)) + + # entity mutation before approval is locked → 409 + with self.assertRaises(HTTPException) as ctx_gate: + await create_entity(pid, "datasets", {"name": "X"}, owner_tok) + self.assertEqual(ctx_gate.exception.status_code, 409) + + # unknown entity type → 404 + with self.assertRaises(HTTPException) as ctx_unknown: + await list_entity(pid, "nope", owner_tok) + self.assertEqual(ctx_unknown.exception.status_code, 404) + + await submit_project(pid, owner_tok) + await approve_project(pid, ApproveIn(code="C1"), admin_tok) + + # approval seeded members (PI + 1 member) + milestones (1) from the proposal content + members = await list_entity(pid, "members", owner_tok) + self.assertGreaterEqual(len(members), 2) + self.assertTrue(any(m["name"] == "TS. PI A" for m in members)) + milestones = await list_entity(pid, "milestones", owner_tok) + self.assertGreaterEqual(len(milestones), 1) + self.assertEqual(milestones[0]["start"], "T1-T3") + + # create + coercion (records "620" → int 620) + ds = await create_entity(pid, "datasets", {"name": "Ảnh CLVT", "records": "620", "status": "Sẵn sàng"}, owner_tok) + self.assertEqual(ds["name"], "Ảnh CLVT") + self.assertEqual(ds["records"], 620) + + # update + upd = await update_entity(pid, "datasets", ds["id"], {"status": "Khóa"}, owner_tok) + self.assertEqual(upd["status"], "Khóa") + + # cockpit bundle reflects entities + audit captured the actions + bundle = await get_cockpit(pid, admin_tok) + self.assertEqual(bundle["project"]["status"], "approved") + self.assertEqual(len(bundle["datasets"]), 1) + audit_actions = [a["action"] for a in bundle["audit"]] + self.assertIn("Thêm bộ dữ liệu", audit_actions) + self.assertIn("Cập nhật bộ dữ liệu", audit_actions) + + # delete + await delete_entity(pid, "datasets", ds["id"], owner_tok) + self.assertEqual(len(await list_entity(pid, "datasets", owner_tok)), 0) + + async def test_seeding_survives_malformed_content(self) -> None: + """A PI can put arbitrary JSON in content; approve-time seeding must never crash (best-effort).""" + from src.research_routes import ( + ApproveIn, + ProjectCreateIn, + approve_project, + create_project, + list_entity, + submit_project, + ) + + owner = await self._seed_user() + admin = await self._seed_user(admin=True) + owner_tok = _bearer(owner, ["viewer"]) + admin_tok = _bearer(admin, ["admin"]) + + created = await create_project( + ProjectCreateIn( + content={ + "tenDeTai": "Đề tài lỗi định dạng", + "chuNhiem.hoTen": "TS. PI A", + "thanhVienThucHien": 5, # truthy non-list — must be ignored, not crash + "tienDoThucHien": "oops", # truthy non-list + } + ), + owner_tok, + ) + pid = created.id + self._project_ids.append(uuid.UUID(pid)) + await submit_project(pid, owner_tok) + + approved = await approve_project(pid, ApproveIn(), admin_tok) # must not raise + self.assertEqual(approved.status, "approved") + # malformed repeatables seeded nothing; only the PI member came from chuNhiem.hoTen + self.assertEqual(len(await list_entity(pid, "milestones", owner_tok)), 0) + members = await list_entity(pid, "members", owner_tok) + self.assertTrue(any(m["name"] == "TS. PI A" for m in members)) + + async def test_update_detail_merges_after_approval(self) -> None: + """The cockpit detail endpoint: approved-only, owner-or-admin, shallow-merge + audit.""" + from fastapi import HTTPException + + from src.research_routes import ( + ApproveIn, + ProjectCreateIn, + ProjectDetailPatchIn, + approve_project, + create_project, + get_cockpit, + submit_project, + update_project_detail, + ) + + owner = await self._seed_user() + admin = await self._seed_user(admin=True) + other = await self._seed_user() + owner_tok = _bearer(owner, ["viewer"]) + admin_tok = _bearer(admin, ["admin"]) + other_tok = _bearer(other, ["viewer"]) + + created = await create_project( + ProjectCreateIn(content={"tenDeTai": "Đề tài chi tiết", "tongKinhPhi": "300"}), + owner_tok, + ) + pid = created.id + self._project_ids.append(uuid.UUID(pid)) + + # detail patch is rejected before approval (cockpit-only) → 409 + with self.assertRaises(HTTPException) as ctx_draft: + await update_project_detail(pid, ProjectDetailPatchIn(patch={"soHopDong": "x"}), owner_tok) + self.assertEqual(ctx_draft.exception.status_code, 409) + + await submit_project(pid, owner_tok) + await approve_project(pid, ApproveIn(code="C-DET"), admin_tok) + + # owner patches admin-detail fields; merge preserves the original proposal key + re-derives scalars + patched = await update_project_detail( + pid, + ProjectDetailPatchIn(patch={"soHopDong": "205/2024/HĐ", "tongKinhPhi": "29900000"}), + owner_tok, + ) + self.assertEqual(patched.content["soHopDong"], "205/2024/HĐ") + self.assertEqual(patched.content["tenDeTai"], "Đề tài chi tiết") # untouched proposal key + self.assertAlmostEqual(patched.budgetTotal, 29900000.0) + + # admin may also patch (owner-or-admin — unlike draft update_project which is owner-only) + patched2 = await update_project_detail( + pid, ProjectDetailPatchIn(patch={"khoaDonVi": "Dược"}), admin_tok + ) + self.assertEqual(patched2.content["khoaDonVi"], "Dược") + self.assertEqual(patched2.content["soHopDong"], "205/2024/HĐ") # earlier patch survives + + # a stranger cannot patch (404 hides the row) + with self.assertRaises(HTTPException) as ctx_other: + await update_project_detail(pid, ProjectDetailPatchIn(patch={"x": "y"}), other_tok) + self.assertEqual(ctx_other.exception.status_code, 404) + + # audit captured the update + bundle = await get_cockpit(pid, owner_tok) + self.assertIn("Cập nhật thông tin đề tài", [a["action"] for a in bundle["audit"]]) diff --git a/be0/tests/test_security_routes.py b/be0/tests/test_security_routes.py new file mode 100644 index 0000000..13288ab --- /dev/null +++ b/be0/tests/test_security_routes.py @@ -0,0 +1,158 @@ +""" +Security regression tests for authenticated / removed routes (no Postgres required). + +Run: cd be0 && python -m unittest tests.test_security_routes -v +""" + +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from tests.security_token_fixture import mint_bearer_token + + +class SecurityRoutesTests(unittest.TestCase): + def _client(self): + from fastapi.testclient import TestClient + + from main import app + + return TestClient(app) + + def test_removed_upload_document_returns_404(self) -> None: + client = self._client() + r = client.post("/upload_document", files={"file": ("x.pdf", b"%PDF", "application/pdf")}) + self.assertEqual(r.status_code, 404) + + def test_removed_get_page_returns_404(self) -> None: + client = self._client() + r = client.post("/get_page", data={"new_page_number": "1"}) + self.assertEqual(r.status_code, 404) + + def test_list_applications_requires_auth(self) -> None: + client = self._client() + r = client.get("/api/applications") + self.assertEqual(r.status_code, 401) + + def test_list_applications_rejects_viewer(self) -> None: + client = self._client() + headers = {"Authorization": mint_bearer_token(roles=("viewer",))} + with patch("src.initiative_db.engine.is_postgres_enabled", return_value=False): + r = client.get("/api/applications", headers=headers) + self.assertEqual(r.status_code, 403) + + def test_list_applications_allows_staff_without_db(self) -> None: + client = self._client() + headers = {"Authorization": mint_bearer_token(roles=("admin",))} + with patch("src.initiative_db.engine.is_postgres_enabled", return_value=False): + with patch("main._load_submitted_items", return_value=[]): + r = client.get("/api/applications", headers=headers) + self.assertEqual(r.status_code, 200, r.text) + self.assertIn("data", r.json()) + + def test_get_application_requires_auth(self) -> None: + client = self._client() + r = client.get("/api/applications/sub-deadbeefdeadbeef") + self.assertEqual(r.status_code, 401) + + def test_get_application_rejects_viewer_without_row(self) -> None: + client = self._client() + headers = {"Authorization": mint_bearer_token(roles=("viewer",), email="viewer@ump.edu.vn")} + with patch("src.initiative_db.engine.is_postgres_enabled", return_value=False): + with patch("main._get_application_from_file_index", return_value=None): + r = client.get("/api/applications/sub-deadbeefdeadbeef", headers=headers) + self.assertEqual(r.status_code, 404) + + def test_review_documents_list_requires_auth(self) -> None: + client = self._client() + r = client.get("/api/v1/review-documents", params={"caseId": "CASE-1"}) + self.assertEqual(r.status_code, 401) + + def test_review_documents_create_requires_auth(self) -> None: + client = self._client() + r = client.post( + "/api/v1/review-documents", + json={"caseId": "CASE-1", "officialBieuMau": {}}, + ) + self.assertEqual(r.status_code, 401) + + def test_chat_requires_auth(self) -> None: + client = self._client() + r = client.post("/api/v1/chat", json={"message": "hello"}) + self.assertEqual(r.status_code, 401) + + def test_analyze_compliance_requires_auth(self) -> None: + client = self._client() + r = client.post( + "/analyze_compliance", + json={"external_requirements": ["ext"], "internal_requirements": ["int"]}, + ) + self.assertEqual(r.status_code, 401) + + def test_test_ollama_requires_admin(self) -> None: + client = self._client() + viewer = {"Authorization": mint_bearer_token(roles=("viewer",))} + r_viewer = client.post("/test_ollama", json={"prompt": "hi"}, headers=viewer) + self.assertEqual(r_viewer.status_code, 403) + + admin = {"Authorization": mint_bearer_token(roles=("admin",))} + with patch( + "main.ollama.chat", + return_value={"message": {"content": "ok"}}, + ): + r_admin = client.post("/test_ollama", json={"prompt": "hi"}, headers=admin) + self.assertEqual(r_admin.status_code, 200, r_admin.text) + + def test_ideas_post_requires_admin(self) -> None: + client = self._client() + headers = {"Authorization": mint_bearer_token(roles=("viewer",))} + r = client.post( + "/api/v1/ideas", + json={"title": "t", "description": "d"}, + headers=headers, + ) + self.assertEqual(r.status_code, 403) + + def test_security_headers_on_health(self) -> None: + client = self._client() + r = client.get("/health") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers.get("x-content-type-options"), "nosniff") + self.assertEqual(r.headers.get("x-frame-options"), "DENY") + self.assertIn("referrer-policy", r.headers) + + +class JwtSecretTests(unittest.TestCase): + def test_production_requires_secret(self) -> None: + from src.auth_jwt import jwt_secret + + env = {k: v for k, v in os.environ.items() if k not in ("JWT_SECRET", "ENVIRONMENT")} + env["ENVIRONMENT"] = "production" + with patch.dict(os.environ, env, clear=True): + with self.assertRaises(RuntimeError): + jwt_secret() + + def test_development_allows_dev_fallback(self) -> None: + from src.auth_jwt import jwt_secret + + with patch.dict(os.environ, {"ENVIRONMENT": "development"}, clear=False): + os.environ.pop("JWT_SECRET", None) + secret = jwt_secret() + self.assertGreaterEqual(len(secret), 32) + + +class LoginRateLimitTests(unittest.TestCase): + def test_login_rate_limit_blocks_after_threshold(self) -> None: + from src.auth_rate_limit import allow_login + + email = "ratelimit-test@ump.edu.vn" + ip = "203.0.113.50" + for _ in range(5): + self.assertTrue(allow_login(email, ip)) + self.assertFalse(allow_login(email, ip)) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_staff_profile_domain.py b/be0/tests/test_staff_profile_domain.py new file mode 100644 index 0000000..58d0fe3 --- /dev/null +++ b/be0/tests/test_staff_profile_domain.py @@ -0,0 +1,95 @@ +"""Unit tests for staff_profile_domain (no database).""" + +from __future__ import annotations + +import unittest +import uuid + +from src.initiative_db.models import User, UserStaffProfile +from src.staff_profile_domain import ( + apply_reverify_from_verified, + assert_complete_for_submission, + assert_employee_id_shape, + assert_unit_exclusive, + material_staff_fields_changed, + normalize_employee_id, + staff_row_for_audit, +) + + +class StaffProfileDomainTests(unittest.TestCase): + def test_normalize_employee_id(self) -> None: + self.assertIsNone(normalize_employee_id(None)) + self.assertEqual(normalize_employee_id(" ab-12 "), "AB-12") + + def test_employee_id_shape(self) -> None: + assert_employee_id_shape(None) + assert_employee_id_shape("ABC-123") + with self.assertRaises(ValueError): + assert_employee_id_shape("ab") + + def test_unit_exclusive(self) -> None: + uid = uuid.uuid4() + user = User( + id=uid, + email="t@ump.edu.vn", + password_hash="x", + full_name="T", + unit_id=uuid.uuid4(), + ) + sp = UserStaffProfile(user_id=uid, unit_name_freetext=" Khoa X ") + with self.assertRaises(ValueError): + assert_unit_exclusive(user, sp) + + def test_material_staff_fields_changed(self) -> None: + a = staff_row_for_audit( + UserStaffProfile(user_id=uuid.uuid4(), job_title="A"), + None, + ) + b = staff_row_for_audit( + UserStaffProfile(user_id=uuid.uuid4(), job_title="B"), + None, + ) + self.assertTrue(material_staff_fields_changed(a, b)) + self.assertFalse(material_staff_fields_changed(a, a)) + + def test_apply_reverify_sets_pending(self) -> None: + from datetime import datetime, timezone + + sp = UserStaffProfile( + user_id=uuid.uuid4(), + profile_verification_status="verified", + verified_at=datetime.now(timezone.utc), + verified_by_user_id=uuid.uuid4(), + rejection_reason=None, + ) + now = datetime.now(timezone.utc) + apply_reverify_from_verified(sp, now) + self.assertEqual(sp.profile_verification_status, "pending") + self.assertIsNone(sp.verified_at) + self.assertEqual(sp.verification_submitted_at, now) + + def test_assert_complete_for_submission(self) -> None: + uid = uuid.uuid4() + user = User( + id=uid, + email="t@ump.edu.vn", + password_hash="x", + full_name="T", + unit_id=uuid.uuid4(), + ) + sp = UserStaffProfile( + user_id=uid, + employee_id="CB-001", + academic_title_code="master", + job_title="GV", + ) + assert_complete_for_submission(user, sp) + + sp2 = UserStaffProfile(user_id=uid, employee_id=None) + with self.assertRaises(ValueError): + assert_complete_for_submission(user, sp2) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_submission_readiness.py b/be0/tests/test_submission_readiness.py new file mode 100644 index 0000000..01e12ed --- /dev/null +++ b/be0/tests/test_submission_readiness.py @@ -0,0 +1,47 @@ +"""Unit tests for submit readiness validation (no database).""" + +from __future__ import annotations + +import unittest + +from src.initiative_db.submission_readiness import ( + ApplicationSubmissionNotReadyError, + collect_submission_readiness_gaps, +) + +from tests.fixtures.minimal_submit_bundle import minimal_tabs_bundle + + +class SubmissionReadinessTests(unittest.TestCase): + def test_minimal_tabs_with_technical_evidence_ok(self) -> None: + tabs = minimal_tabs_bundle() + gaps = collect_submission_readiness_gaps( + tabs, + {"research": False, "textbook": False, "technical": True}, + ) + self.assertEqual(gaps, []) + + def test_missing_evidence_fails(self) -> None: + tabs = minimal_tabs_bundle() + gaps = collect_submission_readiness_gaps( + tabs, + {"research": False, "textbook": False, "technical": False}, + ) + self.assertTrue(any("Nhóm 1" in g for g in gaps)) + + def test_missing_honesty_flags(self) -> None: + tabs = minimal_tabs_bundle() + tabs["report"]["honestyConfirmed"] = False + gaps = collect_submission_readiness_gaps( + tabs, + {"research": False, "textbook": False, "technical": True}, + ) + self.assertTrue(any("Báo cáo" in g and "cam kết" in g for g in gaps)) + + def test_exception_carries_missing(self) -> None: + exc = ApplicationSubmissionNotReadyError(["a", "b"]) + self.assertEqual(exc.missing, ["a", "b"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_submissions_projection_research_kind.py b/be0/tests/test_submissions_projection_research_kind.py new file mode 100644 index 0000000..3ed4329 --- /dev/null +++ b/be0/tests/test_submissions_projection_research_kind.py @@ -0,0 +1,43 @@ +"""Projection of `tabs.application.researchEvidenceKind` onto list rows (no DB). + +Run: cd be0 && python -m unittest tests.test_submissions_projection_research_kind -v +""" + +from __future__ import annotations + +import unittest +import uuid +from datetime import datetime, timezone +from types import SimpleNamespace + + +class SubmissionsResearchEvidenceKindProjectionTests(unittest.TestCase): + def test_poster_without_review_round_trips_on_api_row(self) -> None: + from src.initiative_db.submissions import _as_submission_item + + ini = SimpleNamespace( + id=uuid.uuid4(), + case_code="CASE-PROJ-RK", + status="submitted", + submitted_at=datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + payload = { + "submissionRecord": { + "id": "sub-deadbeefcafe", + "submittedDate": "2026-01-01T12:00:00.000Z", + "name": "Test", + }, + "tabs": { + "application": { + "initiativeClassification": "research", + "researchEvidenceKind": "poster-without-review", + } + }, + } + row = _as_submission_item(ini, payload) # type: ignore[arg-type] + self.assertEqual(row.get("researchEvidenceKind"), "poster-without-review") + self.assertEqual(row.get("initiativeClassification"), "research") + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tests/test_user_notifications_merit.py b/be0/tests/test_user_notifications_merit.py new file mode 100644 index 0000000..e624109 --- /dev/null +++ b/be0/tests/test_user_notifications_merit.py @@ -0,0 +1,66 @@ +"""Unit tests for merit label derivation from draft JSON (notification body). + +Run: cd be0 && python -m unittest tests.test_user_notifications_merit -v +""" + +from __future__ import annotations + +import unittest + + +class MeritCategoryFromDraftTests(unittest.TestCase): + def test_poster_without_review_is_trung_binh(self) -> None: + from src.initiative_db.user_notifications import merit_category_label_from_draft_payload + + payload = { + "tabs": { + "application": { + "initiativeClassification": "research", + "researchEvidenceKind": "poster-without-review", + } + } + } + self.assertEqual(merit_category_label_from_draft_payload(payload), "Trung bình") + + def test_international_remains_xuat_sac(self) -> None: + from src.initiative_db.user_notifications import merit_category_label_from_draft_payload + + payload = { + "tabs": { + "application": { + "initiativeClassification": "research", + "researchEvidenceKind": "international", + } + } + } + self.assertEqual(merit_category_label_from_draft_payload(payload), "Xuất sắc") + + def test_textbook_book_is_xuat_sac(self) -> None: + from src.initiative_db.user_notifications import merit_category_label_from_draft_payload + + payload = { + "tabs": { + "application": { + "initiativeClassification": "textbook", + "textbookEvidenceKind": "book", + } + } + } + self.assertEqual(merit_category_label_from_draft_payload(payload), "Xuất sắc") + + def test_poster_with_review_still_kha_bucket(self) -> None: + from src.initiative_db.user_notifications import merit_category_label_from_draft_payload + + payload = { + "tabs": { + "application": { + "initiativeClassification": "research", + "researchEvidenceKind": "poster", + } + } + } + self.assertEqual(merit_category_label_from_draft_payload(payload), "Khá") + + +if __name__ == "__main__": + unittest.main() diff --git a/be0/tools/__pycache__/e2e_application_form_pdf_export.cpython-313.pyc b/be0/tools/__pycache__/e2e_application_form_pdf_export.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..415b3a6234985a08ae282c04ad0813c80e79cea4 GIT binary patch literal 7635 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 literal 0 HcmV?d00001 diff --git a/be0/tools/e2e_application_form_pdf_export.py b/be0/tools/e2e_application_form_pdf_export.py new file mode 100644 index 0000000..13f99bd --- /dev/null +++ b/be0/tools/e2e_application_form_pdf_export.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Smoke / E2E test for application-form PDF export (docxtpl + LibreOffice). + + Direct (no HTTP): exercises merge + conversion on this machine / container. + cd be0 && python tools/e2e_application_form_pdf_export.py --direct --out /tmp/e2e-mau.pdf + + HTTP: needs FastAPI listening (--url is the API origin, no /api prefix in path). + python tools/e2e_application_form_pdf_export.py --http http://127.0.0.1:4402 --out /tmp/e2e-mau.pdf + + Pure curl + jq (no Python on host; official JSON must be wrapped): + cd /path/to/repo && jq -n --slurpfile o fe0/public/assets/bieu_mau_sang_kien_template.json \ + '{officialBieuMau: $o[0]}' | curl -sfS -X POST http://127.0.0.1:4402/api/v1/docx/preview-application-form-pdf \ + -H 'Content-Type: application/json' -o /tmp/e2e-mau.pdf && file /tmp/e2e-mau.pdf + + If the API returns HTTP 501, rebuild/run be0 with LibreOffice (see be0/Dockerfile: libreoffice-writer-nogui). + +Docker (stack up): + docker compose exec be0 python tools/e2e_application_form_pdf_export.py --direct --out /tmp/e2e-mau.pdf + +Refresh bundled sample after template changes: + cp ../fe0/public/assets/bieu_mau_sang_kien_template.json tools/e2e_sample_official_bieu_mau.json + +Exit 0 on success; non-zero on failure. + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Dict + +BE0_ROOT = Path(__file__).resolve().parent.parent +if str(BE0_ROOT) not in sys.path: + sys.path.insert(0, str(BE0_ROOT)) + + +def _repo_root() -> Path: + return BE0_ROOT.parent + + +def load_sample_official() -> Dict[str, Any]: + """officialBieuMau-shaped JSON (same as Review « Xem lại »).""" + candidates = [ + Path(__file__).resolve().parent / "e2e_sample_official_bieu_mau.json", + _repo_root() / "fe0/public/assets/bieu_mau_sang_kien_template.json", + ] + for p in candidates: + if p.is_file(): + with open(p, encoding="utf-8") as f: + data = json.load(f) + break + else: + raise FileNotFoundError( + "No sample official JSON found. Expected tools/e2e_sample_official_bieu_mau.json " + "or fe0/public/assets/bieu_mau_sang_kien_template.json next to be0/." + ) + + # Visible markers in generated PDF/DOCX + if isinstance(data.get("TRANG BÌA"), dict): + data["TRANG BÌA"]["Tên sáng kiến (Tiếng Việt)"] = "E2E PDF export — tên sáng kiến kiểm thử" + data["TRANG BÌA"]["Năm"] = "2026" + return data + + +def run_direct(out_path: Path | None) -> bytes: + from src.be01.docx_to_pdf import convert_docx_bytes_to_pdf + from src.be01.fill_application_form import fill_application_form_docx + from src.be01.official_to_data_blank import official_to_data_blank + + official = load_sample_official() + ctx = official_to_data_blank(official) + docx = fill_application_form_docx(ctx) + pdf = convert_docx_bytes_to_pdf(docx) + if not pdf.startswith(b"%PDF"): + raise RuntimeError("Output is not a PDF (missing %PDF header).") + if out_path: + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(pdf) + print(f"Wrote {len(pdf)} bytes → {out_path}") + else: + print(f"OK: generated PDF, {len(pdf)} bytes (no --out)") + return pdf + + +def run_http(base_url: str, out_path: Path | None) -> bytes: + import urllib.error + import urllib.request + + official = load_sample_official() + url = base_url.rstrip("/") + "/api/v1/docx/preview-application-form-pdf" + body = json.dumps({"officialBieuMau": official}).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={"Content-Type": "application/json", "Accept": "application/pdf"}, + ) + try: + with urllib.request.urlopen(req, timeout=180) as resp: + raw = resp.read() + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + raise SystemExit(f"HTTP {e.code}: {detail}") from e + + if not raw.startswith(b"%PDF"): + raise SystemExit(f"Expected PDF, got {len(raw)} bytes, head={raw[:64]!r}") + if out_path: + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(raw) + print(f"Wrote {len(raw)} bytes → {out_path}") + else: + print(f"OK: HTTP PDF, {len(raw)} bytes") + return raw + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--direct", action="store_true", help="Run docxtpl + LibreOffice in-process") + g.add_argument("--http", metavar="BASE_URL", help="POST to FastAPI (e.g. http://127.0.0.1:4402)") + ap.add_argument("--out", type=Path, metavar="FILE.pdf", help="Write PDF to this path") + args = ap.parse_args() + + try: + if args.direct: + run_direct(args.out) + else: + run_http(args.http, args.out) + except FileNotFoundError as e: + print(f"FAIL: {e}", file=sys.stderr) + sys.exit(2) + except Exception as e: + print(f"FAIL: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/be0/tools/e2e_sample_official_bieu_mau.json b/be0/tools/e2e_sample_official_bieu_mau.json new file mode 100644 index 0000000..6cfec36 --- /dev/null +++ b/be0/tools/e2e_sample_official_bieu_mau.json @@ -0,0 +1,181 @@ +{ + "TRANG BÌA": { + "Tên sáng kiến (Tiếng Việt)": "", + "Tác giả/nhóm tác giả sáng kiến": "", + "Đơn vị công tác": "", + "Thông tin liên hệ (Điện thoại, Email)": "", + "Năm": "" + }, + "MẪU SỐ 01 - BÁO CÁO MÔ TẢ SÁNG KIẾN": { + "1. Mở đầu": "", + "2. Tên sáng kiến (tên quy trình, giải pháp, phương pháp)": "", + "3. Lĩnh vực áp dụng của sáng kiến": "", + "4. Mô tả sáng kiến": { + "4.1 Tình trạng giải pháp đã biết hoặc hiện trạng công tác khi chưa có sáng kiến": "", + "4.2 Nội dung giải pháp đề nghị công nhận là sáng kiến": { + "Mục đích của sáng kiến": "", + "Về nội dung của sáng kiến": { + "Các bước thực hiện giải pháp": "", + "Các điều kiện cần thiết để áp dụng giải pháp": "", + "Lĩnh vực áp dụng": "", + "Kết quả thu được": "", + "Danh sách đơn vị/cá nhân đã tham gia áp dụng thử hoặc lần đầu": [ + { + "TT": "", + "Tên tổ chức/cá nhân": "", + "Địa chỉ": "", + "Lĩnh vực áp dụng sáng kiến": "" + } + ] + }, + "Về tính mới của sáng kiến": "", + "Về tính hiệu quả": { + "Tạo ra lợi ích kinh tế": "", + "Đem lại hiệu quả trong giảng dạy": "", + "Tăng năng suất lao động": "", + "Nâng cao hiệu quả công việc": "", + "Nâng cao chất lượng công việc, dịch vụ": "", + "Giảm chi phí": "", + "Cải thiện môi trường, điều kiện học tập, làm việc, sống": "", + "Bảo vệ sức khỏe": "", + "Đảm bảo an toàn lao động, PCCC": "", + "Nâng cao khả năng, trình độ, nhận thức, trách nhiệm": "" + } + } + }, + "6. Những thông tin cần được bảo mật (nếu có)": "", + "Ngày ký": { + "Ngày": "", + "Tháng": "", + "Năm": "" + }, + "Lãnh đạo đơn vị (Ký, ghi rõ họ tên)": "", + "Tác giả sáng kiến (Ký, ghi rõ họ tên)": "" + }, + "MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN": { + "Đơn vị": "", + "Danh sách tác giả": [ + { + "STT": "", + "Họ và tên": "", + "Ngày tháng năm sinh": "", + "Nơi công tác": "", + "Chức danh": "", + "Trình độ chuyên môn": "", + "Tỷ lệ (%) đóng góp vào việc tạo ra sáng kiến": "" + } + ], + "Tên sáng kiến đề nghị xét công nhận": "", + "Chủ đầu tư tạo ra sáng kiến": "", + "Lĩnh vực áp dụng sáng kiến": "", + "Ngày sáng kiến được áp dụng": "", + "Nội dung của sáng kiến": "", + "Phân loại sáng kiến (đánh dấu ☑)": { + "Giải pháp kỹ thuật, quản lý, tác nghiệp, ứng dụng tiến bộ kỹ thuật áp dụng cho ĐHYD TP.HCM": false, + "Sáng kiến – cải tiến kỹ thuật từ các nghiên cứu khoa học có kết quả được đăng tải trên các tạp chí, hội nghị trong nước và quốc tế": false, + "Sáng kiến – cải tiến kỹ thuật từ sách, giáo trình, tài liệu tham khảo": false + }, + "Những thông tin cần được bảo mật (nếu có)": "", + "Các điều kiện cần thiết để áp dụng sáng kiến": "", + "Đánh giá lợi ích theo ý kiến của tác giả": "", + "Đánh giá lợi ích theo ý kiến của tổ chức, cá nhân đã tham gia áp dụng sáng kiến lần đầu": "", + "Danh sách những người đã tham gia áp dụng thử hoặc áp dụng sáng kiến lần đầu": [ + { + "Số TT": "", + "Họ và tên": "", + "Ngày tháng năm sinh": "", + "Nơi công tác": "", + "Chức danh": "", + "Trình độ chuyên môn": "", + "Nội dung công việc hỗ trợ": "" + } + ], + "Ngày ký": { + "Ngày": "", + "Tháng": "", + "Năm": "" + }, + "Xác nhận của lãnh đạo Đơn vị": "", + "Tác giả sáng kiến (Ký, ghi rõ họ tên)": "" + }, + "MẪU SỐ 03 - BẢN XÁC NHẬN TỶ LỆ (%) ĐÓNG GÓP VÀO VIỆC TẠO RA SÁNG KIẾN": { + "Ngày ký": { + "Ngày": "", + "Tháng": "", + "Năm": "" + }, + "1. Tên sáng kiến": "", + "2. Tác giả chính/Đại diện nhóm tác giả sáng kiến": "", + "Chức vụ, đơn vị công tác": "", + "Tỷ lệ đóng góp": [ + { + "STT": "", + "Họ và tên": "", + "Đơn vị công tác": "", + "% đóng góp": "", + "Chữ ký xác nhận": "" + } + ], + "Tổng % đóng góp": "100", + "Tác giả chính/Đại diện nhóm tác giả sáng kiến (chữ ký và ghi rõ họ tên)": "" + }, + "MẪU SỐ 04 - PHIẾU ĐÁNH GIÁ SÁNG KIẾN": { + "1. Tên sáng kiến": "", + "2. Tác giả/đồng tác giả sáng kiến": "", + "Chức vụ, đơn vị công tác": "", + "3. Nội dung đánh giá": { + "Tính mới (Tối đa 40 điểm)": { + "Nhận xét": "", + "Điểm chấm": "" + }, + "Tính hiệu quả (Tối đa 60 điểm)": { + "Nhận xét": "", + "Điểm chấm": "" + }, + "Tổng cộng": "" + }, + "Kết luận": "", + "Ngày ký": { + "Ngày": "", + "Tháng": "", + "Năm": "" + }, + "Thành viên Hội đồng (Ký, ghi rõ họ tên)": "" + }, + "BẢN CAM KẾT": { + "Ngày ký": { + "Ngày": "", + "Tháng": "", + "Năm": "" + }, + "Tiêu đề phụ (Áp dụng đối với cá nhân đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại Đại học Y Dược TP. Hồ Chí Minh là tác giả của bài báo khoa học)": "", + "I. THÔNG TIN CHỦ THỂ CAM KẾT": { + "Tác giả đăng ký sáng kiến": "", + "CCCD/Hộ chiếu số": "", + "Đơn vị": "", + "Tên Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH": "", + "Năm xét công nhận sáng kiến": "", + "Vai trò đối với bài báo (☑ vào ô tương ứng)": { + "Tác giả chính Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH": false, + "Đồng tác giả Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH": false + } + }, + "II. CAM KẾT NỘI DUNG (☑ vào ô tương ứng)": { + "1. Quyền sở hữu đối với bài báo trong nước/quốc tế": { + "Tôi là chủ sở hữu hợp pháp của bài báo hoặc được chủ sở hữu/đồng chủ sở hữu đồng ý cho sử dụng bài báo có tên nêu trên làm sản phẩm đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD": false, + "Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH: chủ sở hữu bài báo (cơ quan) đồng ý cho tác giả/nhóm tác giả sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD": false + }, + "2. Đồng thuận của đồng tác giả bài báo trong nước/quốc tế": { + "Tất cả đồng tác giả đã biết, đồng ý và ký xác nhận cho phép Tác giả đăng ký sáng kiến được sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD": false + }, + "3. Cam kết bài báo trong nước/quốc tế uy tín": { + "Cá nhân đăng ký xét công nhận sáng kiến – cải tiến kỹ thuật tại ĐHYD đối với bài báo trong nước/quốc tế cam kết bài báo không thuộc 'Tạp chí săn mồi'. Tôi xin chịu trách nhiệm kiểm tra, đối chiếu và cung cấp bằng chứng khi được yêu cầu": false + }, + "4. Tuân thủ pháp luật sở hữu trí tuệ": { + "Tôi cam kết rằng việc sử dụng bài báo đăng ký xét công nhận sáng kiến tại ĐHYD sẽ không gây tranh chấp về: quyền tác giả/quyền liên quan, quyền sở hữu công nghiệp, tiết lộ bí mật kinh doanh, vi phạm bảo mật dữ liệu của bất kỳ bên thứ ba nào. Tôi chịu trách nhiệm trước pháp luật về tính trung thực, hợp pháp của hồ sơ": false + } + }, + "III. HẬU QUẢ PHÁP LÝ KHI THÔNG TIN KHÔNG TRUNG THỰC": "Tôi xin cam kết chịu trách nhiệm đối với các thông tin kê khai nêu trên. Nếu thông tin được khai trong bản cam kết này không đúng thì tôi chấp nhận: Hủy kết quả công nhận sáng kiến đã được xét (nếu có); Thu hồi, hủy các danh hiệu thi đua, khen thưởng, hoặc các quyền lợi phát sinh có sử dụng sáng kiến này để xét; Xử lý theo quy định pháp luật hiện hành và theo quy chế/quy định của ĐHYD. Cam kết này có hiệu lực kể từ ngày ký và ràng buộc đối với cá nhân cam kết trong suốt thời gian xét công nhận sáng kiến và sau khi kết thúc 02 năm.", + "Người cam kết (Ký tên, ghi rõ họ tên)": "" + } +} diff --git a/database/crud_examples.sql b/database/crud_examples.sql new file mode 100644 index 0000000..9f3cb60 --- /dev/null +++ b/database/crud_examples.sql @@ -0,0 +1,171 @@ +-- ============================================================================= +-- CRUD PATTERNS — Sáng kiến application system +-- ============================================================================= + +-- ============================================================================= +-- CREATE: Submit a new application with multiple authors (atomic) +-- ============================================================================= +BEGIN; + -- Set audit context + SELECT set_config('my.user_id', '42', true); + + -- 1. Main record + INSERT INTO applications(code, title, registration_year, status, purpose, + is_technical_solution, primary_unit_id, created_by) + VALUES ('SK-2025-007', + 'Hệ thống tự động điền hồ sơ sáng kiến', + 2025, 'DRAFT', + 'Tự động hoá việc điền các mẫu số 01–04', + TRUE, 2, 42) + RETURNING application_id \gset + + -- 2. Authors (defer contribution-sum check until COMMIT) + SET CONSTRAINTS trg_contribution_total DEFERRED; + INSERT INTO application_authors(application_id, user_id, contribution_pct, role, display_order) VALUES + (:application_id, 42, 60.00, 'PRIMARY', 1), + (:application_id, 13, 25.00, 'CO_AUTHOR', 2), + (:application_id, 27, 15.00, 'CO_AUTHOR', 3); + + -- 3. Orgs that tested it + INSERT INTO application_adopters(application_id, org_name, address, field) VALUES + (:application_id, 'Phòng KHCN', '217 Hồng Bàng, Q.5', 'Cải cách hành chính'); +COMMIT; + + +-- ============================================================================= +-- READ: Dashboard — paginated list with filters +-- ============================================================================= +SELECT * FROM v_application_summary + WHERE registration_year = 2025 + AND status = ANY(ARRAY['UNDER_REVIEW','EVALUATED']::text[]) + AND title ILIKE '%động vật%' -- uses trigram index + ORDER BY avg_score DESC NULLS LAST, submitted_at DESC + LIMIT 20 OFFSET 0; + +-- Read: full application with nested data (app layer usually does this as N queries +-- or one JSON aggregate — here's the aggregate version) +SELECT jsonb_build_object( + 'application', to_jsonb(a.*), + 'authors', (SELECT jsonb_agg(jsonb_build_object( + 'user_id', u.user_id, + 'name', u.full_name, + 'pct', aa.contribution_pct, + 'role', aa.role + ) ORDER BY aa.display_order) + FROM application_authors aa + JOIN users u USING (user_id) + WHERE aa.application_id = a.application_id), + 'evaluations',(SELECT jsonb_agg(to_jsonb(e.*)) + FROM evaluations e WHERE e.application_id = a.application_id), + 'attachments',(SELECT jsonb_agg(to_jsonb(att.*)) + FROM attachments att WHERE att.application_id = a.application_id) +) AS document +FROM applications a +WHERE a.application_id = 1 AND a.deleted_at IS NULL; + +-- Full-text search (Vietnamese-friendly; combine with unaccent for better recall) +SELECT application_id, code, title + FROM applications + WHERE to_tsvector('simple', title || ' ' || coalesce(introduction,'')) + @@ plainto_tsquery('simple', 'đạo đức động vật') + ORDER BY registration_year DESC + LIMIT 10; + + +-- ============================================================================= +-- UPDATE: Progress an application through the workflow +-- ============================================================================= +-- Submit (DRAFT → SUBMITTED). Triggers populate submitted_at automatically. +UPDATE applications SET status = 'SUBMITTED' WHERE application_id = 7; + +-- Assign to review panel +UPDATE applications SET status = 'UNDER_REVIEW' WHERE application_id = 7; + +-- Upsert an evaluation (same evaluator re-scores) +INSERT INTO evaluations (application_id, evaluator_id, novelty_score, effectiveness_score, conclusion) +VALUES (7, 99, 32, 48, 'Đề nghị công nhận') +ON CONFLICT (application_id, evaluator_id) +DO UPDATE SET + novelty_score = EXCLUDED.novelty_score, + effectiveness_score = EXCLUDED.effectiveness_score, + conclusion = EXCLUDED.conclusion, + evaluated_at = NOW(); + +-- Update JSONB field: patch a single effectiveness sub-field +UPDATE applications + SET effectiveness = effectiveness || jsonb_build_object( + 'economic', + 'Tiết kiệm ~30% thời gian xét duyệt' + ) + WHERE application_id = 7; + +-- Partial update (PATCH-style) — only update provided fields. The app layer +-- generates SET clauses from the non-null fields in the request body. +UPDATE applications + SET title = COALESCE($1, title), + purpose = COALESCE($2, purpose), + updated_at = NOW() + WHERE application_id = $3 AND deleted_at IS NULL +RETURNING *; + + +-- ============================================================================= +-- DELETE: Soft delete + restore +-- ============================================================================= +-- Soft delete +UPDATE applications SET deleted_at = NOW() WHERE application_id = 7; + +-- Restore +UPDATE applications SET deleted_at = NULL WHERE application_id = 7; + +-- Hard delete (only for drafts, cascades to authors/evaluations/etc.) +DELETE FROM applications + WHERE application_id = 7 + AND status = 'DRAFT'; + + +-- ============================================================================= +-- ANALYTICS: Materialized-view refresh (run nightly via cron/pgAgent) +-- ============================================================================= +REFRESH MATERIALIZED VIEW CONCURRENTLY mv_annual_stats; + +-- Leaderboard: top-scoring approved innovations +SELECT code, title, avg_score + FROM v_application_summary + WHERE status = 'APPROVED' + ORDER BY avg_score DESC + LIMIT 10; + + +-- ============================================================================= +-- REVIEW JSON: Persist / retrieve ReviewPanel bundle +-- ============================================================================= +BEGIN; + SELECT set_config('my.user_id', '42', true); + + -- Latest app version number for this application + WITH v AS ( + SELECT COALESCE(MAX(document_version), 0) + 1 AS next_ver + FROM application_review_documents + WHERE application_id = 7 + ) + INSERT INTO application_review_documents( + application_id, case_id, document_version, official_bieu_mau, template_data, full_bundle, created_by + ) + SELECT + 7, + 'CASE-2026-0007', + v.next_ver, + $${"TRANG BÌA":{"Tên sáng kiến (Tiếng Việt)":"Ví dụ"}}$$::jsonb, + $${"initiativeName":"Ví dụ"}$$::jsonb, + $${"meta":{"caseId":"CASE-2026-0007"}}$$::jsonb, + 42 + FROM v; +COMMIT; + +-- Load latest ReviewPanel bundle by case id +SELECT * + FROM application_review_documents + WHERE case_id = 'CASE-2026-0007' + ORDER BY document_version DESC, created_at DESC + LIMIT 1; diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..a479426 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,441 @@ +-- ============================================================================= +-- SÁNG KIẾN (INNOVATION APPLICATION) DATABASE SCHEMA +-- PostgreSQL 14+ +-- +-- Domain: Manage innovation applications at ĐHYD TP.HCM (Vietnamese medical +-- university). Supports the full lifecycle: draft → submit → evaluate → approve. +-- +-- Design principles: +-- - 3NF for entities, JSONB for semi-structured/optional narrative +-- - Soft delete (deleted_at) — legal/audit requires historical retention +-- - State machine on applications.status enforced by trigger +-- - Full audit_log via trigger on all CUD operations +-- - Contribution % sums to 100 enforced by DEFERRABLE trigger +-- ============================================================================= + +CREATE EXTENSION IF NOT EXISTS pg_trgm; -- fuzzy matching +CREATE EXTENSION IF NOT EXISTS unaccent; -- Vietnamese diacritics in search + +-- Convenience: updated_at auto-maintenance +CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS TRIGGER AS $$ +BEGIN NEW.updated_at := NOW(); RETURN NEW; END; +$$ LANGUAGE plpgsql; + + +-- ============================================================================= +-- REFERENCE: UNITS (departments, faculties, centers) +-- ============================================================================= +CREATE TABLE units ( + unit_id SERIAL PRIMARY KEY, + code VARCHAR(32) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, -- full Vietnamese name + parent_unit_id INT REFERENCES units(unit_id) ON DELETE SET NULL, + type VARCHAR(32) NOT NULL + CHECK (type IN ('TRUONG','KHOA','PHONG','BO_MON','TRUNG_TAM','KHAC')), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER trg_units_touch BEFORE UPDATE ON units + FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + + +-- ============================================================================= +-- USERS (unified: authors, evaluators, admins — a user can wear many hats) +-- ============================================================================= +CREATE TABLE users ( + user_id SERIAL PRIMARY KEY, + full_name VARCHAR(255) NOT NULL, + title VARCHAR(64), -- PGS.TS, TS., GS., CN., ThS. + date_of_birth DATE, + email VARCHAR(255) UNIQUE, + phone VARCHAR(32), + id_number VARCHAR(32) UNIQUE, -- CCCD / hộ chiếu + unit_id INT REFERENCES units(unit_id) ON DELETE SET NULL, + position VARCHAR(255), -- chức danh: Trưởng phòng, GV cao cấp + qualification VARCHAR(64), -- trình độ: Tiến sĩ, Thạc sĩ, Cử nhân + user_type VARCHAR(32) NOT NULL DEFAULT 'AUTHOR' + CHECK (user_type IN ('AUTHOR','COUNCIL','ADMIN','STUDENT','EXTERNAL')), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + deleted_at TIMESTAMPTZ, -- soft delete + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_users_unit ON users(unit_id); +CREATE INDEX idx_users_active ON users(is_active) WHERE deleted_at IS NULL; +CREATE INDEX idx_users_name_trgm ON users USING GIN (full_name gin_trgm_ops); +CREATE TRIGGER trg_users_touch BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + + +-- ============================================================================= +-- APPLICATIONS (sáng kiến) — the core entity +-- ============================================================================= +CREATE TABLE applications ( + application_id SERIAL PRIMARY KEY, + code VARCHAR(32) UNIQUE NOT NULL, -- e.g., 'SK-2025-001' + title TEXT NOT NULL, + title_en TEXT, + registration_year INT NOT NULL CHECK (registration_year BETWEEN 2000 AND 2100), + field_of_application TEXT, -- lĩnh vực áp dụng + + -- Workflow state (enforced by trigger below) + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' + CHECK (status IN ( + 'DRAFT','SUBMITTED','UNDER_REVIEW', + 'EVALUATED','APPROVED','REJECTED','WITHDRAWN' + )), + + -- Mẫu 01 narrative (long text) + introduction TEXT, -- 1. Mở đầu + current_state TEXT, -- 4.1 Tình trạng đã biết + purpose TEXT, -- Mục đích + implementation_steps TEXT, -- Các bước thực hiện + required_conditions TEXT, -- Điều kiện cần thiết + results_achieved TEXT, -- Kết quả thu được + novelty_description TEXT, -- Tính mới + confidential_info TEXT, -- Thông tin cần bảo mật + + -- 10 effectiveness sub-fields (all optional narrative) → JSONB + effectiveness JSONB NOT NULL DEFAULT '{}'::jsonb, + -- Shape: { "economic":"...", "teaching":"...", "productivity":"...", + -- "work_efficiency":"...", "quality":"...", "cost_reduction":"...", + -- "environment":"...", "health":"...", "safety":"...", "awareness":"..." } + + -- Mẫu 02 fields + owner_org VARCHAR(255), -- chủ đầu tư + first_applied_date DATE, -- ngày áp dụng lần đầu + content_summary TEXT, -- nội dung sáng kiến (short) + author_assessment TEXT, -- đánh giá theo tác giả + org_assessment TEXT, -- đánh giá theo tổ chức + + -- Mẫu 02 classification (mutually exclusive in form, but stored as flags) + is_technical_solution BOOLEAN NOT NULL DEFAULT FALSE, + is_from_research_article BOOLEAN NOT NULL DEFAULT FALSE, + is_from_book_material BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT chk_exactly_one_classification CHECK ( + status = 'DRAFT' OR + (is_technical_solution::int + is_from_research_article::int + is_from_book_material::int) = 1 + ), + + -- Workflow timestamps + submitted_at TIMESTAMPTZ, + decided_at TIMESTAMPTZ, + + primary_unit_id INT REFERENCES units(unit_id), + created_by INT REFERENCES users(user_id), + deleted_at TIMESTAMPTZ, -- soft delete + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_apps_status ON applications(status) WHERE deleted_at IS NULL; +CREATE INDEX idx_apps_year ON applications(registration_year); +CREATE INDEX idx_apps_unit ON applications(primary_unit_id); +CREATE INDEX idx_apps_title_trgm ON applications USING GIN (title gin_trgm_ops); +CREATE INDEX idx_apps_fts ON applications USING GIN ( + to_tsvector('simple', + coalesce(title,'') || ' ' || + coalesce(introduction,'') || ' ' || + coalesce(novelty_description,'') + ) +); +CREATE INDEX idx_apps_effectiveness ON applications USING GIN (effectiveness); +CREATE TRIGGER trg_apps_touch BEFORE UPDATE ON applications + FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); + + +-- ============================================================================= +-- APPLICATION_AUTHORS (M:N with contribution %) +-- ============================================================================= +CREATE TABLE application_authors ( + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + user_id INT NOT NULL REFERENCES users(user_id), + contribution_pct NUMERIC(5,2) NOT NULL CHECK (contribution_pct > 0 AND contribution_pct <= 100), + role VARCHAR(32) NOT NULL DEFAULT 'CO_AUTHOR' + CHECK (role IN ('PRIMARY','CO_AUTHOR')), + display_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (application_id, user_id) +); +CREATE INDEX idx_app_authors_user ON application_authors(user_id); + +-- At most one PRIMARY author per application +CREATE UNIQUE INDEX uq_primary_per_app + ON application_authors(application_id) WHERE role = 'PRIMARY'; + +-- Deferrable check: contribution % must total 100 per application +CREATE OR REPLACE FUNCTION check_contribution_total() RETURNS TRIGGER AS $$ +DECLARE v_total NUMERIC; v_app INT; +BEGIN + v_app := COALESCE(NEW.application_id, OLD.application_id); + SELECT COALESCE(SUM(contribution_pct),0) INTO v_total + FROM application_authors WHERE application_id = v_app; + -- Only enforce when application has left DRAFT + IF (SELECT status FROM applications WHERE application_id = v_app) <> 'DRAFT' + AND v_total <> 100 THEN + RAISE EXCEPTION 'Contribution % for application % must sum to 100 (got %)', + '%', v_app, v_total; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER trg_contribution_total + AFTER INSERT OR UPDATE OR DELETE ON application_authors + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION check_contribution_total(); + + +-- ============================================================================= +-- ORGS that tested / adopted the innovation (Mẫu 01 inner table) +-- ============================================================================= +CREATE TABLE application_adopters ( + adopter_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + display_order INT NOT NULL DEFAULT 0, + org_name VARCHAR(255) NOT NULL, + address TEXT, + field TEXT +); +CREATE INDEX idx_adopters_app ON application_adopters(application_id); + + +-- ============================================================================= +-- PARTICIPANTS in first application (Mẫu 02 inner table) +-- ============================================================================= +CREATE TABLE application_participants ( + participant_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + user_id INT REFERENCES users(user_id), -- optional link + display_order INT NOT NULL DEFAULT 0, + full_name VARCHAR(255) NOT NULL, + date_of_birth DATE, + work_unit VARCHAR(255), + position VARCHAR(255), + qualification VARCHAR(64), + support_content TEXT +); +CREATE INDEX idx_participants_app ON application_participants(application_id); + + +-- ============================================================================= +-- EVALUATIONS (Mẫu 04) — council members score applications +-- ============================================================================= +CREATE TABLE evaluations ( + evaluation_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + evaluator_id INT NOT NULL REFERENCES users(user_id), + + novelty_comments TEXT, + novelty_score INT NOT NULL DEFAULT 0 + CHECK (novelty_score BETWEEN 0 AND 40), + + effectiveness_comments TEXT, + effectiveness_score INT NOT NULL DEFAULT 0 + CHECK (effectiveness_score BETWEEN 0 AND 60), + + total_score INT GENERATED ALWAYS AS (novelty_score + effectiveness_score) STORED, + conclusion TEXT, + evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id, evaluator_id) +); +CREATE INDEX idx_eval_app ON evaluations(application_id); +CREATE INDEX idx_eval_evaluator ON evaluations(evaluator_id); + + +-- ============================================================================= +-- COMMITMENTS (Bản cam kết) — for paper-based innovations +-- ============================================================================= +CREATE TABLE commitments ( + commitment_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + user_id INT NOT NULL REFERENCES users(user_id), + + paper_title TEXT, + role_type VARCHAR(32) NOT NULL + CHECK (role_type IN ('PRIMARY_AUTHOR','CO_AUTHOR')), + + -- 5 commitment checkboxes + is_legal_owner BOOLEAN NOT NULL DEFAULT FALSE, + is_authorized_by_owner BOOLEAN NOT NULL DEFAULT FALSE, + has_coauthor_consent BOOLEAN NOT NULL DEFAULT FALSE, + not_predatory_journal BOOLEAN NOT NULL DEFAULT FALSE, + complies_with_ip_law BOOLEAN NOT NULL DEFAULT FALSE, + + signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (application_id, user_id) +); +CREATE INDEX idx_commit_app ON commitments(application_id); + + +-- ============================================================================= +-- ATTACHMENTS (uploaded files — figures, flowcharts, annexes) +-- ============================================================================= +CREATE TABLE attachments ( + attachment_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + file_path TEXT NOT NULL, -- S3/MinIO key + file_size BIGINT, + mime_type VARCHAR(128), + kind VARCHAR(32) -- 'LUU_DO', 'PHU_LUC', 'KY_SO', 'KHAC' + CHECK (kind IS NULL OR kind IN ('LUU_DO','PHU_LUC','KY_SO','KHAC')), + uploaded_by INT REFERENCES users(user_id), + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_attach_app ON attachments(application_id); + + +-- ============================================================================= +-- AUDIT LOG — single table, populated by triggers on all CUD operations +-- ============================================================================= +CREATE TABLE audit_log ( + log_id BIGSERIAL PRIMARY KEY, + table_name VARCHAR(64) NOT NULL, + record_id TEXT NOT NULL, + action VARCHAR(16) NOT NULL CHECK (action IN ('INSERT','UPDATE','DELETE')), + changed_by INT, -- set from app via SET LOCAL my.user_id + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + old_data JSONB, + new_data JSONB +); +CREATE INDEX idx_audit_table_record ON audit_log(table_name, record_id); +CREATE INDEX idx_audit_user_time ON audit_log(changed_by, changed_at DESC); + +-- Generic audit trigger function +CREATE OR REPLACE FUNCTION audit_trigger() RETURNS TRIGGER AS $$ +DECLARE + v_user INT; + v_pk TEXT; +BEGIN + -- Get user_id from session var if app sets it; else NULL + BEGIN v_user := current_setting('my.user_id')::INT; + EXCEPTION WHEN OTHERS THEN v_user := NULL; END; + + v_pk := COALESCE( + (row_to_json(NEW)::jsonb->>TG_ARGV[0]), + (row_to_json(OLD)::jsonb->>TG_ARGV[0]) + ); + + INSERT INTO audit_log(table_name, record_id, action, changed_by, old_data, new_data) + VALUES ( + TG_TABLE_NAME, + v_pk, + TG_OP, + v_user, + CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) END, + CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN to_jsonb(NEW) END + ); + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- Attach audit trigger to the important tables (pass PK column name as arg) +CREATE TRIGGER trg_audit_applications AFTER INSERT OR UPDATE OR DELETE ON applications + FOR EACH ROW EXECUTE FUNCTION audit_trigger('application_id'); +CREATE TRIGGER trg_audit_authors AFTER INSERT OR UPDATE OR DELETE ON application_authors + FOR EACH ROW EXECUTE FUNCTION audit_trigger('application_id'); +CREATE TRIGGER trg_audit_evaluations AFTER INSERT OR UPDATE OR DELETE ON evaluations + FOR EACH ROW EXECUTE FUNCTION audit_trigger('evaluation_id'); +CREATE TRIGGER trg_audit_commitments AFTER INSERT OR UPDATE OR DELETE ON commitments + FOR EACH ROW EXECUTE FUNCTION audit_trigger('commitment_id'); + + +-- ============================================================================= +-- WORKFLOW STATE MACHINE ENFORCEMENT +-- ============================================================================= +CREATE OR REPLACE FUNCTION enforce_application_transitions() RETURNS TRIGGER AS $$ +DECLARE + allowed BOOLEAN := FALSE; +BEGIN + IF OLD.status = NEW.status THEN RETURN NEW; END IF; + + -- Allowed transitions + allowed := CASE + WHEN OLD.status = 'DRAFT' AND NEW.status IN ('SUBMITTED','WITHDRAWN') THEN TRUE + WHEN OLD.status = 'SUBMITTED' AND NEW.status IN ('UNDER_REVIEW','WITHDRAWN','DRAFT') THEN TRUE + WHEN OLD.status = 'UNDER_REVIEW' AND NEW.status IN ('EVALUATED','WITHDRAWN') THEN TRUE + WHEN OLD.status = 'EVALUATED' AND NEW.status IN ('APPROVED','REJECTED') THEN TRUE + ELSE FALSE + END; + + IF NOT allowed THEN + RAISE EXCEPTION 'Invalid status transition: % → %', OLD.status, NEW.status; + END IF; + + -- Auto-set timestamps + IF NEW.status = 'SUBMITTED' AND OLD.status = 'DRAFT' THEN + NEW.submitted_at := NOW(); + END IF; + IF NEW.status IN ('APPROVED','REJECTED') THEN + NEW.decided_at := NOW(); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_app_state_machine + BEFORE UPDATE OF status ON applications + FOR EACH ROW EXECUTE FUNCTION enforce_application_transitions(); + + +-- ============================================================================= +-- CONVENIENCE VIEWS +-- ============================================================================= + +-- Dashboard: applications with author names and current evaluation average +CREATE VIEW v_application_summary AS +SELECT + a.application_id, + a.code, + a.title, + a.status, + a.registration_year, + u.name AS primary_unit_name, + (SELECT string_agg(usr.full_name, ', ' ORDER BY aa.display_order) + FROM application_authors aa + JOIN users usr ON usr.user_id = aa.user_id + WHERE aa.application_id = a.application_id) AS author_names, + (SELECT ROUND(AVG(total_score),2) + FROM evaluations WHERE application_id = a.application_id) AS avg_score, + (SELECT COUNT(*) FROM evaluations WHERE application_id = a.application_id) AS num_evaluations, + a.submitted_at, + a.decided_at +FROM applications a +LEFT JOIN units u ON u.unit_id = a.primary_unit_id +WHERE a.deleted_at IS NULL; + +-- Materialized view: annual approval statistics (refresh nightly) +CREATE MATERIALIZED VIEW mv_annual_stats AS +SELECT + registration_year, + COUNT(*) FILTER (WHERE status = 'APPROVED') AS approved, + COUNT(*) FILTER (WHERE status = 'REJECTED') AS rejected, + COUNT(*) FILTER (WHERE status NOT IN ('APPROVED','REJECTED')) AS pending, + COUNT(*) AS total +FROM applications +WHERE deleted_at IS NULL +GROUP BY registration_year; +CREATE UNIQUE INDEX ON mv_annual_stats(registration_year); + + +-- ============================================================================= +-- REVIEW DOCUMENT JSON SNAPSHOTS (ReviewPanel bundle persistence) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS application_review_documents ( + review_document_id SERIAL PRIMARY KEY, + application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE, + case_id VARCHAR(128) NOT NULL, + document_version INT NOT NULL DEFAULT 1, + official_bieu_mau JSONB NOT NULL DEFAULT '{}'::jsonb, + template_data JSONB, + full_bundle JSONB, + created_by INT REFERENCES users(user_id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (application_id, document_version) +); +CREATE INDEX idx_review_docs_app_time ON application_review_documents(application_id, created_at DESC); +CREATE INDEX idx_review_docs_case_time ON application_review_documents(case_id, created_at DESC); diff --git a/database/test_schema.sql b/database/test_schema.sql new file mode 100644 index 0000000..73d7e1f --- /dev/null +++ b/database/test_schema.sql @@ -0,0 +1,83 @@ +-- Validation tests: run in a single transaction per block +-- =========================================================== + +-- 1. SEED: units + users +INSERT INTO units(code, name, type) VALUES + ('DHYD', 'Đại học Y Dược TP.HCM', 'TRUONG'), + ('KHCN', 'Phòng Khoa học Công nghệ', 'PHONG'); + +INSERT INTO users(full_name, title, email, id_number, unit_id, qualification, user_type) VALUES + ('Trần Hùng', 'PGS.TS', 'tranhung@ump.edu.vn', '001001', 1, 'Tiến sĩ', 'AUTHOR'), + ('Đỗ Quốc Vũ', 'CN.', 'doquocvu@ump.edu.vn', '001002', 2, 'Cử nhân', 'AUTHOR'), + ('Nguyễn Hội đồng A', 'PGS.TS', 'hdA@ump.edu.vn', '002001', 1, 'Tiến sĩ', 'COUNCIL'); + +-- 2. CREATE an application in DRAFT state +INSERT INTO applications(code, title, registration_year, status, purpose, primary_unit_id, created_by) +VALUES ('SK-2025-001', + 'Quy trình xét duyệt Đạo đức trong nghiên cứu trên động vật', + 2025, 'DRAFT', + 'Chuẩn hoá quy trình xét duyệt hồ sơ', + 2, 2); + +-- 3. ADD authors with DEFERRED constraint (sums to 100 at COMMIT) +BEGIN; +INSERT INTO application_authors(application_id, user_id, contribution_pct, role) VALUES + (1, 1, 50, 'CO_AUTHOR'), + (1, 2, 50, 'PRIMARY'); +-- At this point sum=100, but app is DRAFT so constraint doesn't even care yet +COMMIT; + +-- Verify +SELECT 'Authors inserted:' AS step, count(*) FROM application_authors; + +-- 4. TRY to submit the application (DRAFT → SUBMITTED): needs classification +-- This should FAIL the check constraint because no classification flag is set +\echo 'Test 4: should FAIL (missing classification)' +UPDATE applications SET status='SUBMITTED' WHERE application_id=1; +\echo '' + +-- Fix and retry +UPDATE applications + SET is_technical_solution = TRUE, + status = 'SUBMITTED' + WHERE application_id = 1; +SELECT 'After submit:' AS step, status, submitted_at FROM applications WHERE application_id=1; + +-- 5. TRY invalid transition SUBMITTED → APPROVED (should FAIL) +\echo 'Test 5: should FAIL (illegal transition)' +UPDATE applications SET status='APPROVED' WHERE application_id=1; +\echo '' + +-- Valid transitions +UPDATE applications SET status='UNDER_REVIEW' WHERE application_id=1; + +-- 6. EVALUATOR scores the application +INSERT INTO evaluations(application_id, evaluator_id, novelty_score, effectiveness_score, conclusion) +VALUES (1, 3, 35, 50, 'Đề xuất công nhận'); + +SELECT 'Evaluation:' AS step, novelty_score, effectiveness_score, total_score FROM evaluations; + +-- 7. Move to EVALUATED → APPROVED +UPDATE applications SET status='EVALUATED' WHERE application_id=1; +UPDATE applications SET status='APPROVED' WHERE application_id=1; + +SELECT 'Final status:' AS step, status, decided_at IS NOT NULL AS has_decision_time + FROM applications WHERE application_id=1; + +-- 8. READ: summary view +SELECT code, title, status, author_names, avg_score, num_evaluations + FROM v_application_summary; + +-- 9. AUDIT trail: who changed what? +SELECT table_name, action, changed_at, + (new_data->>'status') AS new_status + FROM audit_log + WHERE table_name = 'applications' + ORDER BY log_id; + +-- 10. Bad contribution sum should fail at COMMIT +\echo 'Test 10: should FAIL (sum != 100 on submitted app)' +BEGIN; + UPDATE application_authors SET contribution_pct = 30 WHERE application_id=1 AND user_id=1; + -- sum is now 30+50=80, but app is APPROVED so trigger will reject at commit +COMMIT; diff --git a/deploy/nginx/minio-s3-proxy.conf.example b/deploy/nginx/minio-s3-proxy.conf.example new file mode 100644 index 0000000..bbe68e2 --- /dev/null +++ b/deploy/nginx/minio-s3-proxy.conf.example @@ -0,0 +1,47 @@ +# Example: expose MinIO S3 API on HTTPS for presigned URLs (fixes mixed content vs https://your-app). +# +# 1. DNS: A/AAAA record for MINIO_API_HOST → your VPS. +# 2. TLS: obtain cert for MINIO_API_HOST (e.g. certbot --nginx). +# 3. Replace MINIO_API_HOST and adjust upstream port if MINIO_API_PORT ≠ 19000. +# 4. Set in .env (same hostname and scheme — no trailing slash): +# S3_PUBLIC_ENDPOINT_URL=https://MINIO_API_HOST +# MINIO_SERVER_URL=https://MINIO_API_HOST +# 5. Recreate/be0 restart so presign matches this host. +# +# Optionally bind Docker’s MinIO publish to localhost only: +# "127.0.0.1:19000:9000" + +upstream minio_s3_api { + server 127.0.0.1:19000; + keepalive 32; +} + +server { + listen 443 ssl http2; + server_name MINIO_API_HOST; + + ssl_certificate /fullchain.pem; + ssl_certificate_key /privkey.pem; + + # Large evidence PDF uploads go through be0, not nginx→MinIO, but PUT via presign can be big. + client_max_body_size 50m; + + # Disable buffering for streamed GETs if needed upstream. + proxy_buffering off; + proxy_request_buffering off; + + location / { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + proxy_connect_timeout 300; + proxy_send_timeout 300; + proxy_read_timeout 300; + + proxy_pass http://minio_s3_api; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..b5e5553 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,211 @@ +# Requires a `.env` next to this file (or exported vars). +# Validates: scripts/verify-prod-env.sh +# +# Images are pinned instead of `:latest` for reproducible builds and supply-chain hygiene. +services: + minio: + image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z + container_name: minio + ports: + - "${MINIO_API_PORT}:9000" # S3 API → http://${PUBLIC_HOST}:${MINIO_API_PORT} + - "127.0.0.1:${MINIO_CONSOLE_PORT}:9001" # Console admin-only via SSH tunnel + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + # Public URL browsers use for the S3 API (match reverse-proxy TLS scheme/host when applicable). + MINIO_SERVER_URL: ${MINIO_SERVER_URL:-http://${PUBLIC_HOST}:${MINIO_API_PORT}} + MINIO_BROWSER_REDIRECT_URL: ${MINIO_BROWSER_REDIRECT_URL:-http://${PUBLIC_HOST}:${MINIO_CONSOLE_PORT}} + # Community MinIO has no per-bucket PutBucketCors; set explicit SPA origin(s) in `.env`. + MINIO_API_CORS_ALLOW_ORIGIN: ${MINIO_API_CORS_ALLOW_ORIGIN:?Set MINIO_API_CORS_ALLOW_ORIGIN to your HTTPS SPA origin} + volumes: + - ./assets/minio-data:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + restart: unless-stopped + + # One-shot: ensure buckets. Browser CORS is MINIO_API_CORS_ALLOW_ORIGIN on the minio service. + minio-cors: + image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z + container_name: minio-cors + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + entrypoint: ["/bin/sh", "-c"] + command: + - | + mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" + for b in initiative-attachments initiative-exports initiative-quarantine imagehub-blobs; do + mc mb -p "local/$$b" 2>/dev/null || true + done + echo "MinIO buckets ensured." + + # Auth + roles: POSTGRES_* apply only on first volume init — see docs/deploy-production-docker.md + postgres: + image: postgres:16-alpine + container_name: initiative-postgres + # Bind to localhost only — DB is not for the public internet. + ports: + - "127.0.0.1:15432:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - initiative_pg_data:/var/lib/postgresql/data + - ./be0/migrations/001_initiative_schema.sql:/docker-entrypoint-initdb.d/01_initiative_schema.sql:ro + - ./be0/migrations/002_application_storage_extensions.sql:/docker-entrypoint-initdb.d/02_application_storage_extensions.sql:ro + - ./be0/migrations/003_review_documents.sql:/docker-entrypoint-initdb.d/03_review_documents.sql:ro + - ./be0/migrations/004_evidence_artifact_review.sql:/docker-entrypoint-initdb.d/04_evidence_artifact_review.sql:ro + - ./be0/migrations/004_application_admin_results.sql:/docker-entrypoint-initdb.d/05_application_admin_results.sql:ro + - ./be0/migrations/006_user_notifications.sql:/docker-entrypoint-initdb.d/06_user_notifications.sql:ro + - ./be0/migrations/007_user_roles_email_policy_admin.sql:/docker-entrypoint-initdb.d/07_user_roles_email_policy_admin.sql:ro + - ./be0/migrations/008_audit_events.sql:/docker-entrypoint-initdb.d/08_audit_events.sql:ro + - ./be0/migrations/009_backup_artifact_roles_storage_kind.sql:/docker-entrypoint-initdb.d/09_backup_artifact_roles_storage_kind.sql:ro + - ./be0/migrations/010_user_staff_profiles.sql:/docker-entrypoint-initdb.d/10_user_staff_profiles.sql:ro + - ./be0/migrations/011_academic_titles_vn.sql:/docker-entrypoint-initdb.d/11_academic_titles_vn.sql:ro + - ./be0/migrations/012_password_reset.sql:/docker-entrypoint-initdb.d/12_password_reset.sql:ro + - ./be0/migrations/013_email_verification.sql:/docker-entrypoint-initdb.d/13_email_verification.sql:ro + - ./be0/migrations/014_registration_otp.sql:/docker-entrypoint-initdb.d/14_registration_otp.sql:ro + - ./be0/migrations/015_document_templates.sql:/docker-entrypoint-initdb.d/15_document_templates.sql:ro + - ./be0/migrations/016_research_projects.sql:/docker-entrypoint-initdb.d/16_research_projects.sql:ro + - ./be0/migrations/017_imagehub_datasets.sql:/docker-entrypoint-initdb.d/17_imagehub_datasets.sql:ro + - ./be0/migrations/018_imagehub_segmentation_links.sql:/docker-entrypoint-initdb.d/18_imagehub_segmentation_links.sql:ro + - ./be0/migrations/019_imagehub_cloud_import.sql:/docker-entrypoint-initdb.d/19_imagehub_cloud_import.sql:ro + - ./be0/migrations/020_imagehub_dataset_stages.sql:/docker-entrypoint-initdb.d/20_imagehub_dataset_stages.sql:ro + - ./be0/migrations/021_imagehub_task_pipeline.sql:/docker-entrypoint-initdb.d/21_imagehub_task_pipeline.sql:ro + - ./be0/migrations/022_imagehub_task_annotations.sql:/docker-entrypoint-initdb.d/22_imagehub_task_annotations.sql:ro + - ./be0/migrations/023_imagehub_dataset_members.sql:/docker-entrypoint-initdb.d/23_imagehub_dataset_members.sql:ro + - ./be0/migrations/024_imagehub_dataset_project_link.sql:/docker-entrypoint-initdb.d/24_imagehub_dataset_project_link.sql:ro + - ./be0/migrations/025_imagehub_task_review_events.sql:/docker-entrypoint-initdb.d/25_imagehub_task_review_events.sql:ro + - ./be0/migrations/026_imagehub_file_folder_path.sql:/docker-entrypoint-initdb.d/26_imagehub_file_folder_path.sql:ro + - ./be0/migrations/027_imagehub_dataset_label_map.sql:/docker-entrypoint-initdb.d/27_imagehub_dataset_label_map.sql:ro + # Evaluate user/db inside the container ($$…) so Compose .env substitution stays in sync at runtime. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + restart: unless-stopped + + # API — must become healthy (Postgres + MinIO + successful startup) before fe0 starts. + be0: + build: + context: ./be0 + dockerfile: Dockerfile + container_name: be0 + ipc: host + ports: + - "127.0.0.1:4402:4402" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://127.0.0.1:4402/health >/dev/null"] + interval: 10s + timeout: 10s + retries: 15 + start_period: 180s + environment: + - GENERIC_TIMEZONE=UTC + - ENVIRONMENT=production + - JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env — openssl rand -base64 48} + - INITIATIVE_DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + - APPLICATION_DRAFT_DIR=/app/assets/application-drafts + - SUBMITTED_INITIATIVES_DIR=/app/submitted-initiatives + - S3_ENDPOINT_URL=http://minio:9000 + - S3_ACCESS_KEY=${MINIO_ROOT_USER} + - S3_SECRET_KEY=${MINIO_ROOT_PASSWORD} + - S3_BUCKET_ATTACHMENTS=initiative-attachments + - S3_BUCKET_EXPORTS=initiative-exports + - S3_BUCKET_QUARANTINE=initiative-quarantine + # Presigned GET/PUT host the browser opens — must be HTTPS when the SPA is HTTPS (see docs/minio-behind-https.md). + - S3_PUBLIC_ENDPOINT_URL=${S3_PUBLIC_ENDPOINT_URL:-http://${PUBLIC_HOST}:${MINIO_API_PORT}} + - CORS_ORIGINS=http://${PUBLIC_HOST}:${FE_PORT},${CORS_ORIGINS_EXTRA:-} + - AUTH_ADMIN_EMAILS=${AUTH_ADMIN_EMAILS:-} + # SMTP — registration OTP + password reset (same vars as docs; set in `.env`). + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - AUTH_MAIL_FROM=${AUTH_MAIL_FROM:-} + - SMTP_USE_TLS=${SMTP_USE_TLS:-1} + - AUTH_PUBLIC_WEB_ORIGIN=${AUTH_PUBLIC_WEB_ORIGIN:-} + - AUTH_MAIL_LOG_ONLY=${AUTH_MAIL_LOG_ONLY:-} + - TEMPLATE_APPLICATION_FORM_DOCX=/app/template_application_form.docx + volumes: + - ./be0:/app + - ./assets:/app/assets + - ./assets/submitted-initiatives:/app/submitted-initiatives + - ./fe0/public/assets/template_application_form.docx:/app/template_application_form.docx:ro + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + # Dockerfile entrypoint: NLTK download + pip install + uvicorn (no --reload in prod). + restart: unless-stopped + + # Public applicant SPA — minified static build served by nginx (NOT the Vite dev server). + # Build context is the repo root (npm workspace); see frontend_user/Dockerfile.prod. + frontend_user: + build: + context: . + dockerfile: frontend_user/Dockerfile.prod + container_name: frontend_user + ports: + - "${FE_PORT}:8080" + depends_on: + be0: + condition: service_healthy + restart: unless-stopped + + # Admin / council SPA — also a hardened static build, but bound to LOCALHOST only. + # Reach it via an SSH tunnel or a separate authenticated reverse-proxy vhost. + # NOTE: the council review UI is still in progress — keep it off the public internet for now. + frontend_admin: + build: + context: . + dockerfile: frontend_admin/Dockerfile.prod + container_name: frontend_admin + ports: + - "127.0.0.1:${FE_ADMIN_PORT:-8082}:8080" + depends_on: + be0: + condition: service_healthy + restart: unless-stopped + + # Principal-investigator SPA (research proposals + project cockpit) — hardened static build. + frontend_investigator: + build: + context: . + dockerfile: frontend_investigator/Dockerfile.prod + container_name: frontend_investigator + ports: + - "${FE_INV_PORT:-8083}:8080" + depends_on: + be0: + condition: service_healthy + restart: unless-stopped + + frontend_publisher: + build: + context: . + dockerfile: frontend_publisher/Dockerfile.prod + container_name: frontend_publisher + ports: + - "${FE_PUB_PORT:-8084}:8080" + depends_on: + be0: + condition: service_healthy + restart: unless-stopped + +volumes: + initiative_pg_data: + +# All services join Compose’s default project network; DNS names postgres, be0, minio work. +# Do not set your public VPS IP here — use PUBLIC_HOST + ports in `.env` / `ports:`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..594e4ff --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,286 @@ +services: + minio: + image: quay.io/minio/minio:latest + container_name: minio + # Host 9000/9001 are often taken; map to free ports on your machine (S3 API / web UI). + ports: + - "19000:9000" # API / S3 endpoint → http://localhost:19000 + - "19001:9001" # Web console → http://localhost:19001 + environment: + MINIO_ROOT_USER: minio_user + MINIO_ROOT_PASSWORD: minio_password # Use strong password in real projects! + # Community MinIO has no per-bucket PutBucketCors (AiStor-only). Browsers need global API CORS for presigned GETs. + MINIO_API_CORS_ALLOW_ORIGIN: ${MINIO_API_CORS_ALLOW_ORIGIN:-*} + volumes: + - ./assets/minio-data:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + networks: + profyt-net: + ipv4_address: "10.5.0.3" + + # One-shot: ensure buckets. Browser CORS is MINIO_API_CORS_ALLOW_ORIGIN on the minio service (not mc cors set). + minio-cors: + image: quay.io/minio/mc:latest + container_name: minio-cors + depends_on: + minio: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + command: + - | + mc alias set local http://minio:9000 minio_user minio_password + for b in initiative-attachments initiative-exports initiative-quarantine imagehub-blobs; do + mc mb -p "local/$$b" 2>/dev/null || true + done + echo "MinIO buckets ensured." + networks: + profyt-net: + ipv4_address: "10.5.0.5" + restart: "no" + postgres: + image: postgres:16-alpine + container_name: initiative-postgres + # Host 5432 is often taken by a local Postgres; map a different port for host access. + ports: + - "15432:5432" + environment: + POSTGRES_USER: initiative + POSTGRES_PASSWORD: initiative_secret + POSTGRES_DB: initiatives + volumes: + - initiative_pg_data:/var/lib/postgresql/data + # Schema lives under be0 (dyd/0backend/migrations is not in this repo). + - ./be0/migrations/001_initiative_schema.sql:/docker-entrypoint-initdb.d/01_initiative_schema.sql:ro + - ./be0/migrations/002_application_storage_extensions.sql:/docker-entrypoint-initdb.d/02_application_storage_extensions.sql:ro + - ./be0/migrations/003_review_documents.sql:/docker-entrypoint-initdb.d/03_review_documents.sql:ro + - ./be0/migrations/004_evidence_artifact_review.sql:/docker-entrypoint-initdb.d/04_evidence_artifact_review.sql:ro + - ./be0/migrations/004_application_admin_results.sql:/docker-entrypoint-initdb.d/05_application_admin_results.sql:ro + - ./be0/migrations/006_user_notifications.sql:/docker-entrypoint-initdb.d/06_user_notifications.sql:ro + - ./be0/migrations/007_user_roles_email_policy_admin.sql:/docker-entrypoint-initdb.d/07_user_roles_email_policy_admin.sql:ro + - ./be0/migrations/008_audit_events.sql:/docker-entrypoint-initdb.d/08_audit_events.sql:ro + - ./be0/migrations/009_backup_artifact_roles_storage_kind.sql:/docker-entrypoint-initdb.d/09_backup_artifact_roles_storage_kind.sql:ro + - ./be0/migrations/010_user_staff_profiles.sql:/docker-entrypoint-initdb.d/10_user_staff_profiles.sql:ro + - ./be0/migrations/011_academic_titles_vn.sql:/docker-entrypoint-initdb.d/11_academic_titles_vn.sql:ro + - ./be0/migrations/012_password_reset.sql:/docker-entrypoint-initdb.d/12_password_reset.sql:ro + - ./be0/migrations/013_email_verification.sql:/docker-entrypoint-initdb.d/13_email_verification.sql:ro + - ./be0/migrations/014_registration_otp.sql:/docker-entrypoint-initdb.d/14_registration_otp.sql:ro + - ./be0/migrations/015_document_templates.sql:/docker-entrypoint-initdb.d/15_document_templates.sql:ro + - ./be0/migrations/016_research_projects.sql:/docker-entrypoint-initdb.d/16_research_projects.sql:ro + - ./be0/migrations/017_imagehub_datasets.sql:/docker-entrypoint-initdb.d/17_imagehub_datasets.sql:ro + - ./be0/migrations/018_imagehub_segmentation_links.sql:/docker-entrypoint-initdb.d/18_imagehub_segmentation_links.sql:ro + - ./be0/migrations/019_imagehub_cloud_import.sql:/docker-entrypoint-initdb.d/19_imagehub_cloud_import.sql:ro + - ./be0/migrations/020_imagehub_dataset_stages.sql:/docker-entrypoint-initdb.d/20_imagehub_dataset_stages.sql:ro + - ./be0/migrations/021_imagehub_task_pipeline.sql:/docker-entrypoint-initdb.d/21_imagehub_task_pipeline.sql:ro + - ./be0/migrations/022_imagehub_task_annotations.sql:/docker-entrypoint-initdb.d/22_imagehub_task_annotations.sql:ro + - ./be0/migrations/023_imagehub_dataset_members.sql:/docker-entrypoint-initdb.d/23_imagehub_dataset_members.sql:ro + - ./be0/migrations/024_imagehub_dataset_project_link.sql:/docker-entrypoint-initdb.d/24_imagehub_dataset_project_link.sql:ro + - ./be0/migrations/025_imagehub_task_review_events.sql:/docker-entrypoint-initdb.d/25_imagehub_task_review_events.sql:ro + - ./be0/migrations/026_imagehub_file_folder_path.sql:/docker-entrypoint-initdb.d/26_imagehub_file_folder_path.sql:ro + - ./be0/migrations/027_imagehub_dataset_label_map.sql:/docker-entrypoint-initdb.d/27_imagehub_dataset_label_map.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U initiative -d initiatives"] + interval: 3s + timeout: 5s + retries: 20 + start_period: 10s + restart: unless-stopped + networks: + profyt-net: + ipv4_address: "10.5.0.10" + + # ── Frontends ─────────────────────────────────────────────────────────────── + # Two SPAs built from the npm workspace (shared kernel + each app). The browser + # calls same-origin /api/*; Vite proxies to be0 (localhost:4402 is wrong inside the + # container). Build context is the repo ROOT — the workspace — not the app dir, so + # `@ump/shared` (../shared/src) resolves. Dev mode: bind-mount the workspace + reinstall + # on start so new deps land in the isolated node_modules volume. + frontend_user: + build: + context: . + dockerfile: frontend_user/Dockerfile + container_name: frontend_user + ipc: host + ports: + - 8081:5173 + environment: + - GENERIC_TIMEZONE=UTC + - VITE_DEV_PROXY_TARGET=http://be0:4402 + # When unset, Vite allows all hosts. Set e.g. YOUR_IP,localhost for cloud/LAN dev. + - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-} + volumes: + - ./package.json:/app/package.json + - ./package-lock.json:/app/package-lock.json + - ./shared:/app/shared + - ./frontend_user:/app/frontend_user + - ./frontend_admin:/app/frontend_admin + - ./frontend_investigator:/app/frontend_investigator + - ./frontend_publisher:/app/frontend_publisher + # Isolated workspace-hoisted node_modules (not shadowed by the host). + - /app/node_modules + command: ["sh", "-c", "npm install && npm run dev -w frontend_user -- --host 0.0.0.0 --port 5173"] + depends_on: + be0: + condition: service_started + networks: + profyt-net: + ipv4_address: "10.5.0.4" + + frontend_admin: + build: + context: . + dockerfile: frontend_admin/Dockerfile + container_name: frontend_admin + ipc: host + ports: + - 8082:5174 + environment: + - GENERIC_TIMEZONE=UTC + - VITE_DEV_PROXY_TARGET=http://be0:4402 + - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-} + volumes: + - ./package.json:/app/package.json + - ./package-lock.json:/app/package-lock.json + - ./shared:/app/shared + - ./frontend_user:/app/frontend_user + - ./frontend_admin:/app/frontend_admin + - ./frontend_investigator:/app/frontend_investigator + - ./frontend_publisher:/app/frontend_publisher + - /app/node_modules + command: ["sh", "-c", "npm install && npm run dev -w frontend_admin -- --host 0.0.0.0 --port 5174"] + depends_on: + be0: + condition: service_started + networks: + profyt-net: + ipv4_address: "10.5.0.6" + + frontend_investigator: + build: + context: . + dockerfile: frontend_investigator/Dockerfile + container_name: frontend_investigator + ipc: host + ports: + - 8083:5175 + environment: + - GENERIC_TIMEZONE=UTC + - VITE_DEV_PROXY_TARGET=http://be0:4402 + - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-} + volumes: + - ./package.json:/app/package.json + - ./package-lock.json:/app/package-lock.json + - ./shared:/app/shared + - ./frontend_user:/app/frontend_user + - ./frontend_admin:/app/frontend_admin + - ./frontend_investigator:/app/frontend_investigator + - ./frontend_publisher:/app/frontend_publisher + - /app/node_modules + command: ["sh", "-c", "npm install && npm run dev -w frontend_investigator -- --host 0.0.0.0 --port 5175"] + depends_on: + be0: + condition: service_started + networks: + profyt-net: + ipv4_address: "10.5.0.7" + + frontend_publisher: + build: + context: . + dockerfile: frontend_publisher/Dockerfile + container_name: frontend_publisher + ipc: host + ports: + - 8084:5176 + environment: + - GENERIC_TIMEZONE=UTC + - VITE_DEV_PROXY_TARGET=http://be0:4402 + - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-} + volumes: + - ./package.json:/app/package.json + - ./package-lock.json:/app/package-lock.json + - ./shared:/app/shared + - ./frontend_user:/app/frontend_user + - ./frontend_admin:/app/frontend_admin + - ./frontend_investigator:/app/frontend_investigator + - ./frontend_publisher:/app/frontend_publisher + - /app/node_modules + command: ["sh", "-c", "npm install && npm run dev -w frontend_publisher -- --host 0.0.0.0 --port 5176"] + depends_on: + be0: + condition: service_started + networks: + profyt-net: + ipv4_address: "10.5.0.8" + + be0: + build: + context: ./be0 + dockerfile: Dockerfile + container_name: be0 + ipc: host + ports: + - 4402:4402 + environment: + # Dev stack: hot-reload API when bind-mounting ./be0 + - UVICORN_RELOAD=1 + - GENERIC_TIMEZONE=UTC + - INITIATIVE_DATABASE_URL=postgresql+asyncpg://initiative:initiative_secret@postgres:5432/initiatives + - APPLICATION_DRAFT_DIR=/app/assets/application-drafts + # Shared with fe0 `public/submitted-initiatives` so PDFs written by be0 are served by Vite static. + - SUBMITTED_INITIATIVES_DIR=/app/submitted-initiatives + # From inside the be0 container, reach MinIO on the shared Docker network (not localhost:19000). + - S3_ENDPOINT_URL=http://minio:9000 + - S3_ACCESS_KEY=minio_user + - S3_SECRET_KEY=minio_password + - S3_BUCKET_ATTACHMENTS=initiative-attachments + - S3_BUCKET_EXPORTS=initiative-exports + - S3_BUCKET_QUARANTINE=initiative-quarantine + # Presigned « Xem / tải » links in the browser must hit the host-mapped MinIO port, not minio:9000. + - S3_PUBLIC_ENDPOINT_URL=${S3_PUBLIC_ENDPOINT_URL:-http://localhost:19000} + # Optional: comma-separated; merged with localhost defaults (e.g. http://YOUR_IP:8081 for LAN deploys). + - CORS_ORIGINS=${CORS_ORIGINS:-} + # Optional: comma-separated institutional admin emails. When unset, auth_api uses built-in UMP allow-list. + - AUTH_ADMIN_EMAILS=${AUTH_ADMIN_EMAILS:-} + # SMTP (Option A) — OTP + password-reset email. Set SMTP_HOST (and secrets) in repo-root .env; see .env.example. + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - AUTH_MAIL_FROM=${AUTH_MAIL_FROM:-} + - SMTP_USE_TLS=${SMTP_USE_TLS:-1} + - AUTH_PUBLIC_WEB_ORIGIN=${AUTH_PUBLIC_WEB_ORIGIN:-http://localhost:8081} + # Dev-only: OTP/link in stdout instead of SMTP — leave unset when using SMTP above. + - AUTH_MAIL_LOG_ONLY=${AUTH_MAIL_LOG_ONLY:-} + # DOCX mẫu hồ sơ (Xem lại) — cùng file với fe0/public/…/template_application_form.docx + - TEMPLATE_APPLICATION_FORM_DOCX=/app/template_application_form.docx + volumes: + - ./be0:/app + - ./assets:/app/assets + - ./assets/submitted-initiatives:/app/submitted-initiatives + - ./fe0/public/assets/template_application_form.docx:/app/template_application_form.docx:ro + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + # One-shot minio-cors must finish first so buckets exist (Compose v2.13+). + minio-cors: + condition: service_completed_successfully + networks: + profyt-net: + ipv4_address: "10.5.0.2" + +volumes: + initiative_pg_data: + +networks: + profyt-net: + driver: bridge + ipam: + config: + - subnet: "10.5.0.0/16" \ No newline at end of file diff --git a/docs/ADMIN_APPLICANT_NOTIFICATION_SYSTEM_ANALYSIS.md b/docs/ADMIN_APPLICANT_NOTIFICATION_SYSTEM_ANALYSIS.md new file mode 100644 index 0000000..39f4aa7 --- /dev/null +++ b/docs/ADMIN_APPLICANT_NOTIFICATION_SYSTEM_ANALYSIS.md @@ -0,0 +1,221 @@ +# Analysis: notification system for admin → applicant (status & feedback) + +This document describes **how the stack works today**, **what is missing** for true notifications, and a **concrete v1 path** aligned with **current repo complexity** (`fe0` / `be0` / PostgreSQL). It incorporates a **review** of the refined draft (`ADMIN_APPLICANT_NOTIFICATION_SYSTEM_ANALYSIS.md` from review) and **adjusts** a few points for this codebase. + +It complements `assets/APPLICANT_STATUS_NOTIFICATIONS_PLAN.md` (council + broader product) by anchoring on **`application_admin_results`** and **`PUT /api/applications/{applicationId}/admin-result`**. + +--- + +## 0. Evaluation of the reviewed draft (summary) + +The reviewed version improves the original repo doc in several ways; **adopt these**: + +| Theme | Verdict | +|--------|---------| +| **Locked v1 scope** | In-app inbox only; ~60s polling + `refetchOnWindowFocus`; **no** email, MinIO PDF, WebSocket, or council unification in v1. Reduces scope and matches current team capacity. | +| **Append-on-every-save** | Explicit product choice: new row per admin save; optional **UI-only** collapsing of consecutive rows. Clear and simple. | +| **Schema pragmatism** | v1 `type` with `TEXT + CHECK`; **omit `JSONB` until a second notification type** exists. Fewer columns to maintain. | +| **Indexes** | `created_at DESC` inbox index + **partial index** on `(recipient_user_id) WHERE read_at IS NULL` for unread count. Appropriate. | +| **Security** | `PATCH .../read` returns **404** for foreign rows (same as missing id) to avoid user enumeration. Good default. | +| **Helper surface** | `notification_service.create_admin_decision_notification(...)` keeps the admin route thin and future council hook consistent. | + +**Adjust for this repository (critical):** + +1. **`application_id` type** — Public application identifiers in this project are **strings** (e.g. `sub-{hex}`, case codes), not UUIDs. The notification table should use **`application_id TEXT NOT NULL`** (or `VARCHAR`) for deep links, matching `ApplicationItem.id` and API paths—not `UUID`. +2. **“try/except without rolling back the decision”** — With **`get_session()`**, everything runs in **one transaction** that **commits once** at context exit. If the notification `INSERT` fails **after** the upsert has flushed, the **entire** transaction—including the decision—rolls back on exception, unless you: + - use a **`begin_nested()` savepoint** around the notification insert (rollback to savepoint on failure, then continue), or + - perform the notification insert in a **separate short session after** the admin-result transaction **commits** (best-effort second transaction). + + The reviewed draft’s intent (“decision is sacred; notification best-effort”) is right; the implementation must use one of the patterns above, not a bare `try/except` in the same flat transaction. + +--- + +## 1. v1 scope (locked) + +- **In-app notifications only:** PostgreSQL table + applicant `GET` / `PATCH` + `/dashboard/notifications` + optional header bell. +- **Delivery:** React Query `refetchInterval: 60_000` and `refetchOnWindowFocus: true` (no SSE/WebSocket in v1). +- **Write trigger:** successful **admin-result** upsert (same product semantics as today’s `AdminStaffReadonlyReviewDialog` / `ResultManager`). +- **Deferred to v2:** email + outbox worker, MinIO/PDF letter, council `localStorage` → API unification, notification preferences, i18n beyond Vietnamese, retention jobs. + +--- + +## 2. Current state (baseline) + +### 2.1 Data and decisions + +| Layer | What exists | +|--------|-------------| +| **PostgreSQL** | `initiatives.status` ↔ **`application_admin_results`** on **`PUT …/admin-result`** (idempotent upsert). | +| **Applicant reads** | `GET /api/applications/mine`, `GET /api/applications/{id}` — status and **`nhan_xet`** can reflect admin feedback after submission enrichment. | +| **Admin** | Decided list `lifecycle=decided`; React Query invalidation on save. | +| **Council** | Some flows still use **`localStorage`**; not applicant-visible until server-backed (v2; see assets plan). | + +**Gap:** No **`user_notifications`** (or equivalent); applicants only discover changes by refetching lists/detail. + +### 2.2 Frontend + +- **Sonner:** admin-only affordance at save time. +- **React Query:** `applications`, `applications-mine`, `application-detail`, etc.—no notification queries yet. +- **`/dashboard/notifications`:** linked from sidebars; **no page implementation** observed. + +### 2.3 Backend + +- FastAPI + transactional `get_session()`; natural hook: after admin-result body returns, or inside handler with savepoint / post-commit insert (see §0). + +### 2.4 MinIO (v2 only for notifications) + +Evidence/exports buckets are **not** the source of truth for notification text. Optional v2 PDF letter remains separate from v1. + +--- + +## 3. Target architecture (v1) + +```mermaid +flowchart LR + subgraph admin [Admin UI] + A[Confirm / ResultManager] + end + subgraph be [be0] + B[PUT admin-result] + C[Notification helper] + end + subgraph db [PostgreSQL] + D[application_admin_results] + E[initiatives] + F[user_notifications] + end + subgraph fe [Applicant FE] + H[Polling + onFocus] + I[Inbox + bell] + end + A --> B + B --> D + B --> E + B --> C + C --> F + F --> H + H --> I +``` + +--- + +## 4. Database design (v1) + +### 4.1 Principles + +- **`application_admin_results`** remains canonical for full feedback/rationale. +- Notification rows are **summaries + pointer** to the public **`application_id` string** and optional FKs for audit. + +### 4.2 Table: `user_notifications` + +| Column | Type | Notes | +|--------|------|------| +| `id` | UUID PK | | +| `recipient_user_id` | UUID FK → `users.id` (`ON DELETE CASCADE`) | From `initiatives.owner_id` at insert. | +| `type` | `TEXT NOT NULL` | v1: `CHECK (type IN ('admin_application_decision'))`. | +| `title` | `TEXT NOT NULL` | e.g. “Kết quả duyệt hồ sơ”. | +| `body` | `TEXT NOT NULL` | Decision label + ~280 chars feedback (newline-stripped). | +| `application_id` | `TEXT NOT NULL` | **Public** id (`sub-…` / case-shaped), matches API list/detail. | +| `related_initiative_id` | UUID FK → `initiatives.id` (`ON DELETE SET NULL`) | | +| `source_admin_result_id` | UUID FK → `application_admin_results.id` (`ON DELETE SET NULL`) | | +| `read_at` | `TIMESTAMPTZ` nullable | | +| `created_at` | `TIMESTAMPTZ NOT NULL DEFAULT now()` | | + +**v1:** no `payload JSONB`; add when a second `type` needs extra fields. + +### 4.3 Indexes + +```sql +CREATE INDEX user_notifications_inbox_idx + ON user_notifications (recipient_user_id, created_at DESC); + +CREATE INDEX user_notifications_unread_idx + ON user_notifications (recipient_user_id) + WHERE read_at IS NULL; +``` + +### 4.4 Insertion semantics + +- **Product:** append **one row per successful admin-result save** (including typo fixes). +- **Technical:** implement **best-effort** notification with **savepoint** (`session.begin_nested()`) or **post-commit** insert so a notification failure **never** rolls back the adjudication. Document the chosen pattern in code comments next to the handler. + +--- + +## 5. Backend API (`be0`) + +### 5.1 Write path + +After **`upsert_admin_result`** succeeds, resolve `owner_id`, build title/body, insert `user_notifications` via helper using the patterns in §4.4. + +Sketch: + +```text +notification_service.create_admin_decision_notification( + session, *, initiative, admin_result_row, application_id_public: str, decision_label: str +) -> UserNotification | None +``` + +### 5.2 Read paths (applicant-only in v1) + +| Method | Purpose | +|--------|---------| +| `GET /api/notifications` | Paginated; `recipient_user_id = current user`; fields: `id`, `type`, `title`, `body`, `read_at`, `created_at`, `application_id`, `related_initiative_id`. | +| `GET /api/notifications/unread-count` | Count unread; benefits from partial index. | +| `PATCH /api/notifications/{id}/read` | Set `read_at = now()` only if row belongs to user. | + +**Authorization:** for `PATCH`, return **404** if row missing **or** not owned (same body as missing). + +### 5.3 Relationship to applications API + +Notifications complement **`GET /api/applications/{id}`** (status + feedback); they do not replace it. + +--- + +## 6. Frontend (`fe0`) + +- **Page:** implement **`/dashboard/notifications`**. +- **React Query:** `["notifications", { page }]` and `["notifications-unread-count"]`. +- **Polling:** `refetchInterval: 60_000`, `refetchOnWindowFocus: true`. +- **UX:** row click → `PATCH .../read` (optimistic unread decrement optional) → navigate using **`application_id`** string to existing applicant routes. +- **Bell:** subscribe to unread-count query; same polling cadence. +- **Admin UI:** no change required for v1 unless product adds “don’t notify” toggle later. + +--- + +## 7. Security and privacy + +- Scope all reads/patch to authenticated **recipient**. +- Do not log full notification bodies in verbose HTTP logs in production. +- `recipient_user_id` snapshot at insert time: historical rows stay with original recipient if ownership changes. + +--- + +## 8. Rollout order (v1) + +1. Migration: table + indexes. +2. SQLAlchemy model + `notification_service` helper (with savepoint or post-commit). +3. Wire **admin-result** handler. +4. Applicant `GET` list, `GET` unread-count, `PATCH` read. +5. FE: inbox page + bell. +6. Tests: notification appears after PUT (applicant token); PATCH read; PATCH foreign id → 404; **notification failure does not undo admin-result** (integration test with forced insert error). + +--- + +## 9. v2 candidates (out of scope for v1) + +| Item | Notes | +|------|------| +| Email | Outbox + worker; `user_notifications` stays canonical. | +| MinIO PDF | Generate on save; store artifact key; optional `payload` JSONB for metadata. | +| Council | New `type` + `CHECK` extension + second writer from council API. | +| Preferences / retention | Add when volume or compliance requires. | + +--- + +## 10. Relation to other docs + +- **`assets/APPLICANT_STATUS_NOTIFICATIONS_PLAN.md`** — council outcomes, workflow, broader audit. This file is the **admin-first, v1-scoped** implementation companion. + +--- + +*Update when migrations land, when the transaction strategy (savepoint vs post-commit) is fixed in code, or when v2 scope is agreed.* diff --git a/docs/ADMIN_APPLICATIONS_ADMIN_RESULT_RADICAL_FIX_PLAN.md b/docs/ADMIN_APPLICATIONS_ADMIN_RESULT_RADICAL_FIX_PLAN.md new file mode 100644 index 0000000..d06e6bd --- /dev/null +++ b/docs/ADMIN_APPLICATIONS_ADMIN_RESULT_RADICAL_FIX_PLAN.md @@ -0,0 +1,175 @@ +# Radical fix plan: admin applications, admin results, and dashboard consistency + +This document captures how the current stack is wired, what went wrong with “save decision → see it in **Kết quả đăng ký**”, and a **phased, radical** plan to make the behavior **reliable by design**—not only by patching one screen. + +--- + +## 1. Problem statement (what users experience) + +1. An admin uses **Mẫu hồ sơ và Minh chứng** → **Duyệt (xem trước)** → **Xác nhận** (`AdminStaffReadonlyReviewDialog` + `upsertAdminApplicationResult`). +2. They expect the submission to appear under **Kết quả đăng ký** (`ConsideredInitiativesList` → `ApprovedApplicationsList` with `lifecycle=decided`). +3. Sometimes nothing changes, or behaviour is confusing, because **several independent subsystems** must stay aligned: HTTP client semantics, API routes, PostgreSQL `initiatives.status`, optional file fallback, React Query keys, and optional MinIO (evidence—not the same as admin result). + +--- + +## 2. Implementation map (how it fits together) + +### 2.1 Frontend + +| Piece | Role | +|--------|------| +| `ConsideredInitiativesList` | Renders `ApprovedApplicationsList` with `lifecycle="decided"` — only rows whose **list `status`** is `approved` or `rejected`. | +| `ApprovedApplicationsList` | `useQuery(["applications", filters])` → `GET /api/applications` with filters including `lifecycle`. | +| `AdminDocxTemplatePreview` | Opens `AdminStaffReadonlyReviewDialog`; confirm calls `upsertAdminApplicationResult`. | +| `applicationAdminResultApi` | `fetch` + `create` / `update`; `upsert` = **GET then POST or PUT**. | +| `shared/api/client.ts` | Axios `validateStatus: (s) => s < 500` — **4xx responses do not throw** unless callers pass an override. | + +### 2.2 Backend + +| Piece | Role | +|--------|------| +| `POST/PUT/GET/DELETE …/admin-result` | Persists `application_admin_results` and sets **`initiatives.status`** to `approved` / `rejected` (PostgreSQL). | +| `GET /api/applications` | **Primary:** `list_submitted_applications` from Postgres. **Fallback:** `_load_submitted_items()` file index if Postgres fails or is disabled. | +| `GET /api/applications/{id}` | Same pattern: Postgres first, then file index. | +| MinIO (S3-compatible) | Evidence / attachments buckets; **not** where admin “decision rows” live. Decisions are **Postgres**; MinIO only matters for evidence flows. | + +### 2.3 Data flow (intended) + +```mermaid +sequenceDiagram + participant UI as Admin UI + participant API as be0 FastAPI + participant PG as Postgres + participant List as GET /api/applications + + UI->>API: upsert admin-result (POST or PUT) + API->>PG: application_admin_results + initiatives.status + UI->>API: invalidate + refetch applications + List->>PG: list initiatives + drafts → status approved/rejected + List-->>UI: decided list +``` + +Breakage happens when **any** step returns “success” without real data, reads from a **different backend** than writes, or treats **HTTP 404** as a valid JSON body. + +--- + +## 3. Root causes (systemic, not one-line bugs) + +### A. Global Axios: 4xx treated as success (`validateStatus < 500`) + +- For `GET /api/.../admin-result` with **no row**, the server returns **404** + `{ detail: "…" }`. +- With the default client, that **resolves** instead of rejects. +- Any code that checks `if (!data)` **fails** → truthy object `{ detail }` looks like “existing result”. +- **`upsert`** then chose **PUT** instead of **POST** on first save → no row created, **silent wrong success** possible. + +**Partial fix already applied:** pass `axiosSuccessStatusOnly` (2xx-only) on all `applicationAdminResultApi` calls. + +**Radical fix:** see §5.1 — stop relying on opt-in overrides. + +### B. Client upsert = GET + mutate (race + footguns) + +- Two round trips; duplicated server rules; easy to get wrong if GET semantics change. +- Prefer **idempotent server upsert** (`PUT` with “create or replace” semantics) **or** `POST`-only with clear 409 handling. + +### C. Dual source of truth for application lists (Postgres vs file index) + +- Listing can **silently fall back** to `_load_submitted_items()` when Postgres throws. +- Admin-result writes **only** hit Postgres. +- Result: UI can show **stale or empty** “decided” data while DB was actually updated (or the reverse in dev). + +### D. React Query key `["applications", filters]` + +- Invalidate `["applications"]` is correct for TanStack Query partial matching, but **any** cache/subscription edge case should be covered by tests. + +### E. MinIO vs admin decision (scope confusion) + +- **Fixing “Kết quả đăng ký”** does not require MinIO updates. +- Evidence upload paths are separate; do not conflate in testing or plans. + +--- + +## 4. Verification already performed (baseline) + +- **Docker:** `postgres`, `minio`, `be0` healthy; `GET /api/v1/test` OK. +- **Postgres + `create_admin_result`:** `initiatives.status` and `application_admin_results` stay in sync when using the Python layer directly. +- **Integration test `test_applications_db_integration`:** one failing test (`get_application_by_id` fallback `sub-…` without `submissionRecord.id`) — suggests **ID-resolution** edge cases still risky; align with list/GET contract in the same plan. +- **Host Python** may lack `boto3`; validate MinIO from **`be0` container** or install dev deps locally for S3 tests. + +--- + +## 5. Radical fixes (options, from smaller to larger) + +### 5.1 Frontend: default to real HTTP semantics (highest leverage) + +**Goal:** No API call “succeeds” with a 404/422 body unless explicitly handled. + +**Options:** + +1. **Change default `validateStatus` to 2xx-only** in `ApiClient`, then **globally fix** call sites that depended on reading `{ detail }` from a resolved 4xx response (likely few; grep for patterns). +2. **Two clients:** `apiClientStrict` (2xx-only) for CRUD and `apiClient` legacy only where needed—migrate modules incrementally. +3. **Response interceptor:** if `status >= 400`, reject with unified `ApiError` (preserves current “no throw on 4xx” idea but **never** returns `res.data` as success to `.then()`). + +**Acceptance:** ESLint rule or CI script: forbid `apiClient.get/post/put/delete` without explicit `validateStatus` or wrapper. + +### 5.2 Backend: atomic admin-result upsert + +**Goal:** Single request, no client-side GET-before-POST. + +- Expose **`PUT /api/applications/{id}/admin-result`** as **idempotent upsert** (create or update in one transaction), or document **`POST`** as upsert with unique constraint handling. +- Optionally return **updated application row** snippet (`status`, `applicationId`) so the client can patch cache without listing. + +### 5.3 Backend: single listing source in production + +**Goal:** No silent list fallback when `INITIATIVE_DATABASE_URL` is set. + +- On Postgres enabled: **fail listing with 503** and a clear JSON error instead of falling back to files; or log + metrics + feature flag. +- Deprecate file index for `/api/applications` in environments where submissions are always in PG. + +### 5.4 Contract tests (API + FE) + +- **pytest/httpx:** `POST admin-result` → `GET /api/applications?lifecycle=decided` contains that `id`. +- **Playwright or MSW:** Admin flow confirm → list row appears (requires auth fixture). + +### 5.5 Observability + +- Structured logs: `application_id`, `initiative_id`, `decision`, `source=postgres|file_fallback`. +- Metrics: `applications_list_fallback_total`, `admin_result_upsert_duration_ms`. + +--- + +## 6. Recommended phases (practical rollout) + +| Phase | Scope | Outcome | +|-------|--------|---------| +| **P0** | Keep `axiosSuccessStatusOnly` on admin-result API; add **one** e2e/API test: upsert → decided list. | Regression guard. | +| **P1** | Introduce **strict HTTP** default or interceptor (§5.1); fix broken call sites. | Class of bugs eliminated. | +| **P2** | Backend **idempotent PUT** upsert; simplify client to single call. | Fewer races, simpler mental model. | +| **P3** | Remove or gate **file fallback** for `/api/applications` when PG is configured. | Align list with admin writes. | +| **P4** | Fix failing DB test for `submissionRecord.id` omission; document canonical `applicationId` rules. | Predictable IDs end-to-end. | + +--- + +## 7. Out of scope / non-goals + +- **MinIO** consistency for “admin approve” — wrong layer unless the feature explicitly writes objects on decision. +- **Council** flow (`saveCouncilReviewOutcome` / local storage) — separate product path; only mention if merging with admin outcomes. + +--- + +## 8. Decision log (fill in as you implement) + +| Date | Decision | Rationale | +|------|----------|-----------| +| | | | + +--- + +## References (code) + +- `fe0/src/shared/api/client.ts` — global `validateStatus`. +- `fe0/src/lib/applicationReviewApi.ts` — `axiosSuccessStatusOnly`. +- `fe0/src/lib/applicationAdminResultApi.ts` — admin-result CRUD + upsert. +- `fe0/src/components/admin/result/ConsideredInitiativesList.tsx` — decided list entry point. +- `be0/src/initiative_db/application_admin_results.py` — DB writes + `initiative.status`. +- `be0/main.py` — `list_applications` Postgres vs file fallback. +- `be0/tests/test_applications_db_integration.py` — Postgres integration tests. diff --git a/docs/ARCHITECTURE_REDESIGN.md b/docs/ARCHITECTURE_REDESIGN.md new file mode 100644 index 0000000..d39f630 --- /dev/null +++ b/docs/ARCHITECTURE_REDESIGN.md @@ -0,0 +1,871 @@ +# Architecture Redesign Proposal + +## Overview + +This document outlines a comprehensive architectural redesign for the ProfytAI Compliance Management Platform, addressing critical issues identified in the current implementation. + +## Design Principles + +1. **Separation of Concerns**: Clear boundaries between layers +2. **Dependency Injection**: Loose coupling, easy testing +3. **Domain-Driven Design**: Business logic in domain layer +4. **Security First**: Authentication, authorization, input validation +5. **Testability**: All components should be easily testable +6. **Scalability**: Support for horizontal scaling +7. **Maintainability**: Clear structure, minimal complexity + +--- + +## Proposed Architecture: Layered Architecture with Clean Architecture Principles + +``` +┌─────────────────────────────────────────────────────────┐ +│ Presentation Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ API Routes │ │ Middleware │ │ WebSocket │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Services │ │ Use Cases │ │ DTOs │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────┐ +│ Domain Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Entities │ │ Interfaces │ │ Value Obj. │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────┐ +│ Infrastructure Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Repositories │ │ External │ │ Config │ │ +│ │ │ │ Services │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## New Directory Structure + +``` +be0/ +├── src/ +│ ├── api/ # API Layer +│ │ ├── __init__.py +│ │ ├── dependencies.py # Dependency injection +│ │ ├── middleware/ +│ │ │ ├── __init__.py +│ │ │ ├── auth.py # Authentication middleware +│ │ │ ├── cors.py # CORS configuration +│ │ │ ├── rate_limit.py # Rate limiting +│ │ │ └── error_handler.py # Global error handling +│ │ ├── routes/ +│ │ │ ├── __init__.py +│ │ │ ├── workflows.py # Workflow endpoints +│ │ │ ├── documents.py # Document endpoints +│ │ │ ├── compliance.py # Compliance endpoints +│ │ │ ├── health.py # Health check +│ │ │ └── auth.py # Authentication endpoints +│ │ └── schemas/ # Request/Response schemas +│ │ ├── __init__.py +│ │ ├── workflow.py +│ │ ├── document.py +│ │ └── compliance.py +│ │ +│ ├── application/ # Application Layer +│ │ ├── __init__.py +│ │ ├── services/ +│ │ │ ├── __init__.py +│ │ │ ├── workflow_service.py +│ │ │ ├── document_service.py +│ │ │ ├── compliance_service.py +│ │ │ └── ai_service.py +│ │ ├── use_cases/ +│ │ │ ├── __init__.py +│ │ │ ├── create_workflow.py +│ │ │ ├── update_workflow_item.py +│ │ │ ├── analyze_compliance.py +│ │ │ └── process_document.py +│ │ └── dto/ # Data Transfer Objects +│ │ ├── __init__.py +│ │ ├── workflow_dto.py +│ │ └── compliance_dto.py +│ │ +│ ├── domain/ # Domain Layer +│ │ ├── __init__.py +│ │ ├── entities/ +│ │ │ ├── __init__.py +│ │ │ ├── workflow.py +│ │ │ ├── workflow_item.py +│ │ │ ├── document.py +│ │ │ └── compliance_rule.py +│ │ ├── value_objects/ +│ │ │ ├── __init__.py +│ │ │ ├── task_status.py +│ │ │ └── workflow_phase.py +│ │ ├── interfaces/ # Repository interfaces +│ │ │ ├── __init__.py +│ │ │ ├── workflow_repository.py +│ │ │ ├── document_repository.py +│ │ │ └── compliance_repository.py +│ │ └── exceptions/ +│ │ ├── __init__.py +│ │ ├── domain_exceptions.py +│ │ └── service_exceptions.py +│ │ +│ ├── infrastructure/ # Infrastructure Layer +│ │ ├── __init__.py +│ │ ├── database/ +│ │ │ ├── __init__.py +│ │ │ ├── connection.py # DB connection pool +│ │ │ ├── repositories/ +│ │ │ │ ├── __init__.py +│ │ │ │ ├── workflow_repository_impl.py +│ │ │ │ ├── document_repository_impl.py +│ │ │ │ └── neo4j_repository.py +│ │ │ └── migrations/ +│ │ ├── external/ +│ │ │ ├── __init__.py +│ │ │ ├── ollama_client.py # Ollama service client +│ │ │ └── storage/ +│ │ │ ├── __init__.py +│ │ │ └── file_storage.py # File storage abstraction +│ │ ├── config/ +│ │ │ ├── __init__.py +│ │ │ ├── settings.py # Pydantic settings +│ │ │ └── logging_config.py +│ │ └── security/ +│ │ ├── __init__.py +│ │ ├── auth.py # JWT, password hashing +│ │ └── permissions.py +│ │ +│ ├── core/ # Core utilities +│ │ ├── __init__.py +│ │ ├── logging.py +│ │ ├── exceptions.py +│ │ └── constants.py +│ │ +│ └── main.py # Application entry point +│ +├── tests/ # Test suite +│ ├── __init__.py +│ ├── unit/ +│ │ ├── domain/ +│ │ ├── application/ +│ │ └── infrastructure/ +│ ├── integration/ +│ │ ├── api/ +│ │ └── database/ +│ ├── fixtures/ +│ └── conftest.py +│ +├── alembic/ # Database migrations +│ ├── versions/ +│ └── env.py +│ +├── requirements.txt +├── requirements-dev.txt +├── .env.example +└── Dockerfile +``` + +--- + +## Key Architectural Components + +### 1. API Layer (Presentation) + +**Purpose**: Handle HTTP requests, validate input, return responses + +**Responsibilities**: +- Route definitions +- Request/Response serialization +- Input validation +- Authentication/Authorization checks +- Error handling + +### 2. Application Layer + +**Purpose**: Orchestrate business logic, coordinate between domain and infrastructure + +**Responsibilities**: +- Use case implementation +- Service orchestration +- DTO transformation +- Transaction management + +### 3. Domain Layer + +**Purpose**: Core business logic, entities, and business rules + +**Responsibilities**: +- Domain entities +- Business rules +- Value objects +- Domain events +- Repository interfaces (abstractions) + +### 4. Infrastructure Layer + +**Purpose**: External concerns - database, file system, external APIs + +**Responsibilities**: +- Database access +- External API clients +- File storage +- Configuration +- Security implementation + +--- + +## Implementation Examples + +### Example 1: Configuration Management + +```python +# infrastructure/config/settings.py +from pydantic_settings import BaseSettings +from typing import List + +class Settings(BaseSettings): + # Application + app_name: str = "ProfytAI Compliance Platform" + app_version: str = "1.0.0" + debug: bool = False + + # Server + host: str = "0.0.0.0" + port: int = 4402 + + # Database + neo4j_uri: str + neo4j_user: str + neo4j_password: str + + # Security + secret_key: str + algorithm: str = "HS256" + access_token_expire_minutes: int = 30 + cors_origins: List[str] = [] + + # AI/ML + ollama_base_url: str = "http://localhost:11434" + ollama_model: str = "gemma3:27b" + embedding_model: str = "embeddinggemma:300m" + + # Storage + upload_dir: str = "./assets/data/uploads" + max_upload_size: int = 10 * 1024 * 1024 # 10MB + + # Rate Limiting + rate_limit_per_minute: int = 60 + + class Config: + env_file = ".env" + case_sensitive = False + +settings = Settings() +``` + +### Example 2: Domain Entity + +```python +# domain/entities/workflow.py +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Optional +from uuid import UUID, uuid4 +from domain.value_objects.task_status import TaskStatus +from domain.value_objects.workflow_phase import WorkflowPhase + +@dataclass +class WorkflowItem: + id: int + task: str + status: TaskStatus + requires_approval: bool + approver: Optional[str] = None + comment: Optional[str] = None + updated_by: Optional[str] = None + updated_at: Optional[datetime] = None + +@dataclass +class Workflow: + id: UUID + project_name: str + project_description: Optional[str] + records_officer_email: Optional[str] + current_phase: WorkflowPhase + checklist_items: List[WorkflowItem] = field(default_factory=list) + completed_items: List[int] = field(default_factory=list) + pending_approvals: List[str] = field(default_factory=list) + comments: dict = field(default_factory=dict) + validation_results: dict = field(default_factory=dict) + created_at: datetime = field(default_factory=datetime.utcnow) + updated_at: datetime = field(default_factory=datetime.utcnow) + + def add_item(self, item: WorkflowItem) -> None: + """Add a checklist item to the workflow.""" + self.checklist_items.append(item) + self.updated_at = datetime.utcnow() + + def update_item_status( + self, + item_id: int, + status: TaskStatus, + updated_by: str, + comment: Optional[str] = None + ) -> None: + """Update the status of a workflow item.""" + item = next((i for i in self.checklist_items if i.id == item_id), None) + if not item: + raise ValueError(f"Item {item_id} not found") + + item.status = status + item.updated_by = updated_by + item.updated_at = datetime.utcnow() + if comment: + item.comment = comment + + if status == TaskStatus.COMPLETED and item_id not in self.completed_items: + self.completed_items.append(item_id) + + self.updated_at = datetime.utcnow() + + def can_advance_phase(self) -> bool: + """Check if workflow can advance to next phase.""" + all_completed = all( + item.status == TaskStatus.COMPLETED + for item in self.checklist_items + ) + no_pending_approvals = len(self.pending_approvals) == 0 + return all_completed and no_pending_approvals + + @property + def completion_percentage(self) -> float: + """Calculate completion percentage.""" + if not self.checklist_items: + return 0.0 + completed = len(self.completed_items) + total = len(self.checklist_items) + return (completed / total) * 100 +``` + +### Example 3: Repository Interface (Domain) + +```python +# domain/interfaces/workflow_repository.py +from abc import ABC, abstractmethod +from typing import List, Optional +from uuid import UUID +from domain.entities.workflow import Workflow + +class IWorkflowRepository(ABC): + """Repository interface for workflow persistence.""" + + @abstractmethod + async def create(self, workflow: Workflow) -> Workflow: + """Create a new workflow.""" + pass + + @abstractmethod + async def get_by_id(self, workflow_id: UUID) -> Optional[Workflow]: + """Get workflow by ID.""" + pass + + @abstractmethod + async def get_all(self, skip: int = 0, limit: int = 100) -> List[Workflow]: + """Get all workflows with pagination.""" + pass + + @abstractmethod + async def update(self, workflow: Workflow) -> Workflow: + """Update an existing workflow.""" + pass + + @abstractmethod + async def delete(self, workflow_id: UUID) -> bool: + """Delete a workflow.""" + pass +``` + +### Example 4: Repository Implementation (Infrastructure) + +```python +# infrastructure/database/repositories/workflow_repository_impl.py +from typing import List, Optional +from uuid import UUID +from domain.entities.workflow import Workflow +from domain.interfaces.workflow_repository import IWorkflowRepository +from infrastructure.database.connection import get_db_session +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +class WorkflowRepository(IWorkflowRepository): + """Neo4j implementation of workflow repository.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, workflow: Workflow) -> Workflow: + """Create workflow in Neo4j.""" + query = """ + CREATE (w:Workflow { + id: $id, + project_name: $project_name, + project_description: $project_description, + records_officer_email: $records_officer_email, + current_phase: $current_phase, + created_at: $created_at, + updated_at: $updated_at + }) + RETURN w + """ + # Implementation details... + return workflow + + async def get_by_id(self, workflow_id: UUID) -> Optional[Workflow]: + """Get workflow by ID from Neo4j.""" + query = """ + MATCH (w:Workflow {id: $workflow_id}) + OPTIONAL MATCH (w)-[:HAS_ITEM]->(i:WorkflowItem) + RETURN w, collect(i) as items + """ + # Implementation details... + pass + + # ... other methods +``` + +### Example 5: Service Layer + +```python +# application/services/workflow_service.py +from typing import List, Optional +from uuid import UUID +from domain.entities.workflow import Workflow, WorkflowItem +from domain.interfaces.workflow_repository import IWorkflowRepository +from domain.value_objects.workflow_phase import WorkflowPhase +from domain.value_objects.task_status import TaskStatus +from domain.exceptions.domain_exceptions import WorkflowNotFoundError + +class WorkflowService: + """Service for workflow business logic.""" + + def __init__(self, workflow_repository: IWorkflowRepository): + self.workflow_repository = workflow_repository + + async def create_workflow( + self, + project_name: str, + project_description: Optional[str], + records_officer_email: Optional[str] + ) -> Workflow: + """Create a new workflow with initial phase.""" + workflow = Workflow( + id=UUID(), + project_name=project_name, + project_description=project_description, + records_officer_email=records_officer_email, + current_phase=WorkflowPhase.CONCEPT_DEVELOPMENT + ) + + # Initialize Phase 1 items + phase1_items = self._get_phase1_items() + for item in phase1_items: + workflow.add_item(item) + + return await self.workflow_repository.create(workflow) + + async def get_workflow(self, workflow_id: UUID) -> Workflow: + """Get workflow by ID.""" + workflow = await self.workflow_repository.get_by_id(workflow_id) + if not workflow: + raise WorkflowNotFoundError(f"Workflow {workflow_id} not found") + return workflow + + async def update_workflow_item( + self, + workflow_id: UUID, + item_id: int, + status: TaskStatus, + updated_by: str, + comment: Optional[str] = None + ) -> Workflow: + """Update a workflow item.""" + workflow = await self.get_workflow(workflow_id) + workflow.update_item_status(item_id, status, updated_by, comment) + return await self.workflow_repository.update(workflow) + + async def advance_workflow(self, workflow_id: UUID) -> Workflow: + """Advance workflow to next phase.""" + workflow = await self.get_workflow(workflow_id) + + if not workflow.can_advance_phase(): + raise ValueError("Cannot advance: Phase requirements not met") + + # Advance to next phase logic... + return await self.workflow_repository.update(workflow) + + def _get_phase1_items(self) -> List[WorkflowItem]: + """Get Phase 1 checklist items.""" + return [ + WorkflowItem( + id=1, + task="Include Records Officer in system design process", + status=TaskStatus.PENDING, + requires_approval=True, + approver="Records Officer" + ), + # ... more items + ] +``` + +### Example 6: API Route with Dependency Injection + +```python +# api/routes/workflows.py +from fastapi import APIRouter, Depends, HTTPException, status +from uuid import UUID +from typing import List +from api.schemas.workflow import ( + WorkflowCreateRequest, + WorkflowResponse, + WorkflowItemUpdateRequest +) +from application.services.workflow_service import WorkflowService +from api.dependencies import get_workflow_service, get_current_user +from domain.value_objects.task_status import TaskStatus + +router = APIRouter(prefix="/workflows", tags=["workflows"]) + +@router.post("", response_model=WorkflowResponse, status_code=status.HTTP_201_CREATED) +async def create_workflow( + request: WorkflowCreateRequest, + workflow_service: WorkflowService = Depends(get_workflow_service), + current_user = Depends(get_current_user) +): + """Create a new workflow.""" + try: + workflow = await workflow_service.create_workflow( + project_name=request.project_name, + project_description=request.project_description, + records_officer_email=request.records_officer_email + ) + return WorkflowResponse.from_entity(workflow) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + +@router.get("/{workflow_id}", response_model=WorkflowResponse) +async def get_workflow( + workflow_id: UUID, + workflow_service: WorkflowService = Depends(get_workflow_service), + current_user = Depends(get_current_user) +): + """Get workflow by ID.""" + try: + workflow = await workflow_service.get_workflow(workflow_id) + return WorkflowResponse.from_entity(workflow) + except WorkflowNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Workflow not found" + ) + +@router.put("/{workflow_id}/items", response_model=WorkflowResponse) +async def update_workflow_item( + workflow_id: UUID, + request: WorkflowItemUpdateRequest, + workflow_service: WorkflowService = Depends(get_workflow_service), + current_user = Depends(get_current_user) +): + """Update a workflow item.""" + try: + workflow = await workflow_service.update_workflow_item( + workflow_id=workflow_id, + item_id=request.item_id, + status=TaskStatus(request.status), + updated_by=current_user.email, + comment=request.comment + ) + return WorkflowResponse.from_entity(workflow) + except WorkflowNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Workflow not found" + ) +``` + +### Example 7: Dependency Injection Setup + +```python +# api/dependencies.py +from functools import lru_cache +from infrastructure.database.connection import get_db_session +from infrastructure.database.repositories.workflow_repository_impl import WorkflowRepository +from application.services.workflow_service import WorkflowService +from infrastructure.external.ollama_client import OllamaClient +from application.services.compliance_service import ComplianceService +from infrastructure.config.settings import settings + +# Repository dependencies +async def get_workflow_repository(): + async for session in get_db_session(): + yield WorkflowRepository(session) + +# Service dependencies +def get_workflow_service( + workflow_repo: WorkflowRepository = Depends(get_workflow_repository) +) -> WorkflowService: + return WorkflowService(workflow_repo) + +def get_compliance_service() -> ComplianceService: + ollama_client = OllamaClient( + base_url=settings.ollama_base_url, + model=settings.ollama_model + ) + return ComplianceService(ollama_client) + +# Auth dependencies +async def get_current_user( + token: str = Depends(oauth2_scheme) +): + # JWT validation logic + pass +``` + +### Example 8: Main Application Setup + +```python +# main.py +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from infrastructure.config.settings import settings +from infrastructure.config.logging_config import setup_logging +from api.middleware.error_handler import setup_exception_handlers +from api.middleware.cors import setup_cors +from api.routes import workflows, documents, compliance, health, auth + +# Setup logging +setup_logging() + +# Create FastAPI app +app = FastAPI( + title=settings.app_name, + version=settings.app_version, + debug=settings.debug +) + +# Setup middleware +setup_cors(app, settings.cors_origins) +setup_exception_handlers(app) + +# Include routers +app.include_router(auth.router) +app.include_router(workflows.router) +app.include_router(documents.router) +app.include_router(compliance.router) +app.include_router(health.router) + +@app.on_event("startup") +async def startup_event(): + """Initialize services on startup.""" + # Initialize database connections + # Initialize external services + pass + +@app.on_event("shutdown") +async def shutdown_event(): + """Cleanup on shutdown.""" + # Close database connections + # Cleanup resources + pass +``` + +--- + +## Security Improvements + +### 1. Authentication & Authorization + +```python +# infrastructure/security/auth.py +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from infrastructure.config.settings import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash.""" + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + """Hash a password.""" + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """Create JWT access token.""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.access_token_expire_minutes + ) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode( + to_encode, + settings.secret_key, + algorithm=settings.algorithm + ) + return encoded_jwt +``` + +### 2. CORS Configuration + +```python +# api/middleware/cors.py +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from typing import List + +def setup_cors(app: FastAPI, allowed_origins: List[str]): + """Configure CORS middleware.""" + app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, # Specific origins, not "*" + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"], + allow_headers=["Content-Type", "Authorization"], + ) +``` + +### 3. Rate Limiting + +```python +# api/middleware/rate_limit.py +from fastapi import Request, HTTPException, status +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded + +limiter = Limiter(key_func=get_remote_address) + +@router.post("") +@limiter.limit("10/minute") # 10 requests per minute +async def create_workflow(request: Request, ...): + # Implementation + pass +``` + +--- + +## Testing Structure + +```python +# tests/conftest.py +import pytest +from fastapi.testclient import TestClient +from main import app +from infrastructure.database.connection import get_test_db + +@pytest.fixture +def client(): + return TestClient(app) + +@pytest.fixture +def test_db(): + # Setup test database + yield + # Teardown + +# tests/unit/application/services/test_workflow_service.py +import pytest +from uuid import UUID +from application.services.workflow_service import WorkflowService +from domain.entities.workflow import Workflow + +@pytest.mark.asyncio +async def test_create_workflow(): + # Mock repository + mock_repo = MockWorkflowRepository() + service = WorkflowService(mock_repo) + + workflow = await service.create_workflow( + project_name="Test Project", + project_description="Test Description", + records_officer_email="test@example.com" + ) + + assert workflow.project_name == "Test Project" + assert workflow.current_phase == WorkflowPhase.CONCEPT_DEVELOPMENT +``` + +--- + +## Migration Strategy + +### Phase 1: Foundation (Week 1-2) +1. Create new directory structure +2. Set up configuration management +3. Implement dependency injection +4. Set up database connection + +### Phase 2: Domain Layer (Week 3) +1. Create domain entities +2. Define repository interfaces +3. Implement value objects + +### Phase 3: Infrastructure (Week 4) +1. Implement repository classes +2. Set up external service clients +3. Configure security + +### Phase 4: Application Layer (Week 5) +1. Create service classes +2. Implement use cases +3. Create DTOs + +### Phase 5: API Layer (Week 6) +1. Create route modules +2. Implement middleware +3. Set up error handling + +### Phase 6: Testing & Migration (Week 7-8) +1. Write unit tests +2. Write integration tests +3. Migrate existing endpoints +4. Deploy and monitor + +--- + +## Benefits of This Architecture + +1. **Testability**: Each layer can be tested independently +2. **Maintainability**: Clear separation of concerns +3. **Scalability**: Easy to add new features +4. **Security**: Built-in security at every layer +5. **Flexibility**: Easy to swap implementations (e.g., different databases) +6. **Team Collaboration**: Different teams can work on different layers + +--- + +## Next Steps + +1. Review and approve this architecture +2. Create detailed implementation plan +3. Set up project structure +4. Begin Phase 1 implementation +5. Establish coding standards and review process diff --git a/docs/ARCHITECTURE_SUMMARY.md b/docs/ARCHITECTURE_SUMMARY.md new file mode 100644 index 0000000..993853c --- /dev/null +++ b/docs/ARCHITECTURE_SUMMARY.md @@ -0,0 +1,94 @@ +# Architecture Redesign Summary + +## Quick Overview + +This document provides a quick reference for the architectural improvements proposed for the ProfytAI Compliance Management Platform. + +## Key Improvements + +### 1. **Layered Architecture** +- **API Layer**: HTTP request handling, validation, serialization +- **Application Layer**: Business logic orchestration, use cases +- **Domain Layer**: Core entities, business rules, interfaces +- **Infrastructure Layer**: Database, external services, configuration + +### 2. **Dependency Injection** +- Services depend on interfaces, not implementations +- Easy to test with mocks +- Flexible to swap implementations + +### 3. **Configuration Management** +- Type-safe settings with Pydantic +- Environment variable support +- Centralized configuration + +### 4. **Security** +- JWT authentication +- CORS with specific origins +- Rate limiting +- Input validation at every layer + +### 5. **Database Integration** +- Repository pattern +- Neo4j integration ready +- Migration support + +## File Structure Comparison + +### Before (Current) +``` +be0/ +├── main.py (545 lines - everything in one file) +├── src/ +│ ├── compliance_verifier.py +│ └── utils.py +``` + +### After (Proposed) +``` +be0/ +├── main.py (clean entry point) +├── src/ +│ ├── api/ (routes, middleware, schemas) +│ ├── application/ (services, use cases) +│ ├── domain/ (entities, interfaces) +│ ├── infrastructure/ (database, external, config) +│ └── core/ (utilities) +``` + +## Migration Checklist + +- [ ] Create new directory structure +- [ ] Set up configuration management +- [ ] Implement domain entities +- [ ] Create repository interfaces +- [ ] Implement repository classes +- [ ] Create service layer +- [ ] Split routes into modules +- [ ] Add authentication/authorization +- [ ] Implement error handling +- [ ] Add tests +- [ ] Update documentation + +## Benefits + +1. **Maintainability**: Clear structure, easy to find code +2. **Testability**: Each layer can be tested independently +3. **Scalability**: Easy to add new features +4. **Security**: Built-in at every layer +5. **Team Collaboration**: Different teams can work on different layers + +## Next Steps + +1. Review `ARCHITECTURE_REDESIGN.md` for detailed design +2. Review code examples in `be0/src/` +3. Plan migration timeline +4. Start with Phase 1 (Foundation) + +## Questions? + +Refer to the detailed `ARCHITECTURE_REDESIGN.md` document for: +- Complete architecture explanation +- Code examples +- Migration strategy +- Best practices diff --git a/docs/FIX_CHAT_ERROR.md b/docs/FIX_CHAT_ERROR.md new file mode 100644 index 0000000..c06f8d8 --- /dev/null +++ b/docs/FIX_CHAT_ERROR.md @@ -0,0 +1,139 @@ +# Fix Chat Assistant 500 Error + +## Issue +Getting 500 Internal Server Error when calling `/api/v1/chat` endpoint. + +## Root Causes + +1. **Model Name Mismatch** ✅ FIXED + - Code was using `gemma3:27b` but entrypoint pulls `gemma3:270M` + - **Fixed**: Updated code to use `gemma3:270M` + +2. **Ollama Not Running** + - Ollama service might not be started in the container + - Network connectivity issues + +3. **Model Not Available** + - Model might not be pulled yet + - Model name incorrect + +## Solutions + +### Solution 1: Restart the Container + +```bash +# Stop and restart the backend container +docker-compose down +docker-compose up -d be0 + +# Wait for Ollama to start (check logs) +docker-compose logs -f be0 +``` + +### Solution 2: Check Ollama Status + +```bash +# Check if container is running +docker ps | grep be0 + +# Check Ollama inside container +docker exec be0 ollama list + +# If Ollama is not running, start it +docker exec be0 ollama serve & +``` + +### Solution 3: Pull the Model + +```bash +# Pull the required model +docker exec be0 ollama pull gemma3:270M + +# Verify it's available +docker exec be0 ollama list | grep gemma3 +``` + +### Solution 4: Test the Health Endpoint + +```bash +# Check health endpoint (includes Ollama status) +curl http://localhost:4402/health + +# Should show: +# { +# "status": "healthy", +# "ollama": { +# "status": "connected", +# "available_models": ["gemma3:270M", ...] +# } +# } +``` + +### Solution 5: Check Backend Logs + +```bash +# View recent logs +docker-compose logs be0 | tail -50 + +# View ChatAssistant specific logs +tail -f be0/logs/ChatAssistant.log +``` + +## Quick Fix Commands + +```bash +# 1. Restart everything +docker-compose restart be0 + +# 2. Check Ollama +docker exec be0 ollama list + +# 3. Test health +curl http://localhost:4402/health + +# 4. Test chat endpoint +curl -X POST http://localhost:4402/api/v1/chat \ + -H "Content-Type: application/json" \ + -d '{"message": "Hello"}' +``` + +## What Was Fixed + +1. ✅ Model name changed from `gemma3:27b` to `gemma3:270M` +2. ✅ Added better error handling with specific error messages +3. ✅ Added Ollama connection check on initialization +4. ✅ Added health endpoint with Ollama status +5. ✅ Improved logging for debugging + +## Expected Behavior After Fix + +1. Container starts and Ollama service runs +2. Model `gemma3:270M` is available +3. Health endpoint shows Ollama as "connected" +4. Chat endpoint returns 200 with AI response + +## If Still Not Working + +1. **Check container logs:** + ```bash + docker-compose logs be0 + ``` + +2. **Check if Ollama is accessible:** + ```bash + docker exec be0 curl http://localhost:11434/api/tags + ``` + +3. **Manually start Ollama:** + ```bash + docker exec -d be0 ollama serve + sleep 2 + docker exec be0 ollama list + ``` + +4. **Rebuild container:** + ```bash + docker-compose down + docker-compose build be0 + docker-compose up be0 + ``` diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..be9575f --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,41 @@ +# HANDOFF — SciAgent / ImageHub +_Updated: 2026-06-29 (session-end — Gitea Actions CI/CD pipeline) · branch `main` · **40 commits LOCAL/unpushed** · 🟢 HMW-mode OFF_ + +## TL;DR +- Stood up the repo's **first CI/CD** — **Gitea Actions** on the self-hosted box `103.149.170.102:3000` (Gitea 1.26.2). Previously deploy was manual Docker Compose, **no CI**. +- Pipeline `.gitea/workflows/ci-cd.yml` = **backend** (per-file pytest + throwaway Postgres) · **frontend** (typecheck/build/vitest across workspaces) · **deploy** (host-mode `docker compose up -d` on push to main). Local commit `c2e869b`. +- **One hard gate left: NO act_runner is installed** → all runs queue, nothing executes/deploys. User must run `scripts/setup-gitea-runner.sh` on the box (I have no SSH there). + +## Shipped this session — commit `c2e869b` (local only) +- **`.gitea/workflows/ci-cd.yml`** — 3 jobs. backend: `pip install be0/requirements-dev.txt` then **pytest PER FILE** (loop) vs a `postgres:16-alpine` service (per-file avoids asyncpg cross-module event-loop contamination, [[be0-test-harness-reality]]). frontend: node 20, `npm ci`, `npm run typecheck` + `build`, `npm test --workspaces --if-present` (vitest in shared/investigator/publisher). deploy (`runs-on: deploy`, host): clone/reset **persistent `/srv/sciagent`** (NOT ephemeral — prod compose bind-mounts `./assets/minio-data`+`./be0`), write `.env` from secret `PROD_ENV`, `deploy-prod.sh --no-pull` + `check-prod-stack.sh`. +- **`be0/requirements-dev.txt`** — pytest + pytest-asyncio (neither was pinned). +- **`scripts/setup-gitea-runner.sh`** — act_runner 0.2.11 bootstrap (Docker+compose+node+systemd, labels `ci:docker://catthehacker/ubuntu:act-22.04,deploy:host`). ⚠️ runner registration token baked in (already public on Gitea mirror; rotatable). +- **Done via Gitea admin API (keychain user `oneness`, is_admin):** enabled Actions unit · stored secret `PROD_ENV` (valid prod `.env`, `PUBLIC_HOST=103.149.170.102`, fresh hex PG/MinIO pw + b64 JWT, `AUTH_MAIL_LOG_ONLY=1` placeholder) · minted runner token · pushed workflow+reqs to Gitea (workflow `state: active`). +- **Mirror refreshed** to current code: Gitea `main` now a **1212-file clean snapshot** (was 2026-06-14 / 965 files; now incl. all 4 monorepo FEs + the workflow). Leak-checked clean. Detail: [[gitea-cicd-pipeline]], [[gitea-mirror-and-tracked-secrets]]. + +## Current state +- Migrations 001…027 · 6 be0 routers · monorepo 4 FEs (`fe0` legacy standalone) + `@ump/shared`. +- Gitea workflow active; **runners online: 0**. PROD_ENV set; SMTP unfilled. +- Verify this session = artifact-level only (bash -n, pip syntax, YAML parse) — **no app code changed**, so BE/FE suites not re-run. + +## Next — P1 (start here) +1. **Install the runner** (user, needs root on the box — I have no SSH): `curl -fsSL http://103.149.170.102:3000/tlam89/sciagent/raw/branch/main/scripts/setup-gitea-runner.sh | sudo bash`. Then ping me → I verify it's Online (API) + watch the first run (backend→frontend→deploy), report PASS/FAIL with logs. +2. **Fill SMTP** in `PROD_ENV` secret (else OTP/reset mail only logs). Give me `SMTP_*` → I update the secret via API. +3. (Decision) fe0 vs frontend_user port role — deferred this session (fe0 NOT deployed; user confirmed it was a slip). + +## Open threads / risks +- 🔴 **NO runner = pipeline does nothing.** This is the blocker for all execution/deploy. +- 🔴 **40 commits LOCAL/unpushed to origin** — push to GitHub origin BLOCKED (history has `.env` secrets + 1.8 GB PII `assets/` → rotate + `git filter-repo` first). Gitea mirror is current; origin is not. Do NOT `git push origin`. +- First deploy = **fresh empty stack** (new Postgres via initdb migrations, empty MinIO) — no dev data carried over (assets/ excluded by design). +- Caught near-miss (documented): `git add -A` + `:(exclude)assets` did NOT exclude → leak-check stopped it pre-push. Reliable mirror method now in [[gitea-mirror-and-tracked-secrets]]. +- CLAUDE.md still STALE (says "no CI"; says migr 014 / 3 routers / `fe0`). + +## Quick commands +- Gitea API (admin): `CRED=$(printf 'protocol=http\nhost=103.149.170.102:3000\n\n'|git credential fill); U=…;P=…` then `curl -u $U:$P http://103.149.170.102:3000/api/v1/repos/tlam89/sciagent/actions/runners` (check online) / `…/actions/tasks` (runs). +- Runner install (on box, root): see P1 #1. +- Re-mint runner token: `curl -s -X POST -u $U:$P http://103.149.170.102:3000/api/v1/repos/tlam89/sciagent/actions/runners/registration-token`. + +## Reality flags +- CI lives on **Gitea** (`103.149.170.102:3000`), NOT GitHub. Push to Gitea = clean orphan snapshot convention (excl `.env`/`assets`/`.claude`/`CLAUDE.md`). Origin (GitHub) push stays blocked. +- **Push ≠ deploy.** Even with the runner up, deploy only fires on push to Gitea `main`. This session = local commit only; nothing deployed, nothing pushed to origin. +- 🟢 HMW-mode OFF. No sub-agents spawned this session (main-agent + API + git only). diff --git a/docs/PDF_TEMPLATE_IMPLEMENTATION.md b/docs/PDF_TEMPLATE_IMPLEMENTATION.md new file mode 100644 index 0000000..675ab41 --- /dev/null +++ b/docs/PDF_TEMPLATE_IMPLEMENTATION.md @@ -0,0 +1,985 @@ +# Implementation Guide — `sang-kien-pdf` + +A step-by-step walkthrough of how the Sáng kiến PDF + DOCX template generators are built. Read this if you want to understand **why** each piece exists, **how** to modify the layout, or **how** to port the same approach to a different government form. + +--- + +## Table of contents + +1. [The problem we're solving](#1-the-problem-were-solving) +2. [Architecture overview](#2-architecture-overview) +3. [Tech stack and rationale](#3-tech-stack-and-rationale) +4. [Project setup](#4-project-setup-from-scratch) +5. [Implementing the PDF generator](#5-implementing-the-pdf-generator) + - 5.1 [TypeScript data types](#51-typescript-data-types) + - 5.2 [Font registration](#52-font-registration) + - 5.3 [Shared styles](#53-shared-styles) + - 5.4 [Reusable components](#54-reusable-components) + - 5.5 [Page components](#55-page-components) + - 5.6 [Top-level Document](#56-top-level-document) + - 5.7 [Server-side render helper](#57-server-side-render-helper) +6. [Implementing the DOCX template generator](#6-implementing-the-docx-template-generator) + - 6.1 [The Jinja-in-DOCX strategy](#61-the-jinja-in-docx-strategy) + - 6.2 [The 3-row table loop trick](#62-the-3-row-table-loop-trick) + - 6.3 [Multi-section layout](#63-multi-section-layout) + - 6.4 [Building paragraphs and tables](#64-building-paragraphs-and-tables) +7. [Layout calibration](#7-layout-calibration-matching-the-standard) +8. [Verification workflow](#8-verification-workflow) +9. [Common modifications](#9-common-modifications) +10. [Troubleshooting](#10-troubleshooting) +11. [Porting to a different form](#11-porting-to-a-different-form) + +--- + +## 1. The problem we're solving + +The "Sáng kiến" application is a Vietnamese government form (Đại học Y Dược TP.HCM) that has six sections — a cover page (Trang bìa) plus Mẫu số 01–04 plus Bản cam kết. Every applicant fills out the same skeleton with their own data. + +Two real-world workflows need to be supported: + +1. **Programmatic PDF generation** — a web service receives JSON, returns a printable PDF. No human edits the file before printing. +2. **Word-based filling** — an admin opens a `.docx` template in Word, types into it (or uses `docxtpl`/`Carbone`/etc. to merge JSON), and prints. + +Both outputs must look identical to the official reference document (`Sang_kien_SOP_dong_vat`). The data shape (`data_blank.json`) is fixed by an existing system upstream and must not change. + +The trick is keeping the two generators in sync — same layout, same data fields — while staying within each format's idioms. + +--- + +## 2. Architecture overview + +``` + ┌────────────────────┐ + │ data.json │ ← source of truth (data_blank.json shape) + └──────────┬─────────┘ + │ + ┌────────────────┴────────────────┐ + ▼ ▼ + ┌──────────────────────┐ ┌─────────────────────────┐ + │ React-PDF pipeline │ │ docx + docxtpl path │ + │ │ │ │ + │ data → React tree │ │ build-docx-template.ts │ + │ → PDF buffer │ │ generates .docx with │ + │ │ │ {{ }} placeholders │ + │ │ │ ↓ │ + │ │ │ docxtpl.render(data) │ + │ │ │ → filled .docx │ + └──────────┬───────────┘ └────────────┬────────────┘ + │ │ + ▼ ▼ + filled.pdf filled.docx +``` + +The PDF path uses **runtime composition** — a React component receives data as props and returns a tree of ``/``/`` elements. The renderer turns that into a PDF buffer. + +The DOCX path uses **template-based composition** — a build script (`build-docx-template.ts`) produces a `.docx` file *once*, with placeholder strings like `{{ mau_01.mo_dau }}` baked into the document body. At runtime, `docxtpl` (Python) or any other Jinja-aware OOXML tool reads that `.docx`, finds the placeholders, and replaces them with values from the JSON. + +Both pipelines read **the same TypeScript types and JSON files**, so adding a new field requires touching both sides — but the field name lives in exactly one place: `src/types.ts`. + +--- + +## 3. Tech stack and rationale + +| Concern | Choice | Why | +|---|---|---| +| PDF rendering | `@react-pdf/renderer` v4 | Component-based, server- and browser-compatible. Uses Yoga for flexbox layout. Same API as React, so layouts compose like UI code. | +| Vietnamese font | `@expo-google-fonts/tinos` | Tinos is a metric-equivalent of Times New Roman (Apache 2.0) with the full Latin Extended Additional range — needed for `ư ơ ầ ậ ọ ặ` etc. The `@expo-google-fonts/*` packages ship actual `.ttf` files (most other font packages ship `.woff/.woff2`, which `@react-pdf/renderer` can't read). | +| DOCX generation | `docx` v9 (npm) | Object-model API: build paragraphs, tables, sections in TypeScript, then `Packer.toBuffer()` produces a valid `.docx`. Maintained, typed, stable. | +| Templating engine | `docxtpl` (Python) | The most popular Jinja-style DOCX templater. Recognizes `{{ var }}`, `{% if %}`, and crucially `{%tr for %}` for table-row loops. Compatible templates work in `docx-templates` (JS) and Carbone too. | +| TypeScript | 5.4 | Catches type errors at build time and gives autocompletion across all the data fields. | +| Test rendering | LibreOffice (`soffice`) | Used to convert `.docx` → `.pdf` so we can visually diff against the reference document. | + +**Why not a pure HTML-to-PDF approach (Puppeteer)?** It works, but bundle size is huge and rendering is non-deterministic across machines. React-PDF gives byte-stable output. + +**Why not just generate the DOCX and convert it to PDF?** That would solve the layout-sync problem but couples PDF generation to a heavy toolchain (LibreOffice). React-PDF runs in pure Node.js and works inside serverless environments. + +--- + +## 4. Project setup from scratch + +```bash +mkdir sang-kien-pdf && cd sang-kien-pdf +npm init -y + +# Runtime dependencies +npm install @react-pdf/renderer react @expo-google-fonts/tinos docx + +# Dev dependencies +npm install -D typescript ts-node @types/react @types/node +``` + +Create `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020", "DOM"], + "jsx": "react", + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["src/**/*", "example/**/*", "tools/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +The `jsx: "react"` setting matters — React-PDF uses real JSX, not the new transform. + +Add scripts to `package.json`: + +```json +{ + "scripts": { + "build": "tsc", + "generate": "ts-node example/generate-example.ts", + "generate:blank": "ts-node example/generate-example.ts --blank", + "build:docx": "ts-node tools/build-docx-template.ts" + } +} +``` + +--- + +## 5. Implementing the PDF generator + +### 5.1 TypeScript data types + +Start with the data shape. Every field in the JSON gets a strict TypeScript interface in `src/types.ts`. This is the single source of truth — every page component reads it, every change ripples out through the type system. + +```ts +// src/types.ts +export interface NgayKy { + ngay: string; + thang: string; + nam: string; +} + +export interface TrangBia { + ten_sang_kien: string; + tac_gia: string; + don_vi: string; + thong_tin_lien_he: string; + nam: string; +} + +export interface Mau01ApplyRow { + tt: string; + ten_to_chuc: string; + dia_chi: string; + linh_vuc: string; +} + +export interface Mau01HieuQua { + loi_ich_kinh_te: string; + hieu_qua_giang_day: string; + // … 8 more fields +} + +export interface Mau01 { + mo_dau: string; + ten_sang_kien: string; + // … + danh_sach_ap_dung: Mau01ApplyRow[]; + tinh_hieu_qua: Mau01HieuQua; + ngay_ky: NgayKy; + // … +} + +// … repeat for Mau02, Mau03, Mau04, BanCamKet + +export interface SangKienData { + trang_bia: TrangBia; + mau_01: Mau01; + mau_02: Mau02; + mau_03: Mau03; + mau_04: Mau04; + ban_cam_ket: BanCamKet; +} +``` + +Two design choices worth calling out: + +**All fields are strings (or string arrays).** Even numbers like "Tỷ lệ %" are strings. The form is for humans, not databases — values get rendered verbatim, and string-only types let users write `"15%"` or `"khoảng 15"` without coercion errors. + +**Array-shaped tables.** `danh_sach_tac_gia` is `Mau02AuthorRow[]`, not a fixed-size tuple. The page components iterate with `.map()`, and the DOCX template uses a `{%tr for %}` loop. Both handle 0, 1, or 100 rows. + +### 5.2 Font registration + +`@react-pdf/renderer` ships with three fonts (Helvetica, Times-Roman, Courier) and **none of them include Vietnamese glyphs**. If you skip this step, characters like `ư ơ ầ ậ` will render as blank space. + +```ts +// src/fonts.ts +import { Font } from "@react-pdf/renderer"; + +let registered = false; + +export function registerFonts(): void { + if (registered) return; + + const regular = require.resolve( + "@expo-google-fonts/tinos/400Regular/Tinos_400Regular.ttf" + ); + const italic = require.resolve( + "@expo-google-fonts/tinos/400Regular_Italic/Tinos_400Regular_Italic.ttf" + ); + const bold = require.resolve( + "@expo-google-fonts/tinos/700Bold/Tinos_700Bold.ttf" + ); + const boldItalic = require.resolve( + "@expo-google-fonts/tinos/700Bold_Italic/Tinos_700Bold_Italic.ttf" + ); + + Font.register({ + family: "TimesVN", + fonts: [ + { src: regular }, + { src: italic, fontStyle: "italic" }, + { src: bold, fontWeight: "bold" }, + { src: boldItalic, fontWeight: "bold", fontStyle: "italic" }, + ], + }); + + Font.registerHyphenationCallback((word) => [word]); + registered = true; +} +``` + +Three things happen here: + +1. **`require.resolve()` finds the TTF on disk** — this works in Node and bundlers like Webpack/Vite turn it into an asset URL automatically. +2. **One family, four variants** — `fontWeight` and `fontStyle` keys let `` resolve to the bold TTF. +3. **Hyphenation callback returns `[word]`** — this disables React-PDF's default English hyphenator, which would chop Vietnamese words at random points. + +The `registered` boolean guards against re-registration if `registerFonts()` is called from multiple entry points. + +### 5.3 Shared styles + +`StyleSheet.create()` in `src/styles.ts` defines reusable style objects. Three categories matter: + +**Page-level constants.** A4 with ~2.5 cm margins: + +```ts +page: { + fontFamily: FONT, // "TimesVN" + fontSize: 13, // 13pt body + paddingTop: 71, // ~2.5cm = 71pt + paddingBottom: 71, + paddingLeft: 71, + paddingRight: 71, + lineHeight: 1.25, +}, +``` + +**Paragraph variants** for the three contexts that come up: + +```ts +// Indented body text (justified, first-line indent ~1cm) +paragraph: { textAlign: "justify", textIndent: 28, marginBottom: 0 }, + +// Flush-left lines (section labels, inline list items) +paragraphFlush: { textAlign: "justify", marginBottom: 0 }, + +// Section headings (flush-left, with breathing room above) +sectionHead: { textAlign: "justify", marginBottom: 0, marginTop: 4 }, +``` + +The `marginBottom: 0` is deliberate — Vietnamese government documents are visually dense, so paragraphs only get spacing between sections, not between adjacent lines. + +**Component primitives** (table, checkbox, signature columns): + +```ts +table: { + flexDirection: "column", + borderWidth: 1, borderColor: "#000", + borderRightWidth: 0, borderBottomWidth: 0, // we draw R+B per-cell + marginVertical: 4, +}, +tableCell: { + borderRightWidth: 1, borderBottomWidth: 1, borderColor: "#000", + padding: 4, +}, +``` + +The "outer border drawn on the table, inner borders drawn per-cell" pattern avoids double-thickness lines where cells meet. + +**Cover-specific styles** are isolated in their own group because the cover page has unique requirements (page border via `position: absolute`, "Mẫu số 01" badge in the top corner). + +### 5.4 Reusable components + +`src/components.tsx` factors out the patterns that show up on multiple pages: + +**`label`** — a horizontal row with a bordered square. When `checked`, an inner filled `` appears inside it. We don't use the Unicode `☑` character because Tinos doesn't include it; drawing geometry is font-independent. + +```tsx +export const Checkbox: React.FC = ({ checked, children }) => ( + + + {checked ? : null} + + {children} + +); +``` + +**Header variants** — three different two-column header patterns appear in the document: + +- `` — "BỘ Y TẾ / ĐẠI HỌC Y DƯỢC" left, "CỘNG HÒA…" right (Mẫu 03/04) +- `` — drops "BỘ Y TẾ", shows the unit name in bold (Mẫu 02) +- `` — only the right column (Bản cam kết) + +Each one uses the same `flexDirection: "row"` layout with two equal columns. The differences are which lines appear. + +**Table primitives.** + +```tsx + + + STT + Họ và tên + {/* … */} + + {data.danh_sach_tac_gia.map((row, i) => ( + + {row.stt} + {row.ho_ten} + {/* … */} + + ))} +
+``` + +The `width` prop is a **percentage** (the cell renders with `width: ${width}%`). Column widths must sum to 100. The `Cell` component automatically wraps string children in `` so callers can pass either plain text or nested elements. + +**``** renders the recurring "TP. Hồ Chí Minh, ngày … tháng … năm …" line, with sensible blank-data placeholders (`.....`). + +**``** renders one column of a two-column signature block (centered title, italic subtitle, then a 50pt vertical gap before the bold signer's name). + +### 5.5 Page components + +Each section of the form gets its own component file in `src/pages/`. They all follow the same shape: + +```tsx +// src/pages/Mau01.tsx +import { Page, View, Text } from "@react-pdf/renderer"; +import { styles } from "../styles"; +import { Mau01 } from "../types"; +import { Table, Row, Cell, DateLine } from "../components"; + +interface Props { + data: Mau01; + donVi: string; // pulled from mau_02.don_vi by the parent +} + +export const Mau01Page: React.FC = ({ data, donVi }) => ( + + BÁO CÁO MÔ TẢ SÁNG KIẾN + + + 1. Mở đầu{" "} + + (Giới thiệu về những vấn đề liên quan đến sáng kiến…): + + + {data.mo_dau} + + {/* … rest of the page */} + +); +``` + +Three patterns recur in every page: + +1. **Static + dynamic mixed in the same ``.** Section labels like "1. Mở đầu" are fixed, but the italic instructional helper text and the data value next to them aren't. We use nested `` to apply different styles to different runs in one paragraph (because `` in React-PDF can contain other `` nodes, like `` in HTML). + +2. **`{" "}` for explicit whitespace.** JSX collapses whitespace between elements. To preserve a space between a label and an italic helper, we explicitly insert `{" "}`. + +3. **Default-empty rows for tables.** When `data.danh_sach_ap_dung` is empty, we still want one blank row to render so the printed form has a place to write. The pattern: + ```tsx + {(data.danh_sach_ap_dung && data.danh_sach_ap_dung.length > 0 + ? data.danh_sach_ap_dung + : [{ tt: "", ten_to_chuc: "", dia_chi: "", linh_vuc: "" }] + ).map((row, i) => /* ... */)} + ``` + +**Signature block on Mẫu 01 takes `donVi` as a prop**, not from `data` directly. The reason: the standard layout uses the unit name from Mẫu 02 (`mau_02.don_vi`) on Mẫu 01's signature line. Rather than duplicate the value in the JSON, the parent component (`SangKienDocument`) reads it from `mau_02` and passes it down. + +**Cover page is special.** It uses absolute positioning to put the page border around the entire content area: + +```tsx + + Mẫu số 01 + + + {/* header, title, fields, footer */} + + +``` + +`` tells React-PDF to render the border on every page in this section (irrelevant here since the cover is one page, but harmless), and `position: absolute` (set in `styles.coverBorder`) makes it overlay the whole page. + +### 5.6 Top-level Document + +`src/SangKienDocument.tsx` composes all six pages: + +```tsx +export const SangKienDocument: React.FC<{ data: SangKienData }> = ({ data }) => { + registerFonts(); + const donVi = data.mau_02.don_vi || data.trang_bia.don_vi; + + return ( + + + + + + + + + ); +}; +``` + +`registerFonts()` is idempotent (the internal `registered` flag guards against duplicate registration), so calling it from the top-level component is safe. + +The `` element accepts metadata that shows up in the PDF's title bar — `title`, `author`, `subject`, `creator`, `producer`, `keywords`. These don't affect rendering, just file properties. + +### 5.7 Server-side render helper + +`src/generate.tsx` wraps the React rendering in a Node-friendly Promise: + +```tsx +import { pdf } from "@react-pdf/renderer"; + +export async function renderSangKienPdf(data: SangKienData): Promise { + const instance = pdf(); + const blob = await instance.toBlob(); + const arrayBuffer = await blob.arrayBuffer(); + return Buffer.from(arrayBuffer); +} + +export async function renderSangKienPdfFromFile( + inputJsonPath: string, + outputPdfPath: string +): Promise { + const data = JSON.parse(fs.readFileSync(inputJsonPath, "utf-8")) as SangKienData; + const buffer = await renderSangKienPdf(data); + fs.mkdirSync(path.dirname(outputPdfPath), { recursive: true }); + fs.writeFileSync(outputPdfPath, buffer); +} +``` + +`pdf(...).toBlob()` is the cleanest async API even on the server — the `Buffer.from(await blob.arrayBuffer())` conversion is one line. + +`example/generate-example.ts` is a thin CLI on top: + +```ts +const useBlank = process.argv.includes("--blank"); +const inputPath = useBlank + ? path.join(__dirname, "data-blank.json") + : path.join(__dirname, "sample-data.json"); +const outputPath = path.join(__dirname, "..", "out", `sang-kien-${useBlank ? "blank" : "filled"}.pdf`); + +await renderSangKienPdfFromFile(inputPath, outputPath); +``` + +--- + +## 6. Implementing the DOCX template generator + +### 6.1 The Jinja-in-DOCX strategy + +`docxtpl` works by storing Jinja-style strings *as ordinary text* inside the DOCX, then doing template expansion at render time. The build script's job is to produce a `.docx` whose visible text reads: + +> **Tên sáng kiến (Tiếng Việt):** {{ trang_bia.ten_sang_kien }} + +When you open this in Word, you literally see those curly braces. When `docxtpl` opens it, it walks the OOXML tree, finds runs containing `{{ ... }}`, and replaces them. + +**The catch: text runs split across formatting changes.** If you write `Tên sáng kiến (Tiếng Việt): {{ trang_bia.ten_sang_kien }}` in one run, that's fine. But if you bold "Tên sáng kiến" and leave `{{ … }}` regular, Word stores them as **two separate runs**. A naive search for `{{` in the second run works — but if you split a placeholder *inside* the curly braces (`{{ trang_bia.` in one run, `ten_sang_kien }}` in another), `docxtpl` will fail silently. So: + +> **Rule:** every placeholder must live entirely inside one continuous run with one set of formatting. + +The `docx` library makes this easy — when you write `r("{{ mau_01.mo_dau }}")`, that's exactly one `` element with one `` inside. + +### 6.2 The 3-row table loop trick + +For repeating table rows, `docxtpl` uses a special syntax: `{%tr for item in collection %}` and `{%tr endfor %}`. The `tr` prefix tells the engine "remove the entire `` row containing this tag and use the rows between `for` and `endfor` as the loop body." + +A naive single-row pattern doesn't work: + +``` +[ {%tr for x in items %} {{ x.id }} | {{ x.name }} {%tr endfor %} ] +``` + +Because `{%tr for %}` and `{%tr endfor %}` must be in the **same row** (they're stripped together) — and Jinja then sees two opening tags with no body. + +The reliable pattern is **three rows**: + +``` +Row 1: | {%tr for item in collection %} | (empty cells) | +Row 2: | {{ item.id }} | {{ item.name }} | ← duplicated per item +Row 3: | {%tr endfor %} | (empty cells) | +``` + +Row 1 and Row 3 get stripped. Row 2 gets repeated for each item. The data row carries the actual `{{ }}` fields. + +In code: + +```ts +const aw = [6, 22, 14, 16, 14, 14, 14]; // column widths + +const emptyRow_aw = (firstText: string) => { + const cells: TableCell[] = []; + for (let i = 0; i < aw.length; i++) { + cells.push(new TableCell({ + borders: allThinBorders, + width: { size: aw[i] * 100, type: WidthType.PERCENTAGE }, + children: [new Paragraph({ children: [r(i === 0 ? firstText : " ")] })], + })); + } + return cells; +}; + +new Table({ + rows: [ + new TableRow({ children: [/* header cells */] }), + new TableRow({ children: emptyRow_aw("{%tr for item in mau_02.danh_sach_tac_gia %}") }), + new TableRow({ children: [ + dataCell("{{ item.stt }}", aw[0], AlignmentType.CENTER), + dataCell("{{ item.ho_ten }}", aw[1]), + // … 5 more + ]}), + new TableRow({ children: emptyRow_aw("{%tr endfor %}") }), + ], +}); +``` + +The `emptyRow_aw` helper builds a row where the first cell contains the loop tag and the rest are blanks (just `" "`). After `docxtpl` strips it, the visible table has one header row plus one data row per item. + +### 6.3 Multi-section layout + +Word documents are split into **sections**, each with its own page settings — margins, orientation, page borders, headers, footers. The cover page needs: + +- A **page border** (rounded rectangle around the content area) +- A **header** containing "Mẫu số 01" at the top right *outside* the border + +The rest of the document needs: + +- **No** page border +- **No** "Mẫu số 01" header (it's only on the cover) + +In `docx` v9, this is two sections in the same document: + +```ts +new Document({ + sections: [ + { + properties: { + page: { + size: { width: 11906, height: 16838, orientation: PageOrientation.PORTRAIT }, + margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, + borders: { + pageBorderTop: { style: BorderStyle.SINGLE, size: 12, color: "000000", space: 24 }, + pageBorderBottom: { style: BorderStyle.SINGLE, size: 12, color: "000000", space: 24 }, + pageBorderLeft: { style: BorderStyle.SINGLE, size: 12, color: "000000", space: 24 }, + pageBorderRight: { style: BorderStyle.SINGLE, size: 12, color: "000000", space: 24 }, + }, + }, + }, + headers: { default: coverHeader }, // contains "Mẫu số 01" + children: buildCoverPage(), + }, + { + properties: { + page: { size: {/*…*/}, margin: {/*…*/} /* no borders */ }, + }, + // Explicit empty header so the cover header doesn't leak onto subsequent pages + headers: { default: new Header({ children: [new Paragraph({ children: [r("")] })] }) }, + children: [ + ...buildMau01(), + ...buildMau02(), + ...buildMau03(), + ...buildMau04(), + ...buildBanCamKet(), + ], + }, + ], +}); +``` + +Two gotchas worth noting: + +**Twips, not points.** `docx` uses twips (1/1440 inch). Multiply pt by 20 to get twips: +- A4 = 11906 × 16838 twips +- 1 inch margin = 1440 twips +- 1 cm = 567 twips + +**Headers leak across sections.** If section 2 doesn't define `headers`, it inherits section 1's. We have to provide an explicit empty `Header` to prevent the "Mẫu số 01" text from showing up on every page of the document. + +### 6.4 Building paragraphs and tables + +The build script defines small helper functions to keep the body code readable: + +```ts +const FONT = "Times New Roman"; +const SIZE = 26; // 13pt (docx-js uses half-points) +const SIZE_HEADING = 28; // 14pt + +function r(text: string, opts: { bold?: boolean; italic?: boolean; underline?: boolean; size?: number } = {}) { + return new TextRun({ + text, + font: FONT, + size: opts.size ?? SIZE, + bold: opts.bold, + italics: opts.italic, + underline: opts.underline ? { type: UnderlineType.SINGLE } : undefined, + }); +} + +function bodyP(children: TextRun[], opts: { indent?: boolean } = {}) { + return new Paragraph({ + children, + alignment: AlignmentType.JUSTIFIED, + indent: opts.indent ? { firstLine: 567 } : undefined, + spacing: { before: 0, after: 0, line: 300 }, + }); +} + +function flushP(children: TextRun[], opts: { spaceBefore?: number } = {}) { + return new Paragraph({ + children, + alignment: AlignmentType.JUSTIFIED, + spacing: { before: opts.spaceBefore ?? 0, after: 0, line: 300 }, + }); +} + +function centerP(children: TextRun[], opts: { spaceBefore?: number; spaceAfter?: number } = {}) { + return new Paragraph({ + children, + alignment: AlignmentType.CENTER, + spacing: { before: opts.spaceBefore ?? 0, after: opts.spaceAfter ?? 0, line: 300 }, + }); +} +``` + +A typical section then reads naturally: + +```ts +out.push(centerP([r("BÁO CÁO MÔ TẢ SÁNG KIẾN", { bold: true, size: SIZE_HEADING })])); + +out.push(flushP([ + r("1. Mở đầu "), + r("(Giới thiệu về những vấn đề liên quan…):", { italic: true }), +])); +out.push(bodyP([r("{{ mau_01.mo_dau }}")], { indent: true })); +``` + +For checkboxes, since the templating engine has to choose which character to render, we embed the choice in the placeholder itself: + +```ts +const checkbox = (cond: string, label: string) => + flushP([ + r(`{% if ${cond} %}`), + r("☑"), + r("{% else %}"), + r("☐"), + r("{% endif %} "), + r(label), + ]); + +out.push(checkbox( + "mau_02.phan_loai.giai_phap_ky_thuat", + "Giải pháp kỹ thuật, quản lý, tác nghiệp, ứng dụng tiến bộ kỹ thuật áp dụng cho Đại học Y Dược TP.HCM" +)); +``` + +After `docxtpl` runs, this paragraph reduces to `☑ Giải pháp kỹ thuật…` or `☐ Giải pháp kỹ thuật…` depending on the boolean. (For DOCX rendering in Word, the `☑/☐` characters work fine because Word falls back to a Unicode-capable font automatically — unlike React-PDF.) + +--- + +## 7. Layout calibration (matching the standard) + +The "Sang_kien_SOP_dong_vat" reference document defines a specific visual style. Here's a checklist of the calibrations applied to both generators: + +| Aspect | Rule | Where it lives | +|---|---|---| +| Body font | Times New Roman (or Tinos) 13pt | `styles.page.fontSize`, `r()` `SIZE = 26` | +| Page margins | 2.5 cm all around | `padding: 71` (PDF), `margin: 1440` (DOCX) | +| Body line height | 1.25 | `lineHeight: 1.25` (PDF), `line: 300` (DOCX, 240 = single, 300 ≈ 1.25) | +| First-line indent | ~1 cm on body paragraphs | `textIndent: 28` (PDF), `firstLine: 567` (DOCX) | +| Section numbers (`1.`, `2.`, `4.1`) | **NOT bold**; italic instructions in parens | Use `paragraphFlush` not bold | +| Inter-paragraph spacing | None within a section, small gap before new section | `marginBottom: 0`, `sectionHead.marginTop: 4` | +| Cover page | Page border (rounded rect), "Mẫu số 01" outside top-right | Cover-specific styles, dedicated section in DOCX | +| Cover divider | `=====***=====` (literal) | Hardcoded string | +| Cover info fields | Left-aligned, **bold label**, regular value | `coverField` style | +| Two-column header | "ĐƠN VỊ" or "BỘ Y TẾ" left, "CỘNG HÒA" right | `TopHeaderBoYTe`, `TopHeaderDonVi`, `TopHeaderCongHoa` | +| "Độc lập – Tự do – Hạnh phúc" | Underlined, bold | `underline: true` flag in `r()`/styles | +| Tables | Single thin black border, no shaded header | `borderWidth: 1`, no `backgroundColor` on `tableHeaderCell` | +| Mẫu 02 author table column 7 | Header includes parenthetical italic instruction | Custom `TableCell` with two centered paragraphs | +| Signature block | Two columns: "Xác nhận của lãnh đạo / [đơn vị]" left, "Đại diện nhóm tác giả sáng kiến" right | `` (PDF), borderless 2-cell table (DOCX) | +| Mẫu 03 totals row | TỔNG (cols 1–3 merged) ‖ 100 ‖ blank | `columnSpan: 3` in DOCX, manual width sum in PDF | +| Mẫu 04 evaluation rubric | Two scoring rows + total row at bottom | Static text + `{{ … }}` for nhận xét/điểm | + +When in doubt about a layout decision, open the reference DOCX in Word, click into the relevant element, and read its formatting from the ribbon. Mirror those settings in code. + +--- + +## 8. Verification workflow + +Visual diff against the reference is the only reliable way to know you got it right. The flow: + +```bash +# 1. Generate the candidate PDF +npm run generate + +# 2. Convert each page to JPEG +pdftoppm -jpeg -r 100 out/sang-kien-filled.pdf out/page + +# 3. Convert the reference DOCX to PDF and JPEGs the same way +soffice --headless --convert-to pdf reference.docx --outdir ref/ +pdftoppm -jpeg -r 100 ref/reference.pdf ref/ref-page + +# 4. Open them side by side +``` + +For the DOCX generator, add one more step: + +```bash +# Build the template +npm run build:docx + +# Render placeholders WITHOUT filling them — does the layout look right? +soffice --headless --convert-to pdf out/template_application_form.docx --outdir out/ + +# Fill it with sample data and render +python tools/fill-docx.py example/sample-data.json out/sang-kien-filled.docx +soffice --headless --convert-to pdf out/sang-kien-filled.docx --outdir out/ +``` + +Smoke test the DOCX template in Python before declaring victory: + +```python +# tools/test-docx-fill.py +from docxtpl import DocxTemplate +import json + +with open("example/sample-data.json", encoding="utf-8") as f: + data = json.load(f) + +doc = DocxTemplate("out/template_application_form.docx") +doc.render(data) +doc.save("out/template-filled-test.docx") +``` + +If `docxtpl` raises `TemplateSyntaxError: Encountered unknown tag 'endfor'`, you've put a `{%tr for %}` and `{%tr endfor %}` in the same row instead of separate rows. Go re-read [§6.2](#62-the-3-row-table-loop-trick). + +If a `{{ field }}` doesn't get replaced and you can still see the curly braces in the filled output, the placeholder got split across runs by Word's auto-formatting. Build the placeholder with one `r("{{ x }}")` call, not three. + +--- + +## 9. Common modifications + +### Adding a new field + +Say you need to add `mau_01.tong_kinh_phi` (total budget). + +1. **Update `src/types.ts`:** + ```ts + export interface Mau01 { + // … + tong_kinh_phi: string; // new + } + ``` + +2. **Update `example/data-blank.json`** and **`example/sample-data.json`** with the new field. + +3. **Render it in `src/pages/Mau01.tsx`:** + ```tsx + + 7. Tổng kinh phí: {data.tong_kinh_phi} + + ``` + +4. **Add it to the DOCX template generator** in `tools/build-docx-template.ts`: + ```ts + out.push(flushP([r("7. Tổng kinh phí: {{ mau_01.tong_kinh_phi }}")])); + ``` + +5. **Regenerate:** + ```bash + npm run generate + npm run build:docx + ``` + +The TypeScript compiler will yell if you forget to update the page component or miss a field in the JSON. + +### Changing a column width + +Column widths are kept as small integer arrays in the page component (PDF) and the build script (DOCX). They must always sum to 100. + +To widen the "Họ và tên" column on the Mẫu 02 author table from 22% to 28% (and shrink "Nơi công tác" from 16% to 10%): + +In `src/pages/Mau02.tsx`: +```ts +const AUTHOR_WIDTHS = [6, 28, 14, 10, 14, 14, 14] as const; // was [6, 22, 14, 16, …] +``` + +In `tools/build-docx-template.ts` (inside `buildMau02()`): +```ts +const aw = [6, 28, 14, 10, 14, 14, 14]; +``` + +Both numbers must match — there's no shared constant because the PDF widths are percentages of the page width (100% sum) while the DOCX widths happen to use the same convention but go through different code paths. Keeping them in sync is a manual discipline. + +### Adding a new repeating table + +Both the data shape, the page component, and the DOCX template need updates: + +1. **Type:** add `Mau01NewRow[]` to `Mau01`, define `interface Mau01NewRow { … }`. + +2. **PDF page:** mirror the existing pattern in `src/pages/Mau01.tsx`: + ```tsx + + + TT + {/* … */} + + {(data.danh_sach_moi && data.danh_sach_moi.length > 0 + ? data.danh_sach_moi + : [{ tt: "", ... }] + ).map((row, i) => ( + + {row.tt} + {/* … */} + + ))} +
+ ``` + +3. **DOCX template:** use the 3-row pattern from [§6.2](#62-the-3-row-table-loop-trick): + ```ts + const w = [10, 30, 30, 30]; + const emptyRow = (firstText: string) => /* same helper pattern */; + + new Table({ + rows: [ + new TableRow({ children: [headerCell("TT", w[0]), /* … */] }), + new TableRow({ children: emptyRow("{%tr for item in mau_01.danh_sach_moi %}") }), + new TableRow({ children: [dataCell("{{ item.tt }}", w[0], AlignmentType.CENTER), /* … */] }), + new TableRow({ children: emptyRow("{%tr endfor %}") }), + ], + }); + ``` + +### Switching to your organization's font + +Replace the four TTF paths in `src/fonts.ts`: + +```ts +Font.register({ + family: "TimesVN", + fonts: [ + { src: "/path/to/your/Regular.ttf" }, + { src: "/path/to/your/Italic.ttf", fontStyle: "italic" }, + { src: "/path/to/your/Bold.ttf", fontWeight: "bold" }, + { src: "/path/to/your/BoldItalic.ttf", fontWeight: "bold", fontStyle: "italic" }, + ], +}); +``` + +For the DOCX side, change `const FONT = "Times New Roman"` in `tools/build-docx-template.ts` to whatever font you want to embed. Word will fall back to a system font if the named font isn't installed on the reader's machine, so prefer common names (Times New Roman, Arial, Calibri). + +--- + +## 10. Troubleshooting + +**PDF renders blank squares where Vietnamese characters should be.** +The font isn't registered or the registered font lacks Vietnamese glyphs. Check that `registerFonts()` is called and that the TTFs at the resolved paths are actually loaded (not 404 / missing). Tinos has the right glyph coverage; many "Times New Roman clones" don't. + +**`Error: Failed to fetch font from https://…`** +You're hitting `@react-pdf/renderer`'s URL-based font loading and your environment can't reach the URL. Switch to local TTFs via `require.resolve()` (already what `src/fonts.ts` does). + +**`docxtpl` raises `TemplateSyntaxError: Encountered unknown tag 'endfor'`.** +You put the `{%tr for %}` and `{%tr endfor %}` tags in the *same* table row. Re-read [§6.2](#62-the-3-row-table-loop-trick) — they have to be on separate rows. + +**Some `{{ field }}` placeholders aren't being replaced.** +Word split your text run mid-placeholder. Make sure each placeholder is constructed with a single `r("{{ x }}")` call, not split across multiple `r()` calls or assembled from concatenated strings. + +**The DOCX has "Mẫu số 01" appearing on every page, not just the cover.** +The cover-section header is leaking into the next section. Add an explicit empty header to the second section: +```ts +headers: { default: new Header({ children: [new Paragraph({ children: [r("")] })] }) }, +``` + +**Tables overflow the right margin.** +Column width percentages don't sum to exactly 100, or a single cell has too much wide content with no wrap point. Either fix the widths or add `wordBreak: "break-word"` to the cell style. + +**`textIndent` doesn't seem to work in ``.** +React-PDF's `textIndent` only takes effect when the `` *itself* has `display: "block"`-like behavior — i.e. it's a top-level paragraph, not nested inside another ``. If you're nesting, wrap the inner content in a parent `` that has the indent style. + +**The DOCX page border doesn't appear.** +Page borders are a Word feature configured in section properties. Check that you've set all four (`pageBorderTop/Bottom/Left/Right`), with non-zero `size` and a `space` value (24 puts them ~1.7cm from the edge in our setup). LibreOffice and Word may render them slightly differently — Word is the canonical view. + +**Filled DOCX has weird extra empty rows above each table.** +Those are the `{%tr for %}`/`{%tr endfor %}` rows that didn't get stripped — meaning the loop tags ended up in paragraphs *inside* a cell, not as standalone row text. Make sure the `firstText` in your `emptyRow_*()` helper is the entire cell content, not appended to other text. + +--- + +## 11. Porting to a different form + +The same pattern works for any structured government form. The migration steps: + +1. **Extract the data model.** Open the reference DOCX, list every blank line and every table column. Each becomes a field in `types.ts`. Repeating sections (lists of authors, lists of attachments) become arrays. + +2. **Identify the sections.** Most forms have a cover page plus N body sections. Each body section becomes a `` component plus a `buildSectionN()` function in the DOCX builder. + +3. **Catalog the visual primitives.** Headers, signature blocks, tables, checkboxes, date lines — write them once in `components.tsx` (PDF) and as helper functions (DOCX), then reuse. + +4. **Calibrate the styles.** Open the reference, measure margins, font, line spacing, and indent. Set them as constants. See [§7](#7-layout-calibration-matching-the-standard). + +5. **Render and diff.** Generate, convert to JPEG, line up against the reference. Iterate until they match. + +6. **Smoke-test the DOCX template** with `docxtpl`. If a placeholder doesn't fill, it's almost always run-splitting — fix by collapsing into one `r()` call. + +The most labor-intensive part is the visual calibration (step 4–5). Everything else is mechanical translation from "what the form looks like" to "code that produces the same thing." + +--- + +## Appendix: file-by-file inventory + +| File | Lines | Purpose | +|---|---:|---| +| `src/types.ts` | 177 | TypeScript interfaces matching `data_blank.json` | +| `src/fonts.ts` | 56 | Tinos font registration | +| `src/styles.ts` | 239 | Shared `StyleSheet.create()` styles | +| `src/components.tsx` | 156 | Reusable ``, ``, ``, header variants | +| `src/pages/CoverPage.tsx` | 64 | Trang bìa with page border | +| `src/pages/Mau01.tsx` | 172 | Báo cáo mô tả sáng kiến | +| `src/pages/Mau02.tsx` | 206 | Đơn đề nghị công nhận sáng kiến | +| `src/pages/Mau03.tsx` | 82 | Bản xác nhận tỷ lệ đóng góp | +| `src/pages/Mau04.tsx` | 94 | Phiếu đánh giá sáng kiến | +| `src/pages/BanCamKet.tsx` | 119 | Bản cam kết | +| `src/SangKienDocument.tsx` | 43 | Top-level `` composing all pages | +| `src/generate.tsx` | 37 | `renderSangKienPdf(data)` server-side helper | +| `src/index.ts` | 5 | Public API barrel | +| `tools/build-docx-template.ts` | 1301 | Generates the Jinja-style DOCX template | +| `tools/fill-docx.py` | ~30 | CLI to fill a template with JSON data via `docxtpl` | +| `tools/test-docx-fill.py` | ~25 | Smoke test script | +| `example/generate-example.ts` | ~35 | CLI for the PDF pipeline | +| `example/sample-data.json` | — | Realistic filled-in example | +| `example/data-blank.json` | — | All-empty template instance | + +Total: about **2750 lines** of TypeScript + ~50 lines of Python. The DOCX generator is the largest single file because every static line of body text is a `out.push(flushP([r("…")]))` call, but the pattern is repetitive and easy to skim. diff --git a/docs/PDF_converter.md b/docs/PDF_converter.md new file mode 100644 index 0000000..3aa79d4 --- /dev/null +++ b/docs/PDF_converter.md @@ -0,0 +1,363 @@ +# Specification: Browser-Based DOCX-to-PDF Converter + +**Status:** Ready for implementation +**Audience:** Frontend engineer (React + TypeScript) +**Estimated effort:** 1–2 days for a working component, +1 day for polish and tests + +--- + +## 1. Overview + +This document specifies a React component, `DocxToPdfViewer`, that accepts a `.docx` file in the browser, renders it on screen with layout fidelity equivalent to Microsoft Word, and produces a downloadable PDF that matches the rendering page-for-page. The component runs entirely in the browser; no document content ever leaves the user's machine. + +The component is intended for use cases where users need to view a Word document and obtain a PDF copy without installing Word, opening a desktop converter, or trusting a third-party cloud service. Typical scenarios include legal forms, application packets, internal templates, and document submission flows where PDF is the required output format. + +## 2. Goals and Non-Goals + +### 2.1 Goals + +The component must preserve the document's page size, margins, fonts (where embedded or system-available), paragraph alignment, tables, inline and floating images, headers, footers, footnotes, bullet and numbered lists, and basic text formatting (bold, italic, underline, color, size). It must correctly render documents containing non-Latin scripts, with Vietnamese diacritics, CJK characters, and right-to-left scripts as concrete test cases. It must work on the current versions of Chromium-based browsers, Firefox, and Safari without server assistance. It must expose a clear TypeScript API and emit lifecycle events suitable for integration into larger applications. + +### 2.2 Non-Goals + +The output PDF is **rasterised**: each page is a JPEG image embedded in a PDF page of matching dimensions. Text in the output is therefore not selectable or searchable. If selectable text is required, the implementer should use a server-side converter (LibreOffice headless, Aspose, or a paid API) instead — this is documented in Section 12. + +The component does not edit, sign, redact, fill forms in, or otherwise modify the source document. It does not support `.doc` (legacy binary format); callers must convert to `.docx` upstream. It does not attempt to be a general-purpose Word viewer with comments, track changes, or revision history rendering; only the final accepted state is rendered. + +## 3. System Context + +The pipeline has three stages, executed in order: + +``` +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ .docx file │ -> │ docx-preview │ -> │ html2canvas │ -> │ jsPDF │ -> Blob +│ (Blob) │ │ (HTML) │ │ (Canvas[]) │ │ (PDF Blob) │ +└─────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ + │ + └─> visible to user as the on-screen preview +``` + +The rendered HTML serves a dual purpose: it is both the on-screen preview shown to the user *and* the source material from which the PDF is rasterised. There is no separate hidden render pass. This is a deliberate architectural choice; see Rule 3 in Section 7. + +## 4. Dependencies + +The implementation requires three runtime dependencies and their type definitions: + +| Package | Version | Purpose | +|---|---|---| +| `docx-preview` | `^0.3.5` | Parses `.docx` and renders to HTML with high layout fidelity. | +| `html2canvas` | `^1.4.1` | Rasterises a DOM subtree to an HTMLCanvasElement. | +| `jspdf` | `^2.5.1` | Assembles canvas images into a multi-page PDF. | + +`docx-preview` has a transitive runtime dependency on `jszip`, which it imports via its package; no direct install is required when bundling with npm. When loading via CDN, `jszip` must be loaded as a separate ` +``` + +For bundled React applications, install via npm; CDN choice does not apply. Verify each library's presence on `window` before the first conversion call and surface a clear error to the user if any failed to load. + +## 8. Error Handling and Edge Cases + +The implementation must handle the following scenarios gracefully: + +**Wrong file type.** When the user drops a `.pdf`, `.txt`, `.doc`, or any non-`.docx` file, the component shows an inline error message and does not enter the rendering stage. Validation is by file extension; MIME-type sniffing is unreliable across browsers. + +**Corrupted or malformed `.docx`.** `docx-preview` will throw during `renderAsync` if the file is not a valid OOXML package or contains unparseable XML. The error must be caught, the status set to `"error"`, and the error message surfaced to the user. The component must remain in a state where another file can be selected. + +**Empty document.** A valid `.docx` containing no content will produce an empty wrapper with no `
` elements. The implementation throws an explicit error rather than producing an empty PDF. + +**Images with restrictive CORS.** With `useBase64URL: true`, `docx-preview` inlines embedded images as data URLs and CORS does not apply. If the option is changed to `false`, externally hosted images will taint the canvas and cause `toDataURL` to throw a `SecurityError`. Do not change this option. + +**Very large documents.** Documents with more than ~50 pages may exhaust memory at `scale: 2` because each captured canvas is held in memory before being added to the PDF. For documents this large, the implementation should release each canvas (by setting its reference to null) immediately after `addImage` returns, and consider lowering `renderScale` to 1.5 when page count exceeds a threshold. + +**Mixed page orientations.** Documents that switch from portrait to landscape mid-flow are handled by the per-page dimension calculation in Section 6.4. Do not assume all pages share the first page's dimensions. + +**Rapid file changes.** If the user drops a second file while the first is still converting, the in-flight conversion must be cancelled or its results discarded. The simplest approach is to track an incrementing conversion ID; results from a non-current ID are ignored on completion. This is not strictly required for correctness — the second call will overwrite the first — but it prevents stale progress updates from confusing the status display. + +## 9. Performance Considerations + +For a typical 5-page A4 document, end-to-end conversion on mid-range 2024 hardware takes 1.5–3 seconds. The dominant cost is `html2canvas` capture, which scales roughly linearly with page count and quadratically with `renderScale`. The `docx-preview` rendering stage typically takes 100–300 ms regardless of page count. PDF assembly is negligible. + +Memory peaks during the capture loop, holding one canvas worth of pixels per page until added to the PDF. At `scale: 2` with US Letter pages, a single canvas is approximately 8 MB of RGBA data. A 20-page document briefly holds ~160 MB before garbage collection. + +Output PDF file sizes for a 5-page document at default settings are approximately 1.5–3 MB. Lowering `imageQuality` from 0.95 to 0.85 typically reduces output by 30% with no visible degradation; lowering below 0.80 introduces visible JPEG artifacts on text edges. + +## 10. Browser Support + +The component targets the current and one prior major version of Chrome, Edge, Firefox, and Safari. Internet Explorer is not supported. The relevant browser features are: + +- `File` and `FileReader` APIs (universal since 2014) +- `Blob` and `URL.createObjectURL` (universal since 2014) +- Canvas `toDataURL` with JPEG support (universal since 2012) +- ES2020 syntax targets in `tsconfig.json` + +`html2canvas` has known limitations rendering certain CSS features — `mix-blend-mode`, `backdrop-filter`, complex `clip-path` — that may affect documents using heavy graphical design. For Word documents this is rarely relevant; standard business documents do not invoke these features. + +## 11. Testing + +Implementations should be verified against the following test corpus: + +| Test document | Asserts | +|---|---| +| Plain prose, 3 pages, A4 | Basic flow; page count and dimensions match | +| Document with one table per page | Tables render with borders and cell shading | +| Mixed portrait and landscape sections | Each PDF page matches its source orientation | +| Document with embedded PNG and JPEG images | Images appear in correct positions | +| Vietnamese-language document with diacritics | All characters render; no missing glyphs | +| Document with header and footer including page numbers | Headers/footers appear on every page | +| Document with bulleted and numbered lists | List markers render with correct indentation | +| 30-page document | Memory does not exceed 500 MB during capture | +| Corrupted .docx (truncated zip) | Component shows error and remains usable | + +Beyond visual diffing of the rendered preview against the source `.docx` opened in Word, the captured PDF should be opened in a separate PDF reader (Acrobat, Preview, or Firefox's built-in viewer) to confirm that page dimensions, count, and rendered content match. Programmatic visual regression testing of the PDF output is beyond the scope of this spec but can be implemented using `pdf-parse` + `pixelmatch` if needed. + +## 12. Known Limitations and Alternatives + +The text in the output PDF is rasterised and therefore not selectable, searchable, copyable, or screen-readable. Users who need any of these properties — particularly accessibility for visually impaired users — must use a server-side converter that emits real PDF text objects. Recommended alternatives in decreasing order of fidelity and increasing order of cost: + +1. **LibreOffice headless** (`soffice --convert-to pdf`): free, self-hosted, very high fidelity, requires Linux server with LibreOffice installed. ~1–3 seconds per document. +2. **Aspose.Words Cloud or self-hosted**: paid, very high fidelity, native PDF text output, requires license. +3. **CloudConvert, ConvertAPI, or similar SaaS**: paid per-document, simple HTTP API, sends document contents to a third party. + +The HTML preview produced by `docx-preview` *is* accessible — screen readers can navigate it, text is selectable, and users can zoom — so the component's accessibility story is intact for users who don't need the PDF artifact itself. + +This component cannot edit, sign, redact, or annotate documents. For those features, evaluate `pdf-lib` (PDF mutation) or `docx` (DOCX generation, which is a different package than `docx-preview`). + +## 13. Appendix: Algorithm Pseudocode + +For reference, the complete conversion algorithm in 20 lines: + +``` +function convert(file, container): + clear container + await renderAsync(file, container, { + inWrapper: true, + breakPages: true, + useBase64URL: true, + experimental: true, + renderHeaders: true, renderFooters: true, renderFootnotes: true, + }) + await rAF; await sleep(50) + + pages = container.querySelectorAll("section.docx") || container.querySelectorAll("section") + if pages is empty: throw + + pdf = new jsPDF using pages[0] dimensions in mm + for each page in pages: + canvas = await html2canvas(page, scale=2, useCORS=true, bg=white) + if not first page: pdf.addPage(page dimensions) + pdf.addImage(canvas.toDataURL("image/jpeg", 0.95), 0, 0, w_mm, h_mm) + + return pdf.output("blob") +``` + +The pseudocode omits error handling, lifecycle management, and progress reporting, all of which are required in the production implementation per Sections 6.6 and 8. + +--- + +*End of specification.* diff --git a/docs/PDF_preview.md b/docs/PDF_preview.md new file mode 100644 index 0000000..f0bd463 --- /dev/null +++ b/docs/PDF_preview.md @@ -0,0 +1,313 @@ + + +# Applicant PDF / report preview — re-implementation guide + +This document describes how PDF and “draft” preview work in the DYD frontend and backend so you can reproduce behavior in another codebase or refactor safely. + +--- + +## 1. Two different things called “PDF” + +| Path | What it is | Layout fidelity | +|------|------------|-------------------| +| **Server PDF** | `GET /api/reports/{reportId}/export/pdf` | Same as **Xuất Word**, then LibreOffice **DOCX → PDF**. Official template layout. | +| **Client “draft” preview** | `PdfExportDialog` + `PrintableReport` + `html2canvas` + `jspdf` | HTML recreation; **not** pixel-equal to Word. Used when server PDF fails (typical: LibreOffice missing). | + +**Canonical document for official exports:** the filled `.docx`. The PDF is a **conversion**, not a separately maintained template. + +Reference implementation: + +- Dialog: [`fe0/src/components/PdfExportDialog.tsx`](../fe0/src/components/PdfExportDialog.tsx) +- Layout: [`fe0/src/components/PrintableReport.tsx`](../fe0/src/components/PrintableReport.tsx) +- Hooks: [`fe0/src/api/hooks.ts`](../fe0/src/api/hooks.ts) +- PDF handler: [`src/Backend/DYD.Application/Features/Reports/ExportReportPdf.cs`](../src/Backend/DYD.Application/Features/Reports/ExportReportPdf.cs) +- LibreOffice wrapper: [`src/Backend/DYD.Application/Common/Export/SofficeConverter.cs`](../src/Backend/DYD.Application/Common/Export/SofficeConverter.cs) + +--- + +## 2. Why server PDF matches DOCX (margins, text, spacing) + +[`ExportReportPdfHandler`](../src/Backend/DYD.Application/Features/Reports/ExportReportPdf.cs) runs: + +1. `ExportReportDocxQuery` → bytes of the filled report `.docx` (identical pipeline to **Xuất Word**). +2. `SofficeConverter.WordToPdfAsync(wordBytes)` → writes temp `.docx`, runs **LibreOffice headless** `--convert-to pdf`, reads `input.pdf`. + +So the PDF is **one layout engine pass** over the merged OpenXML file. Field text and structural spacing come from that document; you are not duplicating merge logic for PDF. + +**Caveats:** Font availability on the server, LibreOffice vs Microsoft Word subtle differences, and very long unbroken strings may change wrapping. For strict WYSIWYG with Word desktop, compare in both viewers. + +**Contrast:** The React `PrintableReport` path **does not** use the Word template—it renders its own HTML. Use it only as a **fallback preview**, not as a legal duplicate of the official form. + +--- + +## 3. User flows (where the preview opens) + +### 3.1 My Reports (`fe0/src/pages/MyReports.tsx`) + +1. User clicks **Xuất PDF** on a row. +2. App calls `GET /api/reports/{id}/export/pdf` and downloads the blob on success. +3. If the error message contains `LibreOffice` or `soffice`, it alerts and opens `PdfExportDialog` with `{ reportId, initiativeId, reportCode }` from the row (initiative id is set correctly). + +### 3.2 Dashboard Overview — Panel 2 (`fe0/src/pages/DashboardOverview.tsx`) + +1. **Xuất PDF** calls the backend download path only (`handleExportPdfBackend`). +2. On LibreOffice failure, fallback opens `PdfExportDialog` but currently passes **`initiativeId: ''`**. That disables `useInitiative`, so **Section I** of `PrintableReport` may show placeholders. **Fix when re-implementing:** pass `selectedReport.initiativeId`. + +### 3.3 Component names + +There is no symbol `ApplicantPreviewPanel`. The modal title is **“Xem trước PDF”** (`PdfExportDialog`). + +--- + +## 4. Data loading for the client preview + +`PdfExportDialog` renders `PrintableReport` and passes data from three React Query hooks (all under authenticated `apiFetch`): + +| Hook | Endpoint | Type (TS) | +|------|----------|-----------| +| `useInitiative(id)` | `GET /api/initiatives/{id}` | `InitiativeDetail` | +| `useReport(id)` | `GET /api/reports/{id}` | `Report` | +| `useDocumentsByReport(reportId)` | `GET /api/documents/by-report/{reportId}` | `DocumentListItem[]` | + +Hooks use `enabled: !!id`. Empty `initiativeId` skips initiative fetch. + +`DocumentListItem` includes optional **`content`** (JSON string) and **`summary`**. Each recognition document (Mẫu 01–04) stores form state as JSON in `content`. + +`DocumentType` enum: `Description = 1`, `Application = 2`, `ContributionRatio = 3`, `Evaluation = 4`. + +--- + +## 5. `PrintableReport` structure (fallback HTML) + +### 5.1 Root layout + +- `ref` on a single root `div` captured by `html2canvas`. +- Width **794px** (A4 at 96dpi), padding **40px**, `box-sizing: border-box`. +- Font: **Times New Roman**, 12px, line-height 1.5, black on white. +- Optional prop `schoolName` (default: `Đại học Y Dược TP. HCM`). + +### 5.2 Sections and data sources + +| Section | Source | Notes | +|---------|--------|------| +| Header (ministry, school, “BÁO CÁO SÁNG KIẾN”, export date) | Static + `new Date()` vi-VN | | +| Metadata grid (mã BC, mã SK, tiêu đề, đơn vị, trạng thái BC, dates) | `report`, `initiative` | Status via numeric map → Vietnamese label | +| **I.** Nội dung Sáng kiến | `initiative` | `shortSummary`, `description`, `objectives`, `scopeOfApplication`, `expectedOutcomes`, `startDate`/`endDate`, `estimatedBudget` | +| **II.** Kết quả thực tế | `report` | `actualOutcomes`, `actualBudget`, `implementationNotes`, `challenges`, `lessonsLearned` | +| **III.** Checklist 4 tài liệu | `documents` | Fixed order of four `DocumentType` values; codes, `DOCUMENT_STATUS_LABELS`, `approvalDate`; optional bullet list from `summary` | +| **IV.** Mẫu 01 | Parse `content` for `DocumentType.Description` → `DescriptionData` | Only if parsed object is truthy | +| **V.** Mẫu 02 | `DocumentType.Application` → `ApplicationData` | Authors/support staff tables, classification labels | +| **VI.** Mẫu 03 | `DocumentType.ContributionRatio` → `ContributionData` | Participants + total % row | +| **VII.** Mẫu 04 | `DocumentType.Evaluation` → `EvaluationData` | Scores table + total /100 | +| Footer | Static + date | | + +Empty string fields use helper `Paragraph` → gray italic “(chưa có nội dung)”. + +`pageBreakBefore` on IV–VII does **not** create real breaks in the **downloaded** PDF from html2canvas; see section 6. + +--- + +## 6. Download pipeline (`PdfExportDialog` → file) + +1. `await document.fonts.ready` if available (helps Vietnamese glyphs). +2. `html2canvas(ref, { scale: 2, backgroundColor: '#ffffff', useCORS: true, logging: false, windowWidth/Height: element scroll size })`. +3. `canvas.toDataURL('image/png')`. +4. `jsPDF` A4 portrait; image width = page width; height from aspect ratio. +5. **Multi-page:** repeatedly `addPage()` and `addImage` with a **negative Y offset** to slice one tall image across pages (`position = heightLeft - imgHeight`). **Limitation:** content can be cut mid-line or mid-table. + +**Output filename:** `{reportCode}_{YYYYMMDD}.pdf` (fallback codes: `report?.code` or `BaoCao`). + +**Dependencies:** `html2canvas`, `jspdf` (see `fe0/package.json`). + +--- + +## 7. JSON shapes (`document.content`) — examples + +Parse with `JSON.parse`; invalid JSON → section omitted (or empty). Shapes are defined in [`PrintableReport.tsx`](../fe0/src/components/PrintableReport.tsx) and should stay aligned with the four tab forms that persist `PUT /api/documents/{id}`. + +### 7.1 Mẫu 01 — `DescriptionData` (`DocumentType.Description`) + +```json +{ + "introduction": "Mở đầu...", + "initiativeName": "Tên sáng kiến", + "applicationField": "Lĩnh vực", + "currentStatus": "Tình trạng giải pháp đã biết", + "purpose": "Mục đích", + "solutionContent": "Nội dung giải pháp", + "implementationSteps": "Các bước thực hiện", + "conditions": "Điều kiện áp dụng", + "trialUnits": [ + { "id": 1, "name": "Đơn vị A", "address": "Địa chỉ", "field": "Lĩnh vực áp dụng" } + ], + "novelty": "Tính mới", + "effectiveness": { + "economic": "...", + "social": "...", + "teaching": "...", + "productivity": "...", + "quality": "...", + "environment": "...", + "safety": "..." + }, + "confidentialInfo": "...", + "submissionDate": "2026-01-15", + "authorName": "..." +} +``` + +### 7.2 Mẫu 02 — `ApplicationData` (`DocumentType.Application`) + +```json +{ + "unitName": "Đơn vị chủ quản", + "initiativeName": "Tên SK đề nghị", + "investorName": "Chủ đầu tư", + "applicationField": "Lĩnh vực", + "firstApplyDate": "2025-06-01", + "authors": [ + { + "id": 1, + "name": "Nguyễn Văn A", + "dob": "01/01/1980", + "workplace": "Khoa X", + "title": "PGS", + "qualification": "TS", + "contributionPercent": 60 + } + ], + "initiativeClassification": "technical", + "contentSummary": "Tóm tắt nội dung", + "confidentialInfo": "", + "conditions": "", + "authorEvaluation": "", + "trialEvaluation": "", + "supportStaff": [ + { + "id": 1, + "name": "Trợ lý", + "dob": "", + "workplace": "", + "title": "", + "qualification": "", + "supportContent": "Hỗ trợ hành chính" + } + ], + "submissionDay": 10, + "submissionMonth": 5, + "submissionYear": "2026" +} +``` + +Classification values: `technical` | `research` | `textbook` (mapped to long Vietnamese labels in UI). + +### 7.3 Mẫu 03 — `ContributionData` (`DocumentType.ContributionRatio`) + +```json +{ + "initiativeName": "Tên SK", + "mainAuthor": "Tác giả chính", + "position": "Trưởng khoa — Khoa X", + "representativePercent": 40, + "submissionDate": "2026-05-01", + "participants": [ + { "id": 1, "fullName": "Nguyễn B", "workUnit": "Khoa Y", "contributionPercent": 40 } + ], + "digitalSignatureConfirmed": true +} +``` + +Total row sums `participants[].contributionPercent` (display only; not validated here). + +### 7.4 Mẫu 04 — `EvaluationData` (`DocumentType.Evaluation`) + +```json +{ + "initiativeName": "Tên SK", + "authorName": "Tác giả", + "position": "Chức vụ", + "evaluationDate": "2026-05-11", + "noveltyLevel": "high", + "noveltyScore": 35, + "noveltyComment": "Nhận xét tính mới", + "effectivenessLevel": "medium", + "effectivenessScore": 45, + "effectivenessComment": "Nhận xét hiệu quả", + "conclusion": "Kết luận" +} +``` + +Levels: `high` | `medium` | `low`. Printed table shows shortened level text (split on ` (`). + +--- + +## 8. Field inventory (PrintableReport) — quick reference + +### 8.1 `InitiativeDetail` → Section I and header + +| UI label (approx.) | Field on `InitiativeDetail` | +|--------------------|----------------------------| +| Mã Sáng kiến | `code` | +| Tiêu đề SK | `title` | +| Đơn vị chủ trì | `owningUnitName` | +| Mô tả tóm tắt | `shortSummary` | +| Mô tả chi tiết | `description` | +| Mục tiêu | `objectives` | +| Phạm vi áp dụng | `scopeOfApplication` | +| Kết quả dự kiến | `expectedOutcomes` | +| Thời gian áp dụng | `startDate`, `endDate` (ISO) | +| Kinh phí dự toán | `estimatedBudget` | + +### 8.2 `Report` → Section II and header + +| UI label | Field on `Report` | +|----------|-------------------| +| Mã Báo cáo | `code` | +| Trạng thái BC | `status` (numeric → label map) | +| Ngày nộp BC | `submissionDate` | +| Ngày duyệt BC | `approvalDate` | +| Kết quả đạt được | `actualOutcomes` | +| Kinh phí thực tế | `actualBudget` | +| Ghi chú triển khai | `implementationNotes` | +| Khó khăn | `challenges` | +| Bài học | `lessonsLearned` | + +### 8.3 `DocumentListItem` → Section III + +| UI | Field | +|----|--------| +| Loại | `type` + `DOCUMENT_TYPE_LABELS` | +| Mã | `code` | +| Trạng thái | `status` + `DOCUMENT_STATUS_LABELS` | +| Ngày duyệt | `approvalDate` | +| Tóm tắt (bullets) | `summary` | + +### 8.4 JSON document sections + +See section 7 for keys. Every field in `DescriptionData`, `ApplicationData`, `ContributionData`, `EvaluationData` maps 1:1 to labels inside [`PrintableReport.tsx`](../fe0/src/components/PrintableReport.tsx) (search `Paragraph label=` and table headers). + +--- + +## 9. Re-implementation checklist + +- [ ] **Official PDF/Word:** Call the same APIs (`export/docx`, `export/pdf`) so template fidelity stays server-side. +- [ ] **Fallback modal:** Fetch initiative + report + documents; render a single scrollable column layout; optional: fix Dashboard Overview `initiativeId` on fallback. +- [ ] **Download:** Match `html2canvas` + `jspdf` multi-page slicing or replace with a service that returns vector PDF (e.g. server-only preview URL). +- [ ] **i18n:** Labels are hardcoded Vietnamese in `PrintableReport`. +- [ ] **Tests:** Fixture objects for `InitiativeDetail`, `Report`, four `DocumentListItem` records with sample `content` JSON; snapshot or visual regression on the root `div` dimensions. + +--- + +## 10. Embedded PDF preview (optional UX) + +If you need an in-app preview that **matches** the official PDF (as in a browser PDF viewer with page count): + +1. `GET /api/reports/{id}/export/pdf` → `blob` → `URL.createObjectURL(blob)`. +2. Use `

3ukDLu*I0AQ z%x(^`+CD3nv2bC5su|ljN=*!0Ds|~)-Nd3G`n!RiRby@gwU>d&6i#C4Fd0tCq@n1c3-oyUa@S3N%WXQ z1mb6RD1j-gq)fN1n&s(r2Hosh|HJ|=hCZ_$;I<0&>F(nfSnS-YZWHsG{dwe3l^2kl zd29%cOf*(=eR0{~xY;$9<>_2{ahb^6ZOYhBa1HkvOL-QTDdX_~7~WZ#rg)u}iNt$V z!FA}C$7=lSH*)TmO4DYB)2QpT@mH!ZrdR&XGx)h$eJ^TnNrf0zU97mERpVJr3|yS# z{3ChUz!Jt{GVyFRqYbZ;+j=a*y8c&XH`^mLT^>#lJXV~7xZ48W|K}o}CPJ)EWQ!XzM4(>m7B}AI${140;Yuw7xoC=&0lV zwc(&OjM|Z0>*+D?s5kwQ>DJCkq@zyz6PecT$)k>%@h9ej)*O0{-VN+`{M1wsGv(jN zw$<6UIB3>iTxzS+r`XeyKfBr9deNSF*?NAGIpN=BJL*e}pIUFdI4O11HGeFF7Mw}O zpDYHtqCYVmj5JLn<%5xO{^UwvUlG}dZFTbW7jGB-^6QTJ5S`{TZFT%~t)ng=McV4< zDb8X6^&{QMP5Q%`AT=3( Settings: + """Get cached settings instance.""" + return Settings() + + +# Global settings instance +settings = get_settings() diff --git a/be0/src/infrastructure/vector_db/__pycache__/qdrant_service.cpython-311.pyc b/be0/src/infrastructure/vector_db/__pycache__/qdrant_service.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c2db6bcdc8d7acebb67220a053d6678914da50b GIT binary patch literal 14829 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 List[float]: + """Generate vector embedding for text using Ollama""" + try: + # Generate embedding directly with text string + response = ollama.embeddings( + model=self.embedding_model, + prompt=text + ) + embedding = response.get("embedding", []) + if not embedding: + raise ValueError("Empty embedding returned") + return embedding + except Exception as e: + self.logger.error(f"Error generating embedding: {e}", exc_info=True) + raise + + async def add_idea(self, title: str, description: str, category: Optional[str] = None) -> Dict[str, Any]: + """Add a new idea to the vector database""" + try: + # Generate embedding for the idea (combine title and description) + idea_text = f"{title}\n{description}" + embedding = await self.generate_embedding(idea_text) + + # Create idea object + idea_id = str(uuid.uuid4()) + idea = Idea( + id=idea_id, + title=title, + description=description, + category=category, + created_at=datetime.now().isoformat(), + embedding=embedding + ) + + # Store in Qdrant + async with httpx.AsyncClient() as client: + response = await client.put( + f"{self.qdrant_url}/collections/{self.collection_name}/points", + json={ + "points": [{ + "id": idea_id, + "vector": embedding, + "payload": { + "title": title, + "description": description, + "category": category, + "created_at": idea.created_at + } + }] + } + ) + + if response.status_code in [200, 201]: + self.logger.info(f"Idea {idea_id} added successfully") + return { + "id": idea_id, + "title": title, + "description": description, + "category": category, + "created_at": idea.created_at, + "status": "success" + } + else: + error_msg = f"Failed to add idea: {response.text}" + self.logger.error(error_msg) + raise Exception(error_msg) + + except Exception as e: + self.logger.error(f"Error adding idea: {e}", exc_info=True) + raise + + async def search_similar_ideas(self, query_text: str, limit: int = 5, score_threshold: float = 0.5) -> List[Dict[str, Any]]: + """Search for similar ideas using vector similarity""" + try: + # Generate embedding for query + query_embedding = await self.generate_embedding(query_text) + + # Search in Qdrant + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.qdrant_url}/collections/{self.collection_name}/points/search", + json={ + "vector": query_embedding, + "limit": limit, + "score_threshold": score_threshold, + "with_payload": True + } + ) + + if response.status_code == 200: + results = response.json() + similar_ideas = [] + for result in results.get("result", []): + payload = result.get("payload", {}) + similar_ideas.append({ + "id": result.get("id"), + "title": payload.get("title", ""), + "description": payload.get("description", ""), + "category": payload.get("category"), + "created_at": payload.get("created_at"), + "similarity_score": result.get("score", 0.0) + }) + self.logger.info(f"Found {len(similar_ideas)} similar ideas") + return similar_ideas + else: + error_msg = f"Failed to search ideas: {response.text}" + self.logger.error(error_msg) + return [] + + except Exception as e: + self.logger.error(f"Error searching ideas: {e}", exc_info=True) + return [] + + async def get_all_ideas(self, limit: int = 100) -> List[Dict[str, Any]]: + """Get all ideas from the database""" + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.qdrant_url}/collections/{self.collection_name}/points/scroll", + json={ + "limit": limit, + "with_payload": True + } + ) + + if response.status_code == 200: + data = response.json() + ideas = [] + for point in data.get("result", {}).get("points", []): + payload = point.get("payload", {}) + ideas.append({ + "id": point.get("id"), + "title": payload.get("title", ""), + "description": payload.get("description", ""), + "category": payload.get("category"), + "created_at": payload.get("created_at") + }) + return ideas + else: + return [] + except Exception as e: + self.logger.error(f"Error getting all ideas: {e}", exc_info=True) + return [] + + async def delete_idea(self, idea_id: str) -> bool: + """Delete an idea from the database""" + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.qdrant_url}/collections/{self.collection_name}/points/delete", + json={ + "points": [idea_id] + } + ) + + if response.status_code in [200, 201]: + self.logger.info(f"Idea {idea_id} deleted successfully") + return True + else: + self.logger.error(f"Failed to delete idea: {response.text}") + return False + except Exception as e: + self.logger.error(f"Error deleting idea: {e}", exc_info=True) + return False + +# Global instance +_qdrant_service: Optional[QdrantService] = None + +def get_qdrant_service() -> QdrantService: + """Get or create Qdrant service instance""" + global _qdrant_service + if _qdrant_service is None: + _qdrant_service = QdrantService() + return _qdrant_service diff --git a/be0/src/initiative_db/__init__.py b/be0/src/initiative_db/__init__.py new file mode 100644 index 0000000..e58f433 --- /dev/null +++ b/be0/src/initiative_db/__init__.py @@ -0,0 +1,17 @@ +"""PostgreSQL persistence for initiative cases, drafts, and related documents.""" + +from src.initiative_db.engine import ( + dispose_engine, + get_database_url, + get_session, + init_engine, + is_postgres_enabled, +) + +__all__ = [ + "get_database_url", + "is_postgres_enabled", + "init_engine", + "dispose_engine", + "get_session", +] diff --git a/be0/src/initiative_db/__pycache__/__init__.cpython-311.pyc b/be0/src/initiative_db/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4678d70dabccbdc4f088dd7d6aabe4218a95363a GIT binary patch literal 528 zcmbu6y-EW?5XWclGZ+&D8_UD)K<-{Zd;vua>8@Ee*~zhRU+iuKt$Yhxu@T?KQm(bK z3#2lYvq?m(-G%?`3^Tv^!@i702jI@rm*NcIW1H;7+sAcAk4KPDfMn9ESnU;F?H7I> z6hR#pVI36_gA%0w%!*hBQ%J-6%bV8NQsZ=Tm2{|$G8UUTCW}@ls!>)6t8Ouw3xnn? zkyCdwzP4mEkn&EE%eaS%oqUbwFetJBgh5PC}CFbkN6~lLyZG6Qk$ZuW2r4+0MbP zf6H(~JBFjVSy5G-zrvbQ*qr8~>vH1ce>gekTa!HN?lyz-x|PccFZ7s3=sUnHNLD^$ d>>Va=Fj)l=^H%U*If{mkvX_V0U42Naz5(_ykSG8E literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/__init__.cpython-313.pyc b/be0/src/initiative_db/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30934cc01280bb86c606922f7b522702abce8a14 GIT binary patch literal 444 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 literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/application_backup.cpython-311.pyc b/be0/src/initiative_db/__pycache__/application_backup.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..98fd131e9261c236e7ba9f5834c8564f7145868e GIT binary patch literal 15504 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 Ikm$4jA1Cyx)c^nh literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/application_backup.cpython-313.pyc b/be0/src/initiative_db/__pycache__/application_backup.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ee86eaf0cb3baaa7adfb22e5d86687b28cd2c22 GIT binary patch literal 14228 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 pOtTpYT09o`*40c}sx96;V@)xX literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/application_storage.cpython-313.pyc b/be0/src/initiative_db/__pycache__/application_storage.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86f6bfbe57f12cac4d9e5f395e2e5423273d228b GIT binary patch literal 17950 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 literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/drafts.cpython-311.pyc b/be0/src/initiative_db/__pycache__/drafts.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..789e38b474a73e8e4ecafca6ffbaa24f5d5e3861 GIT binary patch literal 13571 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 NI5`Cco)oGR|KGNU!R7z} literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/drafts.cpython-313.pyc b/be0/src/initiative_db/__pycache__/drafts.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54a43969e781a4af2ccef40e51c0f111c96f48ad GIT binary patch literal 12798 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 literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/engine.cpython-311.pyc b/be0/src/initiative_db/__pycache__/engine.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf05dc32f69d9c21ed5ac3cf8fdfc81028358ea3 GIT binary patch literal 6565 zcmbUlZEqCEdG_{R_s(l`ycldJUfU43a4{jgBqR;dVISncJ`>v{aD=SZw_~$pZ}+mh zhp}rE2T)585>=#%s8UsbN*g7S`k|HjsY=Kn*o!WTwUs5LDpLFH5L88csQS$8-TC%x z2<`0bv-9%I%rnnEZ!^DdYvT|k^)z>3iAU(~q*70;TVU=#gu-of3dtykWJdO>Ou?7) zF{I3@Y>uU0f6fnIzZxh6b3xh{P(y`qE?kJ@BD5Y3Ve<)wCCCj z9l4IehTH}Q`4DC=#OoyBjh`_HeF3kVa+_pU+AN3h7CG`WXo6a&%t0+mYq8u`xh=O% zenyU8^XIzcXXQ=s?UsAxE%5D;1-TQx&%k#pe4my3xtSG-(DK37^B$)sA5e8{E&a$>YHflZTi#U-+gXu8#c^gcG6jd=r078czu zcQ^B!>kFN2n(Ukt5^Md>H~QDk=nknZdG4IkQD0A-mh!NRIq7ZqhuqM3IyIRVCR0a7 z(n98_kR6*8(x)>MlM{l0XMka_WuRHqt)iIMWo!z&I6+Wk;nbR8Ig&C}ghNQ`QK`fONc~>F{14@WOFzO^$UD#2hDTn#sgA(aL# zX-dWB8gy64Xs7663a<-0i=`=5$?sf)EeNUXu;5~AQUMd(^(A@j*pbX=W<7%EGpmMO zl&4;GwGtHNu{u&vW?btfytsG2fIlcH7bO*IR$~6f(Ael`8q5G_m}~mhr%nKE+W98f z9V`n6Uyzj<#S+FQPf|DIj=+X+5gW?1lBZmJ4g3t%Ucu03O+m&gwqWe+d8`R$iTEHa zLw#xvh&BOpaAzXqgzzPtFIm_Kfh;+JX~i%tC#dQ(;C>uG`A1H-I6OQej;D`-WgbsW zX2!B2&>>RVAJ<83Pr_%klUZ3p579+IcqHtRip4#qk>BIt=pNUr3>N2{XTigmB?GUu zQ}mx2-7wK}@S1&KHvR$6BD%}RZ>cxbl~_mB7u>Mv9vg1sA0PntSgxIWfB;kx7kNDR z_ZAe2UBMsCT$`zGLxFI$3x%VLA1;Ua>%kR2_sbJkPS_o1%l>yO{&#KvyUQGZD|sXN zYwP=-m&={6R61WNbB8M2A=|%B{(q2H!m+B4Y15c{EE|Ch0Jz5nqQM6UKotc7PYvz^ zr_iIehO-8zdM;1e>WNrZc$xd-P`Hh(CJTL`QEs)l#f^`_Yq5}E8Q84TnaO4*Gaxx{ zr^VqE$W3Y@EuI=5abnFi2zGqB=>%s$YPE}>BclfhGP(db9ndDq68wM3#A3-%jqT8! zg4gT^u!vToZ8s0un+MC$J(cJlJ38{`m;Q96^e+t?OP|S>-jIzz|>7;fii-Xnvq3Sh^Qu2N2+JrX?AcIjdOb zA-rQ@qbB(Ug3E=CiYe9yf{qYdDI3p2e;Qsh2>=+ub(OiE3fHqZv=VLq6>E3w|CIe5 z{~La3p}ci}W$XTO^gtzg!1f=YEb%adSb(cU68-~=y8gKNFMAnc`eX)tW)lm*i;@)QqKTy@f4@4)>Rc_$VXshlYv5fQ z^D)TUwmNR1CGzoN!f^{8?|!brKdIA34q6jv0PRK1t)_q0cZofNW*G*Zff&riN<`)l z579$`K%@Km5eGtRp>}i-YN7m~kkwz%i-IyOfJzGm1MJHO4woNuu0A?IDcM03WW|gh!!Ev&+CPPV$Fx^aYLewinq}QEO51i1UyC`0sR1+ zfU3`8!-$dwIIcp&6r3A0VaTt|Zex zBu{*oJW)=LR+6LkTi8xwo1ZT8(-nSt@z_dd;;Wrsr|r(;<<8@k&f~U!(@Oi6TbFNK z{`kY2A6Ahsu^g@4Houam0* z&w}^J`VW4Zh$oZy6ul20K72BkNiG5IbRQl{vfn1Tp@X4s4>15e28@^rYQWxuLd}3d zsh&RSwdzIqE)mPRCV9O4*N8pjP@t=48M8t*m7q#NQj&1GLKvZtsk)If!=%k z_OOVBW#MJ+lM51XcmV5ZvAx;FwYo8|+I2+uQa3jLdS>;uWB7;w*BQ0@4^^4R@@qm~ zQdQbg+x@PDphGB1d|atG2x>`nHa1?;ETw?chM^m+{M$%8f6e05FAt2@fr2S`&1V3B z&kJ?hq23j~%kIvV`Lh-NtQ|hP5(P$eyDaLbHyDjO6c2lnV7@f$4T*@OVST`)OHbhl zjqYZbvAF5+4$v!2%}&rOb(i7RYoX_Z?c{w|!i=S+K+1Ycp8+gXMUCCEjnx*3S=D+P8wGjKJ)50ONj$_kS1fFUPl6 z;@j=mI(a#^0Ze-&PV6j;+6Jm24}tdxy$^nx=qGRKDSD}$_blBq$Rq*nHym>$!G06# zO(lKb?55@9b4TKVJ8>4;?elI1lhHw>WCH*&EwoJRL%qDCsoae7 zYE%1RQtpF0PEpqLqUdm5np)rWV)oyE5Wy&BL=mf2~BIzYj ztU)vhBKi+h$-O3aPuN>l8wTO_c94cwitcWJ#G3{QAKj3KL_1Cd#C60yj)z5&Txb9> zn+Xo}KJB8|bdd~QR8>f@X>28coR;0#i7?jHA>xFnB2n>gF}aVzKP%{RNyUealYmFO zBvpRfFzgEP&Lf;`eDiH7Qg%Dw=!^~ z_JV^9Gs=JwXoX=Pa}c!81GqK@;xsQ10u0y?FX&)Nn-|28Z*$ehF`&|QaF2zy3bU!D InYy$80stEoNB{r; literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/engine.cpython-313.pyc b/be0/src/initiative_db/__pycache__/engine.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..182202907c929312b6e02ae3a59b2cd5f661eca6 GIT binary patch literal 5913 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 literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/models.cpython-313.pyc b/be0/src/initiative_db/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b1b5cf70bdbc92ae0715097229493ee44b0febe GIT binary patch literal 23479 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 literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/repair_split_submission.cpython-313.pyc b/be0/src/initiative_db/__pycache__/repair_split_submission.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d0a9136d2316dceee307529f0e45628a559f0a7e GIT binary patch literal 15867 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 OA`0KXxiFV_!~Y-eEKSe= literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/submission_readiness.cpython-311.pyc b/be0/src/initiative_db/__pycache__/submission_readiness.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3120ba29502869732ecd85e0eb8473da4112ff57 GIT binary patch literal 33070 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 BRW|?t literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/submission_readiness.cpython-313.pyc b/be0/src/initiative_db/__pycache__/submission_readiness.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f8f6c6c51f92316993286aa92f348a98507cc245 GIT binary patch literal 28098 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 literal 0 HcmV?d00001 diff --git a/be0/src/initiative_db/__pycache__/submissions.cpython-313.pyc b/be0/src/initiative_db/__pycache__/submissions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..482448969a4a3a9e7334322702f6a6f7dc702a4e GIT binary patch literal 70711 zcmd?Sd3+q#eJ9vQbpw3>4Rqr^8wYU`2MK~Cz{>y#k^o4MLa`}`kko7dO#md&psN9i zr0p1yJPsInhU7>dg0T`okrGj{v!XMem_8Cylw{Mf^4V?|l4^G_wv=dh#(!*vl$43( z>?gb5->a?zjV3ACp3i({w@AExRrRiV$M1f>_d!O6-GHlPA9w2ix@j={1KlW>RgM@x z_$`aU@MQyU5DdJLHw_s3O@hfwar1y#FtcAy;MlK4u;7;)u=ZO8Yk!)M#?n~^()(?K zjm514_I`)p=ywXv{tO|bKU2u;cL^@`E^Q#IKU>J|cMEQIpFZH}&k=H1+%}NgpC{z? z=L`A$1wui8p-|XgBoy@*3&s5wAP}*N6l(Dq-f%1N@;AL^gKt+G0P|4y>#H)lV z7SBMuTBv66OvE<`8~SU6n*Lg$mOXO~)b-a3_5BS(Lw}>t=rugb4Gk+@qy1JI9&Ktj z%J)KZfuYjCXB8Rv>_=PL&55V2d@t`VGMwUsP5eII!(yBH{rmwwhvKJ7Td-j;tN@9J3q)5u~1jz3bi_=WE%Uee{MO;mwJEb_qkKJ;aLR-p->>Fx zxLb7p#KEHF1U8wz`;Lf#{@;kgMR z$Uk(jX*4i)8Y!kiQ*%>D@I=r%7Z{xmhMRv*;HlmicLYMA**Q7yV7)1xJ|370&P~k( zWcOvf!p=g&+BQ3y94 z43EXF;ox*|Y!2C-UEy<~F+La$BVoNQo

yg#!l$(18hC>pMz zyifSqKH+C8tB*?*Bm++bx_E2}EHF`f>TJZ{jR}Lv)z^hOVABBjlvm$i*ZKZ|E-qoC zNZ|mYMNkMpOh2FjKLePSUnMG1F;aUP05%p_GPEics)gdo@KRbQ{$!-{eynu#UldhD z*+pzk`T=!&1|t4c_K8aQwY;rl?GA}DRWPdFcQU!JBnmS@nf&U_i3(cr=aChQeO~Sl zIc0!LRAJf&cywG6o>PVV3_^#g_Y$Sa@D!}}_i_GkZ`WV|4Ukh3IQ9q5b@xZZ=*vVU z)&{DD0bo0WpUsf4zq_|9)PwShu%?4pRzsk*li(j&CJN-p{J>dOfvzZr9m15bw~(i$ zn}AfJgvKs8keC;kZmDh_q@fN+{45!j8{)7xgaQc%fmfcB0}_Sk)q(yqG^r@DTrVh!OY)xXz47Bv51{sqSMk$3@ZPI`+D%W-d3)b`_2&_CpA9u7%t3ToS5F{H+=+IC zM3bmje8rKnscHV;I9uG`!5-fOS)HGSsf0D|rY0@?uVgFl`q-FfiHu7jQaLoFR0 zcOE>l!+)f$bwvM$c3 zrrvhw)VZlcJ9H7kG?EuE57W>G3_(qzyXbEqv4QRYfHqLD6dp^sss`Ij;;f5-V1Xd& zKM8D@KhxJ80Q$u;+;ehfRFom2OO_BJ0kVsgr^TijMoL&~nD9K{r~$qk_y)%?MXwh* zC-GSz|FdJXCkD`jBwkAhtk;{!&q9t9y6*I7p;IuTx|KAl7vR|kl`|&L!`eA(x zZ?Z3SU;m(ZZt-HuGSiu=^SVR#lwPm9xRuj&0E)K*#G3%t`UVpw)**P#XiNf#B`}Vx2D=t;sEL%ERwp1)@l*$_As7jY%m!Ihy z>T?r01K~t2Hc=2qd;qy8tm);5wt(J<@C2B!MH%?6vtjBJxebZJnJUiTj|jhy|1g~j zjOspd>g;(J>xII)%O26zAlVvj+Ez^3R*1G$l5N#!Yus#`*6B??{gkUzuHm)XpVz-y zf4%jmO|LiMjaYh2g6ldaxsC~zV^j7z!C1%YpD+#fgZfPv(BoP2gRS~Cr~+0!+|)Jm zm=5c->yPJ<0;6a251!=`M&gBlTHx_g*#k~L1*8!`{w%_uqd0|dMs-tm*W>FitRFur z+RG(-xnL}p>zzxffKM4u3=H&lD`AZh=~&G}@H~aHvN_aUkJ_uC7o8Aw=xTKl$}M?C za#KE??qm*5Q;d4uF&&rFp-Y{j)f?J7ch(#EC?W!;GHQvDM=}wT!y3<@L#T2hC)_fF zy_JwHXmhYpJpAOm7{5f92MKgsq}Dhx!NHO zYinnob6%F)=4G98UbfrjWuJ3ij@#yymz@`Z*?MRD2Q-^M7{}m77s01Ont;AO0mQYJ zaJ{H^jra78p8=VD?T1Y-fmi@}5s8AJAuJxWE%B(LCE6VBkKTCJ%K_(X8Zr@PbUeC6 z8)XoP+;e>l!Mg#3m+-n5B0#!cdphFn25$KS1QGhoaOG!0J(Lp=tmDyjUofZMfv%>Z zoO`$^;hd`W-o7ef^hnc?1?m`#eO#pTTk(B_p z#GfQoQ;R*h6EklvO!pNkKjBmI*&`v zMx*%uQ;I-^fUj0n!C z=!{Cvs9=fC5OFXSF(fz#MCX9y91tu6a>Sz(yG8p#$-a`z5JB{)xu&eM|fv|u?c zNA$;fMSHVkZ=Ti}O*WJqFD;K3gr`jgm+eCxoR7(w))}m}X#--gXvqzcArCWy!prC_ z!joe7&Awj5tBfqb%)6vI&WK(K@?r|$naO=vbCL}(X3c~W-hIWO1+v4$X*0I-41~B%r8)!ul+8#*lgMnhcrp!9zD=Xblr1HVSGZ6dgd@S!`-c3vSq@`T6R7#f0(UvLu{G0Z=NqgO8 zy=Y$|*_Wth!~-+V2#d~UZXUxQM5Nn_NJTmb(8jWqJ4v8 z-yj$_urBIGlJrG;G3%ZG0FSzNIK2`(xc$Uew_Dv)>IqL|B$soVMn=bDujX=w(EHy3 zG5HLTGdvrUVO}Nn!2#*xj1*Io8%Q8vG;I9%9L^Ln4(pLuuACQqX7bfrhIZ|a2D?Iy zJebMECG#>aFqpbH3kh)pXW4-g9SL?144$VWClK>EchK^suxxgr-hFN`%cHsQd3&%m zJo5obszj+fmwN|$Z@e0j!xY}0z8l{=k11dXcAZNY`a+R}Tbp+xFM>k>a3blDg$om9 z#a<-zLDgc*r;|5-B+~Ei?>a&3>puPuQ1V~nKm1iVqq_GUu5tahR=!hGce7;mWXWo= zWUW-PRxDU26|55->m|qf(YCnRF>ZW#WOO9%DVbRHM5ExUOWu|`3d?!;fzb!1D5LS! z6WgEIC_1VoNA;LKUQrp#|Mt#_onz<6&R<@B*+GATWmVi+AOPc+?3=%&Z@hZ7{O~3(Y^l+$SV#BFi`;|$S zI(Yu!C_92?L!abh^3Nh&0{`JgIDi9=N_mh*+op{XU}_21eP_Y1H;X; zoy8|Nbt4}}_2o_7%m=_U=nkZ|@9!Z5ba?;32^CPO@&~)lp9uK*uD)$A9KC?!neOwbAudCP#7DRf47-x`fO)}1`v!aBv?Rc6= zPk=1zQtpr$x^_6ZGpRz^&PN%5o~8+KZ7G|med<~!mC*v{_F6Csl&kLWPHHHd^%wS^UXg9NfgYitW(1< zPiybLP{KcfBlG^IbF3z3+*$Paz=eS+hv%JwMWUlla@5^)G)_7ir`)xYd+Ft~lkPRL z_olmf(%pR3D7sfm?$sZf40%q_5P8lI4JM){;7sdGpeE+%eEJC{VFjS5$(eDcEQ@0I zip)ZhRAz*k)uXPQwWrb`Rg1fFIo~m2CeWfuUfP*3VwjDUAF-#>=unqo zLnJTrd@7w|#I1OnCVJGfEkbO<K+*e z#)ox4Ly)}KIxt4cQ_pCJ=6E8x4vY^PGwYz<)ZVvkyGxfaEL+3z)V1IivZ-$&42+QZ zgbgfh5&@%i;5rHJ3R@BpoEAsI^-eaG698Fsgda{=fgEr^*#^!s96n*_;yAXhcxr#b z5JCXKgoGg)>E^#pKGR8@RQD=YmnpNl5>`NVppuXw;%TYKtBt@5R$FSlmG}X{qX6uh z-HE@Bc>gc{!~YphYL&T7&M9Z}Rr9qH(b*z7TLjAt_f$!hRI>PTkyz3!l{5>5t8tH6 zrwSItj)?`$QbF^WC2nyMg$G_|ytF!AHh-$T_R^+TELR+2`D&?r^*if!i{-oH-ucs3 z6IP`T4pt?tPqP*46V5bq+&WkBK=xP4FkMV=~DSz8D>&LX8NJ;a~sJe}sov3#afaUo)dZ+Eqvg zHvIBgQ6CL#<_sCZ_qkA;BRRu4JpPG_%IraWsq*PmdiUYMkIcl%ya$qI1lnzCA99^* zgnY%>E}jc;?q%{h;e-KaDtw5d1mGlcyP4z^juIfBfKA~S z^fKq%Nxl~z^`2n!%W?gEgZx_vEdVGC6AA-Xfz^Gn`lhvF(pn)@Hi_0|$=W=+J#Myw z9b_tvyYe4Dcj4TG;jxj6BNK-uSM^QT!b#Uc(N!n8>c)(5y9>-GYiYcoWNcsD?0ob= z;ziX?)Lm)Kws$LY*?WJAvO3lL|G^ zARYkqIm&W}H94rljIlJxPc}$y72FJ%R@Xq4-t_s?bH+=#Be@Y`w8)ZoH?Q+Y@d``uf}Mg5X)3fA_(o#HwAsg5Qn2G0P2*GGeSR{%yQStA2J7<}Oy^%mYzEW$pOKGNADcG(Y4Y`xLla#V`~$N- zIG8X%a)|#Xy>w>ezx<~I=j+WfQgwk+Q8oBqlf#Pm8;WmE${U4gQ)YWWY8P-McLAA! zlt6G5t+AKj0Wxp%Tx|H(xhIE3+XBh907sOzyl+`wFubt+`8`6@w(AST1-qmLyF~A9 z$-7%{?-AX5B)GOcqpk0|JmYI08@@Ok&o9P3<;)j63qU}K`Woy{Ef{|Y<+U8 z=&F@mwKKe$&NEl%0@zpQj_&%z4OIW)hVhNq1%UxW~bK`UM{VpJ~eNI@nCE#{+c4VFSqyK2*;rXjihqXu}M()D0- zIi3$*IJ!J_X}R7{Ro_T$6sqW=Xw`r;MmWX{>TcS}HLtV>l9o;2R|U5EJsKVd@RGvSez;uBbHs4+v|Z zS`yVGV^mZ2{BJFlJT;=dRt9(aE!lj%WO50PNA^heJs_MtWluGKqdB%f z^wde7I>Dj6Z`zkl+Lwv;6_S0$P5YWj`x?={PO`5*o8}&4tnU|xEqb5HzVFuN1XG@h0 zL?0cR*|la~HcHu{3_&uiKSks}yJQBjDqecOO0kdCL>6{nT})ArR8HBX1D178iU^+z zu`*&B&KWkG(ebN?O`}UuiX)SE#;4jH6)nL&PY3nD8#3C#>OO*gaRc~kCV!et3oRHn zJybJnI+cFIm!n)~rnHQ2*rd))**Zp!xiWDxKJ{p-(~!(Xy{T&r29k+89Uy^M$g!RG zCS|q4=R@6$|9)Ruuq)CB5v$0+45C`i5QApgBjKb#7gGR8>I)zdI}qFe&4(~VmldL7 zSi(si8`6Qu)^gZPIu5ctR-?BaOhP80%(M^!+y;`p2Gc^59|?$$;C4Rm`VOm2`JH|5{XKhiYJcG zN`js{;Uwu%x6V`iWzVeN) z#3EuzgH+NW(t<6da9lt4FX_#nwx;JMc?v zuF!?h#4*uXCpqf`%MACsw*1E%FEl>Ze6d+Dmd$o&V@!n0Fi=lKST@G=d3a3}SAkVw z>zJ8C1w?>DVW0qMgxMy^|0BJ~$;>1rQjjQ?aR7hUNw6L>vxWZ{iH_0)*$+>0d$rAv z)m>gC+LlSSWrA^rdn&(T;?5ZNe2wCw(fIbqTo+wqE?N?|WCPjrM)$B` zOc?3Z>AT;1?f1I9IN=O>n@P9>4`(3#ky3KVh3aSPuTbYD|@_>AcqIJ)14ji33sQrW)LH_q6z269meVveM-V+ZG-l`MSLAWYD}4O!3wJ+L zIB{0YtCRBT1atPCk^&0|&Xc0^q~ts)SWePtEQ5rY3=ID(@>XiDK>zT#Y9SFOi3z3u zXc``{LKXA^{HUIMDYpo)8`HpwFI3|D$`Cd78GLTzS~!WGbN0{{@nKE|RdKHFESsd9pbwwQ|yWqXv~J8zd|8MbIKTOtGh$K@SD# zhZE=1dX?^Uvw$Q}yLcDo9ncEGO?jHZs>M{2RPjsI2YRV~QqAJ$Ar(&x>OmwK)xBF> z`eZ0xSuItrj8}Q3s@3tjrOyuwp3NYtxhMNYPp#ys6+E@q3gZ=3QpNIkVeymu;|uGg zg{|@0g;MR-ILlz;W@+PLq2lgmT4Onqca2!FR;pMl;J)+jczLB%-V`q=dUC^bS)K#z zFWE`TWqejb2RaI1HYd17^-b<4T^Ib>g$K zIL=$fv9UO=bR0LAf5oH8$Ica?tip601Lxq1vTEdDITfp7m1M=rW3fupvE1l^GEI3- zZXU`jr#PG&M%*g!RH;Z&m6gKHQdFnoPz}9VW#_Z9=O<%%IE#8!1`4<)Zh_XAj2egc z6=F<0Tq(Ek$|7~Gk-C{%e4EroEOj|o&C;t@Fw!sM>Ti?2n58eCrSv7YNnOHH&z~jr z(%YmiMd~uH0qw?8%di5u&DEYWjen z;0JHQfsTZY_(2fd?}^|zAL5xwzr4Fy2zby=&9_q$LeXRf%PvZ+=Nk#4jKH68r6l&5 z;Q>C_K4jS=+k|L>D5p8ewMy6_iq;K%Ioghg3XUAy-{#-H2X<0g_9vk{xmmVf>m%MXZ-Mi{IeKS>FQGrf`|+T{-7x&} z*1^L=xLXXr*_V0(f_qEpl9!egO)C|-R7kW2{5*^kMGTLjonL~wMT?OckvkBGgybYs zLJ9S}_v#NqO(0$Z2;{#CCtHGZ;%1{p%Ufz zH)R5zMZ)kZ*jPC7#@0{hCyspEJYi;?zeV(Hl{{Mo$5tkV{MgQmJ0IVF zVgF<87uz9)>~iDu-nvb%iJBRwnaATk6qhv~jC@p%iS|4zhMHzy+VwG<18a_?6`Rw} z1W9q`MMq!qOwR5u7?GgqnlhW@C&YQkJ427DV*D|x^3h6Uo7HKMaTs4Ub61U>AKw8n z85w;@BY?;x4GI2D%+rOaamEUOY+8Vc-;(|nRMKvIFGTXXKWyT6pqKdUE-#V0JWcW> zEktoc3ufr{tXQwzdcN)uJ#CVwO>nf$JP0&jcgpiM6ZLlU_20y}RAF3_g7BoG^b+G( zy&%iy1GYcgJ?nsYLp+NBIPEi3{ivnm;%tJ(rP6e0m#$L3}B^M zn+0+=rP6Y`sD5xO-lSP1^k!gc6iSx~1|K^v28kdNF6-c&)*ECT;Q~x!B1(SD%-Q;{1#v&%h^bV)AKGVuvQT zG-k|8X3;T?5jG=J;`PaV83K}t*aRYyFfJLv$^db;1rn;ouq=k10JSrcnTdo2;-G$F zg{YVg`HVD$p{IdxST^A^RHd2U#6(ww|20kTYp6ar5q6hkpC7w((%vB08vx&>0w}~8 z@;pDOy~;^z4u~t-r4{WrS9DIU=oDAnA+5NBg-#WF@#L*{#c?G%LpRslF}daram`)Qn!Cgm z$D|dA;#N#sW{mT?vP5zDqV##m}SnBqWk>`TYt5Ykt3s zuoIHbub`6OB_H9|ib2kdDxu9hNeUf5Zf&jG>Qd{QbFVB{&x$@#@i-pFVDYb5DGVm zg`1?pO{2R7<0knQjhnzr7%v(R$L_z{A>^+Y^VduH(Bu+~>*ZTCu8*hk5DFW_!Un0Z zfpTb&Z_(HgFZWKYjul+Z7s^+PUyiNaJRT{x3mydEEbG#=@yN<4{iwL7I+gcb@$xO9ugo#}SZ+IR1o3P=6pKGEcrOy0Q3O;#H6Nxf8d zJ~ZfZ^B=vNe&!>>w2=ZFy4<2szfiRJ@;Sk@PBg8POzT+ETuPO46iL@3!nBbB+{vW% z!qUyxcMGNiqUnHSI*=^5j0%<=Me24vVo=Ft$&$zKxoiImvdB)RZ@5Zc8U&&ob z{}G*@W^<;p6`^5(-w586Daj=vv;4V%+$&}^zY*s6DOQS0Wr4C7O3si^0W0U^>{`Bn z8ddk%z)`SdabYN><2EVnTpso=SHilVY{(LGp^0CH3g9%Ak>zZ!xPTTjHBp{XpoF>{&D1|7moJ!GNGjC%^}5He-LHnnVA89Nt|C(N{`ti?T4 zo@sPe;NPKrNj*djVVxw^)-jxGjp$h`dDaS!wHcgiW=f3?`RCdrKsavQ8_bqvdz28> ztlm%>4r}85Xa{;`6Ac%QnQAvLl207`C;GcWr_=UdhN?6DGGm1QMEMXyhtX{A{KwB; zIQ!W7i|1(%ogEcz=SkS1-l5(4*gnUm*fFZ)?;u~sfMRSv;Jh4-PS`_%v#AUc9>qh7iwjZW8E{XF0&3Ef3;i4-yr61kn%SWo<~+3aErzb@sf)1=tRfm!$L{3 ztcEoTBn`4)bc@F3cvbC0Al7=dMW|XMR;`h$)-V(cF5Mz?;gLt5YBpqT=9z{VPN`v~ zi#x9(feG<`gh*gFI#m|CQ1Z*v+pETO)66zcW;uvF&EUAkkQ5zOWm!#8lEol-(_Aez zI-u7n4>Ogdu{s{)*%b$t>h@?9XlE@8qWC(9-4~#*l9xK4RX8imsOv%s*fg_zafIwM zB`>8CV@7&cUH6&gcvUHLQ~AxT!l2GirKX_hl~f*D7R5;JaPC8Db6upJpFLs(>ylSw ze#!^Clxg*3b$^knDMpNFDRyy|E7li*R=%8Oc#N14p6>65NTDtLG;H>#l}*vpBodW% z4V%?1hd`YvMyC>4m%3)k9>NT3&O{$J4VzD;%T)t8O{(RpRJv2?-nnI})rwTQQ$(h+ zOWlqUOJsd21c&VA%*+{v!Iex&@K>hd!B6NByWpoY0d@gYWA?n9o2Ewml3U{fO;BTShq-wV)%oU8MI5=~F$a`w6O7isfB=V0k<~>PO6%Fxtv?uV;pRUq|6^g4k&s8>sdX!m8*Ef z9yzFvn^|eIr@9Aw)reyxFLHRcw8M_!yak5j!y3sM&L44y?#4X2Hv6Y#SjE|cK*r)0P^n70?T)pqWhE6#oBGG18GG*{E zWYNOK$m!Wq;{-Y{63m1~LK%N-UB;jBsY_S4XV^Uq0hvp=poc2k`R^l9^e7yqnI>4S zf$ecH>hWQXq~%01l>S`G+RftbKYw;s13R!U8HE`1l~8ZeR2{RA_emYfP+5ayHpm$p zKcpz+weJizeeg8!+UNo#Xd^WhZ|FwsJWTG~P?(3x*lB9o3BA96L|j?sEH5R@!K^&+ z_;w<6VBkP7jilyzH@(2O9d*2i96%TyUkK-u4yqrD&>Tr>%0=iasa{AL`cc{#N_G}{ zE@|4YiKzf}^8Xi=O_MT8D;nn{WcQwW_z`c<4H87|A`A96e#Aa(MDJgbtqnCX`8G&Z zFunmX$z^#u=siaHZV1$owSFAIkpZIs{}MhRAb)g#^aV*U)Gj|V#!}4mPf5jAr~lrzvu`|Ilw#zm&k*~ zj?PGbhiqFd;lbk$`EwfQTk)}nP@rG#qV@a+%8t-QnXAH13?8G$$5Z9VDph3qm?t!g z=}*ZTR@>;2&7!O1`_FI^MiOXGn8;nl&L^0#jx0d?8pZlEa`ZGN|4cp;`BspR9Z_s2 zUoO2P$sQ)u!!M(E8F`E*dL)F6>ANyL8J^ZNzm^=5c7ZVlh{$4S*+jlhuNP2mw3-rD z#Ya^z4&ZC{B&-mR1XrsE>Y@bqF{CMVHJ(W(Jxf_UP0qK;`3{`=0zQO1VKqor3gf5o z5=M*9a`4(!nw$v-y-$6!1Zw%c{9}k7CNcnmDcVcceXyja7ytW+3t>lH_?5KEcd=|ET7nM>40cl zA{mzm#wAnEhRdB-e4=xm{C^)qD z-`XlBZ55)eO0rc=o8;p@2Q=NPr##h{TZMJ^3Ld}c@k<`R;PAiiDwuK=TpVH+06b;! zc{TCkc~1s$$~0}yb;Eco94NlIZDYo1r_SNNxKXgz#LKHMZ5-Qiaeqq5XNPDhlPqO| zrR;WkKJPo3zSosaa_S39NE`$Xq{$+=&!%y3URYhyc}KPWm^NzPSc zhWFh#GA(^_7a5-QE`YdpeEvdd{yLmPN!6=ZlOU#>kM_WUd76CNm}%N=u~7fyEc2voYP_kL!$*7DQb*K-BWR#~{|`eDJbli7_jPy#X_Wt6jB za(UI&BGIu{auBl|_ivZ&yRto=aTp_?Ct2nRmU+`Txz-|5Ja7C);}gwKHVcmVGu|k- z5=+5alyT6Octv%*q#ui-0M>ipK%cU}NoUMO(`G{+*)RVTrh2X9Ap>p&<0qjj`P->W zLbFAsXgNRLu>3ZPmQI89e*K)=s7GWPvXq;g@BzVdQuLgZJST{$(YTdO}udfy7wMz3^g+lEO!%oF@myMSLSC7i= z9J9v@%O{Qr1$ASV>HBlE5a~k(qduMkq?!vfn|mCZX|ojy6~4lGqx<7#7xZ?gb1c>l z{Zz^7tG(Cm|MBVT>)ven zd1CXMJID5C{?M3Dr>#^3%`>O2aJg8pLMm7h&o7NHte+~Yc&D=QX644o%8g>>W~p+s zShhtf+Y+x@5_gvaQjtoD?3i-HKf)IGMr~W=q1A>r_3quCoHz6J%de0WqTU$)@XYt#%#jFP_Fz{K@}UCbY2)6`W#q*Uj`dmBMrrv&nQD< zbYcW-+mW0eoy_3nj3Xxe=i;52GlA8XCZM&9Q4Pb_s$@k+s4dA$O<2ur^+g#!qehov z=c+<2seETPsl?@~%Ttq|Ber4d%xq~blTOJln3LHH=srL6zbnl$fz3$BCA*+EONR{X zQrC$81;SjZu<&9IMxXG8j#$_=Vvf|N{9rm*m0{|flr?5d82gAN^wY>fb?VvEj2OOf zNyP3UWIK7OYsuI>gjOf7Q)y;3XO1+c-lA=aR;$O5vv5{0bZjqT>|aigzp}j=HuW5P z32d6&5mTrRBezgl`fu0gR>u1I?U_Hrx%~Qwf(-*EuzuJQQTAk-@54c7iCCGJa=>;3 zvY%mSYkDaS18~ad%4QTow89i#_c=<<=j<4nxtHnT3bf2g66YAr*{g$UfRg>Mm<<@4 zbiHApljy$Wg^-GrUp;4DG@=*qFJB|hVP|Ax>WStW4qr58X1Kyjq`py?FG< zDMpEJfkGY|=88#_^ZVZOH~uinzFr6S&!5fW^vK%KI~mE}$+sdm`fOCBcgQY(!Wgt@ zU+5(>f0l^AG$qIn(c=T;43jegC*fdmlPrORTc$vxp_D!$nNr6GC1G?OpG+f7QE&}r z&6P6z;G1xwPoPQ}agp@L?owY3^P_fj&a+>M`tYeQWG zN$C(v$37L9Gz^`xCq1)0@h1Kf)sI99qcqXm7;_}aLsE*eq?ln~c;Tyv>{~GM*GbuD z9%W`nm4exk;chUV2Au`*+sL^JCt-z&U$A}1GyvffGRuS58~iXiBjoU?6JwIe91zBV z_?QARYzTZK3}Y%wh5m+u){ygUN|+!Y<0$-^e8irpcQK9vf04pJpto5)a0a^fTLzvhk7kDMP(qF}>(GpM9QI!#iNC z1O7Rx3ONrG*MPnccq^{Kk(sy#pV)M+MkNQ}-O>u!@hT~UP5#E^qX+OEsL|cP@Nr-t zudN&18!spu-T!`y4KUBN4`}D@-I#gGRrD>62=kk+`ID~sFD!U|$yGD)0SaHQy19Pe zmewB@T}LF>5m?JCu6%0X8v`*AG)tx8rGi^~-*j!AbitNii{xq zg1thO_I~W(Sj*V3;MhC8T07bwvhn|T4zpk`GiB~^7>T8=Ofva89asvwJov`7d!??s#;C=;WI-sm0w&r@t{!D^aabN4=dd(IQ^p2FFE~!#V=2N`C}LF zJ4z=CFO^^}Ex8={NyoK9Y5gH_^gZVEu>){T$o1tWgZ!I^b4kA=il7u!tLh?A*fXdvK z5Hc%G*x*Z>Af8eeQ1CS3phgcM^`anbv>I_hh3%<4WMn~&HbVv|joJf|no$O5c8Qzt zI#N*Yw~!UwnTZ&sA#ruRXC_6oL=VtmI)zAHFnMf(E!;G+I3povN}m%#-X*4JT`Cuy zE>fxR(+--vG&M|Y*ixd83~@qmQ3nX-jrWmvQk242vHzi1z3UY{i~TDZ{F?!?t02WN#+))S{H1v#BI9l;r_w%cuz`h(RsMteCf& zyk>!v!xqRhur+Y&RgjivNSBZ;&oFG_gOFu7oN5nXE0|3gz=S7P=4tjmg!IH7$S)kp zJ>RPa`teQp~4>};5zHf+DS?vVPn$9vIxPS;)P7)7P7;3xxBOz zL83IZ(c>5%R*2!Uj}QbnXd{rUEu;H*5@}%4%R>uiu5cJ%Rq@!QC`O3wEvE~5q+A^20gqfeMY zzSx)1MDjovh7e}7RaI#|46W>WID{CQ6rU5^o zSc7m9IgwrkCHQr`3R7RGC_(uM*`*mi{--EKAeu0grU`LAJyu}rp0kf@tBkTaN2j% zQZ{KRduFj{sg*3XH!VviElcB$PH2VF2R7h93Djys72S(bsdtw)kM2(&H7_(ZP3vyu z#8PL>Q|{t#MV@dx>A2~xnRM5@uq*Zpk4>rFQ|?4R7Q|IJQu!x3r25z*Zt zxjRriofX1C@njBl7Uws)I(ud27}~0{<`IhT9@{ikuyi^o4u za16X%HnTcrn98e~2+QVVVSrNct)YdYW0B-obkor=>1dd8*GTRqm$@rFlkU~>!%cV7 zq`T>IM0Br`+^fFm*M=NAt6TnNN>#kH;*uFw!Zcq^`I$>B--Uxe`eF(kkU#Xrlw2B6 zI8aE00=?HxYcz7 zJzkQjO~zhjB#m!F&mwcchRrI23qxlmE$u%e<(rX^C7l&_rkspVc`z1Pka@4J<;)bO zGJZyPV%Q+lUFsavPoNsob(>kkLo*(vsp1*VJ(bq+7`5Xm-E+f6;{E~~&}y^HFdFEI z(o8!uz6hDte<%{5MGulN3jCbJp?V7G*2QzI9Mu$ zIiukLt3?|93(ijTAkwDi#jp`i-gIi?aIQbyhv@z1Px zcSC}<2i`A9REYq>?P~$M{(?cZ!5Kd{&~ZNFbO02^GLdm!o)ul4*uu8Km%Q6#hE-UMAiF$GEUPD#D!UIHxtrhD0>d)Yh7Tczdg!jaC&<(;DYsN_ES#mN{4 zkuk2Zjj)QoRG7E?YR8Z7y&j%iy;oShHz{s7D0uD{J@-qV`vu4S?-o@&b@w;!exWcH ze*OWmXr)xNQgCT+_L;k}P59EqgP@OODP2`A?^iCpyhCc(B+H7886f~VRkJY0NsBfM zi?(0S7i(Ijn%3)GV$CiJtd7sC95Y^Y#f!_v3>WRUmC5}wWgO#$RWS!_;y@>Q;sL?4 zH11t3Z1V|4pfT>AF0vDW0q0|KV3>pmj9WTORYU$E&47<@ISp<-N#+pBXbDxREYT-B z^g2h4V4Tkme12)%Y1`{HyzRB^U1EN_Q4hBpi#dHIQCR&)?RfR28!vD%!^!8FOO(wNO?{DD77J)!BHzo2*I_|Q9i4?VH2qgOMx>mQK+mp3O;78!19 z>DiiPFUQ7tIM?m$K0%4?_ z;R^x8mcQ)8G=Q=#_ayDGCdFXT>Y*jG_F>XAZYJ(YbVGP6iWt>_Ihf}j*DDRL-gI~; z9bVB64e{nvY1e3^4af;1BSWZntXv`66C}-JL5^NF= zneO2pSTn+m1d#?GWU{^)B)vU|PkGlcd95EXwZ0~33T8&)YbZUzOw0$nB2Yh?Wl)?L z2YnUr@MSbhO* zl1-YtO-qGNJ7vQ{zoy5(CPxFt-lBKp+(*@wkYH{={uY+(T!mxN@s8Nxt1AT88qu{z za;+I{mF<_vCQN<{TSG@hQ@La+zomhq3Pg|%6s3*C$)K5<*^L&auk?03hF4!IX#QeG z%4|s^Wo7ba^+iGFIvUg4z{+0MVmhBnU0Qx#Oc9ofqvbNq1y*K0BbHfvCy^n40tKk7 zbQPzp{L)De(L*85Pf&2#tlgo4f|(S?&fJ9F{O`6f6tb+-T?7`m)b?ePx!`-%xLBR+UKa$T>?jA;SB&T~*m@MsI?gUdmCJ z+LG;ar2<$KdHPd*AfkJQzP@q35gn^i(I6!85rXo6lsgB>BsHIHMkyhjhAZGaII!pn z`BEblBl^%noOmmwdT`=h3QkixK2EER5e3&)%VAa7fKW;|DGcgr%FI#9FlD|#6d}Yn zd1byoq)Dsd*s5^{jp7JNK{-2!qiPW& z)HLhTnqXA#zUsOtd1o`r&Zv8WtR#UqYRcI4;fv=!q7ax@_R!)?)z!6{rH2&pGcrr4 zOPceUlnheFOY@Rv=_`$DkxE%1%})mJ+emd)g_fmiA0Y-(@{&&v)S~<`MVnLcGMK|`$9F@ zlgbNRexq_mtJkh_Y|`BKeY2%y-1q&N&{JuJC#W$U&b(KZhs}g|*75~4CKLGX`0Nl= zX6(1JPV*&k-H)V7W?XlN%ys9<&_Dk$f*2FMQ^EW8QV3x?i3-N)mN&tq;S2~S;hQdm zYVcsf(z|PL65bujHN2 z9+kgEjUEO%5dJ$jb7qD^Bj0okNB^YeFi-Ssl02Kn%wy(Hr|e_|9YIQ$v8dl!bwF^o zi_Uh**)DY6C0Oo)&Nw2{nSHLVY{}&XR~CzsvW71<(3lSPX}l z{7p%2^E>Mf3!WpQ=ZNGvA~=o^+o0f`f#S- zlkVoLM$x@mA~_mFXu4W3>0ZUWE8l?udiNb4T1`%fxxo32L+32MaMwG<^-}Swo5kxV zi`UEA=wjX`DQ^=i1?c%*7w?)bAYq|m9dudz-|)ve#iAur(GtO>y>B|VOgguS&TW!& z+nC{9eEnx5vVyQIPGvgCTCh_BToA*oqc-MO#P51 z?#H*By|k`W17gSaL!opjZ?X2`CnuS~HsK?usR8B@R zhSOR$&9n@gy8@F;;gDEeDuwuTdr^0#1dTaE1FPqTEZq;4_1kK>%uT-kA(HRksA#!X zzTc51GPKqa6{D4u@86WkhspQbe+&8kw5(4iPr!-1VY_KnNwvvu9yBgY$rdOx2=y@4kx#p7$ITs4BF zhOq70S&{8p!nI{bj*b+R2!RvKm*>eY~P( zY91ND>v_82QUgrM2@AFhWvvh^CmUmMAXZL#16I--xRnF3@=9;a_`L1ugO?6o?i9*b zjO`a3E2fvK#^6I5l8I!X0;tj=LnsT|(B4S~8(>nLve5d9`4y3U9yyf4ZA&waBzxsm`nv16P+c%f|!KV#_#M zPahwol#$grsGpQ9!7xaRWScTZ|G^5!)`9&;Jr8Ca4X{GfEYnOs zs9E30JKeKt#70?j_DKV?QczqYl09Sj98xG4vj#ksrs@G}IwpU*ji_k`%S%PUuexuP zH3GW2Iuj90+uRINJbODBs%_SM4nUO_Hj^w7TIg*0X}c{|SH`r>%VOFx8McQIiGLjx?Ub2^8P09Kl5@+TlgzH2uVB~GocjXu!-S5@ zJlVO*4>JUgaY{3|8$;#RQdV@(qve@eWwdROg04|DUnd_!*8Yinj8&RO*1`l}(s-II zBkV|Wt>w1=AIL)oKpg%LfT43{iq0UkJ7u2d)%~R(%0}V7+Sm zKBVFN_?BaO>k*$rC_}bXrLshyY)i2NX>WML+g7~WVtCVH+wC#GS)_+MRG8udvUQP^ z>Qspa60yVBfw^CR9XLnvg?~4Sr%{7QP<|7AW|BZ_(pYau^5jUz6|0{~gxSEbP02B>PU) z4IdzWGa-m1geW738rEIIlxwMAS$b>B77Jo`UOpk(mrM5LpPLoeaC=tVT{E!a8c?Z! zXT^Ovthg;g+1CHRvf{|Z!wF{M;RKWO_}tt$BnCGw<=&nfcU>le-?Zw(c74fNcVl zIdyS4oZ*U5&8~p{g>Gi19b~*TR^>QThHDZ-owF}>9Y^#yYf~`C6yHR<#rHnB+#bF5 zv*RNCr*0>Gs}cnD%W1|WNf)cqr&)h9rnhPPNhzI-R;8I( zV9=#osATyK=WzB+Z>bR|_0xzUREM@HSbj>`;FCF@zkW)45?PiijLL6+s>e&i+9Yce zX_i^4-|1IrF+wZnpk$h**?4KTVAgzeX!{xSF;{VxuAaZJ4xk-ZFo9`tCNCw+5Ox(@ znw~b39fh=AlH1G}d3_|$++bq1eH5~OMygq)k19M5kbGM@-Q%#io;-v!`_I?*wmF!uK9&!b*ua;)hk zJ^v_P%EFoweD!*KsjR;TpfA!J3`e)&)uCQy0YPP~o2(q9Z5?%Q@$ke5u?0!yNUk;wB{4y z^JPlDrJ?Z%wRzARdGECskeZ+iPms92lBonHY_N^ZN5W^(W*OBCQH%gNH{m35IeY*O zlAUpSC~7-5NFPV*=<5k}MPT5rjpqmWgzeDzy?1spKmS$aoiH9fx@U*1@R>zarl@a=wQsvKjEF>G9X(d`!-7$YIRQ1o@bf88h>3=l>I>VJyx64M7P5 zS?kU{bvEMfK9f0zPMEk8^x|zw{a?u;Hf@-oUz)?2I*4{9w?CM93x^?<%l|h-?IkRB zM;eQLR~F#h5X-yiT{#Iu2j10^clAy0wn^`{>t&*MpXA*~=gk|y%A9Bw7OxYW>qX~! z$+=#ztdHADZrWg?3P65+o3$8h|L$pbcqvvm-5&g;G*> zc9+h=S$hMos4_OzrYK8FD3OXOIj-tvb;T1@PzE=ptvHorhcT7Nn?3Tqzi0ZG1AryU z$*}p*-T(XlzyG6OzxV$4zc&D$qTuz$=&|p%O4S{5b;nKnjZ32cfaE_Q`wt{cNxibG zme^vrn@R%IO5vyiCsJL%!Dm}OIf2Um%mH5lS9j7g4lpa!BuqSTujuWQynV8_?@qqx zI6|xB1H@ItZDx8Zum9@kx6ILfbN$x_q~a#IxJmLh%iiXQIb!~N$zAv@hv=&Ml?#V# z;B8+hl04nAr+a3v>VYBG+8yUs0(175=IuZ#Xp#$>M93A!Bh}ZzoLOu>F8WVM{u8pF z;_Aehy(W4w#|vvq7N+V5I?xcNiZgq>=)1i9**$W8lbF9@{&^|4UCwQfSYxid*%Pns zitI{eKAk1=s*#@pb#oUNEN|PTz!o{MMf9W|5mUU%&>i=wcOIvJALaCR?Dv@Oc%1u->~~6ez{_@u=tJ@UijdR5h!R`H7|{^k($!Xkh4cCi zK+`F@p)33}FZ=^NGnR&7TFLzwTQM`kC6>yb;nNFaN(6;9BlIdiZ80!$Hat1|g~`>I zPrn^3mGMDtGnr>$YlH1iu;u$r{8xVXkeidvrNKz*SRRB?PjP~bJ@Gl0_x6pABAP{L zcnsgfi4drDXzcv((Ae0eZK0v5@aSYnIDauj@iarw%8ZA4l~1Qa`zKg7q_tko*`9E- zx|7*J4G2;6|7EuteuD;!jGlf@M7)6)Fh;0eStqN;xll!bKK@D|2l|dJ+7hEYLAPnB zv0a+ccOvb=|3Y!fMu;31hGB4c;)Mxfgcu1|JVyKt;ShM82=@gQy^}Fb;Z<)tUW~Se_RN1v~QL5Y}SMHMB!ASpX_maOcR#6pO z*C+AQ0yq zWQMX5_{*30#(#Rw<4h^elB| zNAiDyM!cyuB=fdRBhD35*z}vAA$wB#9*)yl93cd@==)*)nO`$qEk>z492tL(n~V0bp{GX1 z9v@Ad^p7VK94uk*g;9iIA(X+9abXEL^c%8;pW(*k7Io4ikc=|v2bD?Rx}6cPiJdsX z?57PyJAXiLegpE&aH8+Y#BuC+H1AsBe9!fRH`{M-6>|sZNT#xP3!V+gmO)`jE9i`r z@ye3?|Dm&gMIeTDgHt{)Ob)VbPOOcJx#c}d{|kWcnH9$KQ@Zh&02A~Qfk-K9=p|WO zBiPZC^Yr*{pqdN|>V8BNL{PiC7v)xmbM{Z&{x^0=?kd?`#rC_ru|p25|7oD< zZUF9`q(HkIXqP-4vZv!`o`P3Ty>U)1Z2fbOyrEa}^vRySxSjUJAXa3>YB63q@A^6w ze+xhLZN2N;D*3j{zU|<=l&(JY1o~X^Jt_O1jQcV_H==u>RS55~#7h`~$#^mNhKT`5%3 z1S(m{sH8~|Yb#I+2qR;Bn4C*DvXH|Xb*c@rsg{WdzE4!75zj?LaJO%M$U+s5~f9JI1YmG~PzSI#4BY8;SJ0Uw|;VDTnO#DES&^Aq> zt|v)X%+qGLi7;yl{uFyn3nZzu=L5d_gemxlr?ei zPMj(J;1e;m$nN3jY!}B zAV3;U;jaK1+&T-(DrC;CIZV+PGA*G^ks_G@NgTLh>G%nqkuq?CUI8XVCRnt<({MsN zJS9NTf-_p-{Mpg55y6Wobd%^9?FCPxW4eF~J5#l8Sm1L)Y2>Pd$m#5qotzfCYgc+b1ijUgFEp1wH-*)FU%TvUj~Ask5MDa19q`h*Ld#d~ zmo=m^61q|iiP&nr1$#L2ZO*yJX}|5^0f#YB)E~_n{P4t8ZvT;;GUN8-xKu5uDruCNoOxDr>0%!4a;;>yFWJah#IieBtJ^j`NF z3CD;pTpSLp;zTi*<+f$Xoxe)%JeJ#$C3nFpx&1764h7w1CE%^o!p*X$FI=UB04u?s zrT(H-a_5K5V9rVw^G>ihF^?z|!00$~K=>uPSZKr83zY;c1gr#X1pb~ zf!zeQ5a=Z^2t0$uC<%-B&;R0eoctb#?# zZoN-yPJh<=J-rvo-$LGO4i9~b7q+ugV`GEkFciC7l{O9uw`(fOr$m_dpv)OAj?ICd zNH^k$&UP-j5foJKg%VB=;jZENSGult&Gx*yHL`Wd9~8?EO8!H#|B&cD6!Yaq9E=mr zJSKCfg2v<}+cY$~iW4W{*D*dO#jR7y_?V4SM#3SbWk7g}x|e*HPF3RU*u8gOgF8xE zJsgL19Tpb9JsNtRqF#lu)YvNEeS^9K*hW^~rHsRcXU>n0TrSTx1}yonsQ(Y6Dz*Qk zeDtwDut)8yk3;XP+sJ_i#$l)A-zEEZiSAvijl(}gRbSOOOq;O$jyF!&haM636G-bD zp&z#|S7+-Jrkxt3<%-0db2snRfWaM%vTZ@tyIcQ8mN={G>!it z;UubVD9G-rk$n|T)2L_qG^BhL%Ftbkk5qiMVCG4Hrk6m&B9zc&E}_r_QdT+eZN@!- z|46Uhe*)T!?jIbW&u*RD4_D|?phXU}%p6`WtDM;nwP62LH+!TqIHX8-6C7JB;2=_(3-gdbd`1kf!w#2z~equDi$<|O*?K} z79Tk!T2D*X)3WvSiUpkEfuS#9_KA6iXl<9Q?XtClTuI>&X&_;G$sxt62 z+_23{NFxS9LKZE*K^#LXRxuvB1fR0bL4k?ZBEe(bggBYBU;4TvN#H3mG+c0`%iQ{P zRc)1M(ydjY$>hfGBGzPe^7o4R1Tq{`*MhW?VK|nrU8TB-Hs}*5P!R3^bS3dq+GYv6 zGPJEEiLIDxJ?lwc8(DWlO%(Wt#-9Dc?gTet)UFT%ouX}kc7>RN#1FeTZJL`-V{PQv zMcZAAbJBjZU)XoebR`ENJk2j~LiMycoU1k9rP9guNj$X1kY*eg>~dAdC7j2~Dgu6< zR+iu}USlO3ZfOR!=kT*!n}Oxmash)VCi@vvUa}*MGLeLS7A{Sj8wdV5rVZ(StjZ z zni@+*_&N&PRh`5kQ&jCL2L`5dHOVynUn8Y8j`4UcX6Akk8#A*gWoCN9Wowy#YwkPm z5U*UEn3 zpy7?xm93Gnrk6;-LG0lXR zP{9mH$%DZv-*7vLX?qA5R*f~SU2 zf~s-9{ThO&vG5Y#3TdXle=+lV(!7|$R-;hBSH_>A2o!G(;p#ke0Wbw7+hS4!Ff-#x z;oiHy6M7LCp|M-Fq`nk`MorAaQDw(C(mmZI`vI<9?qz(t3$4GfN0Cn)vjxTko@ zaU1eDp| z?MIZbn*cK_)=Y>C=#e&3!ru|7rb{NKvDH`X$KV8>KnJDL*HzI|<9gei{BN-Jms!Me79?59B>U zdy@d`Kw7F;bh9oQd};(yq(>HAtnZWyVV3g$6GCT(Hvt86=qv(VBch%#IXX192$c?c zbnq-9qmL=p(5k-&1fQdUVE9b`t*nR`pkl$|onmUy+Qu#D{DrP|`y0@nv%dYY{JXal#SZ#yYxLvN@5$T0h z5)7MUAI#W!Pc5_xvzFK0%dVp6CeanbvE*MrZ~cDGpXMwyOZB_t`dyNLx9r~?fsMS` zbBJHct)CA_xh-;Ti^z`8mjdNf)FHBLSK(4&&4T&;zMK3zd*9nD7WPSneR5&nojfu3 zAl8FEhlx8-ak5+Ng|bj5W4W|EynjRnDP7^*Ca6O=9zIxvqyWYjlG}9b77xHRAyeLkp0z zk6&_E&DGAEBzKeSZW7r6M_y1_@JQ4{<{Vb2^Y|vZtI!S%lO?A>1IdX$ zmOAlmTz&xzF+2rITabyG;^vAP=c^G=eN6G_9C0lbHqW122rHLj?zWU8VqI$t^H_D= zcUo_BzPD8@*o!0LjJXP#ALw7rLVASYZgh=;j8*l8p#JNG~z~mNv--*hMzL zD}rLqT^!wZm%S`mYi!i}y(_AT6;?6tcU#1g?q9u1EX(Is8P;bHnKRSK`;2xzSOW!J z3zc0v4hDTK$P+K)EBIk$fAM1$^N+Up_B%{>>^#oNcT1P`j=yq$yY)`HmCn0*s*&(X zN$$Z$(yHCgod3AErwR%GerxHG0@F|O3F#*V&LdscpH%vf zv|E4DZl&|CzHLajYjHm2wcpL-0WSxW>bkaxsi)43PPXWG#~X}rR23eXe?D{-UWSbh zYf<6pmjbmo>6te%7)1{(Hu^9GiJwsnOfler@-MT+pFR@KiUvH0aRY^$MKhC0Q@CX? z;7K)?(c+#PMPI$pN)G2V0WIu!flqNQM{3J7Wg8R>szZ1%f?qp7kks&WsWUef(@;sCJk=54+c3yK_L(cZXUWzS#J$EHx z-k=_`Kn3tsnt|n|Inetfl0j54mKM zN;+dPR}@z*3KcfGY9Sf4A>ndal`y}kRM|?kvM`QJmfT2}Ovo&xekmbPM4*@elNL2W z@c|)cl@Z-K6SfR;p(6J&`;wznHnVM0MY}9MTgnpsk&wNO{nCQI`yCSMPC)UJX6}|P z{P&WR_EoKFx$T74IL4a?5K;aZ=}DqaEDBP%VH(v zV86Yws&#Z(5py^r&a2MZp;vtL7aM|!IbSU#H)5#F_0C(ayA|dh4D-X{sF%E@F?S$V zT5)ZCthgptRJ9bSkOS*t#dS-CCDGbz>t7pteJmCziTR6IG!by#+kW-9-RV5+K0bXu z-KRPBV8w~H@-$pSD&tkttJZQ((98ZoS5Nx^-h8V*x3AlDYYR{3-OfGht+&mUd#bIs ztF3gtevcgqAM?%wF8jw`9+32a>W_3_`0>A(228g^44YCpgfkXc2_h=NcHqNO&K%BQ z$bH7faF}&sRvfZmw1yR%MF(u=au)r4&p;_T^2pG{#6@h8BBQxhg&MaLVIsP?=u6nv zCXs|ON;(ISAy_y$7ZElPC1(Z|Dr%}xL>r+H_#sg(s>fgyQ5K4BnhPxCiDet5vW;@t z#+jp{1yPjfkSrTxMZwvr=&|`n#iBN;s7)?vn>i#}+LS}Gw8biFq9b#C3q4{*msHUu zS9CFB2tYa{263XS)zed8OVBXuiIxkAuE@M*fBPXZ{CZE;`cRh%&AxF}IJ3>YZrxj7 znvvPWE@O3N8*I+%)&cggBg1;P%3!A2DP|B4bQXyi)x=Nl3wy=&_@sECwk}0Hu)|(I z&AwmnBnN$=wm3UOm{O`GqXmPyriq@@c^)_R@U&%6wP`Sl4w^r0Sg56})PluE&mgZV zcDi63KxRklAn>&=*G>lK^9 z84WSfYF=8gnh;Ef2*VoWm{Kizskl$6b4eXzxg@_M8;GrzGzw*-L>Emn>z` zJ#w&Fvb4yS7SYo3>DpwG(VqNdeJgK@*uI{@z zBvtRD#LA`8>TAv4eepZfQfa4L+8ME2^{vYA*KfaZ@@Dw<(^CC_Tt6UIryi1{GUA#& zla+n1(6jIP6?1MFp27q*`gPIY#2EQ5v0(ETV&osjhNGhT`TY5*g{P#VZn>yC;$DL5 zp5t;svy|5&=e0y^@y&)IzfUvZ!HUyh%JR3;c0YGs)|+e)5v0@1Pg&2q)Yg%daS zO71?{-N#(%%r}cJ#M;>`S~jz_*$+K?3-))K?{qr%@3eo+^MF{gr@bV?8G6Yrf69q5Z>>2!QR)YjffS8OsqC&(SC;m)Obha3!eo?`(NukJyh%x!o`mwCx3qNjQP@rB znOl;Qo|gYQWz|~Rn_B+ol>W=?0T)wGycos8r?oOhnkOV#I+R>}2I##}$j zo{N3zw9ZYmHsvm{naNk^?@(6qoP6aZ+e0?k%7-(hv_yNE2%1tZtL@v1_QApM`QgFA zMd#q)*(vgvH#jKZd&mi|5g;3W;W;|{Z36EQ_$|6eqP>8RCroSr7HyN4E|4t{o3!v@ zp7hN(Bs_h7yhF$#oIK^C74}CXZ0p8^;3P~JCAtory8!PciVwaBYFMXk*ug-hX~@yXF)p_o!j2oUcru$>D*LUB$!KQ=Tr{LIL?O9HKS2o-eK ztKFri5h^JqL@DkGVYrp?Q*hO%a7NXXSVN$eK#b5lD%!cB(J^5iCDajEPkD!tGhuJbsDBfmBA0zA_ zu#>TBH5K>$p+WWx3iR1mp6m52t3NRy!@CQ`hPibcU z2Lg}MCDY41MrX$e94BxBV9`zvd9ZhS(RpCsz|qJ0`+AP`3s))m9D%P9j{C8rhx-Q) zA3#X)p2NcPbpIj&#uUFoXTM9}4Fdn1z_$tfDS;mlxJlq+0*eIx3xWShK;wP?mM(dE z+)@IJ6=r0PQ8h-yh*B+DE}X{>qJKs>e@x&V0=Eb-lEWwrBUy|>{frX+Gl9P(@N)vc zB=9!`KB79wpAYkY!8VVx9Xmob-6M-Bfi&z)VI>fUoTAS9YsYp>jH$7aox&qXMUmVD zsRdxB!o%Nm9&d?rDyZZ58j-8kAIqF~hW(3it{L_(#^ubgf4Y-&oDPx8dc?R&G1IZk zIcM0v7*{145A49Bah?I@Hu zEjP#S=S6PcT8WaPy%^g5^doM{p$fiQP{=t0k&@{8`9f^&%4wH!+U1<~8QZeGVCEZ=y$BNPjAgm3 zLgb2<{bjQab5)|hR`S=%{@NK|tfV?xGVh03nFPzSl4g-Bj0M+4d#@c3>vrF)k%D{W z;NG7G58n+QmVyIva6sfru)f4sz%42497Ek=X!ng{Qm{u3_K21e9C(wkdyeO^aV!a9 z!Ez-HkmyDQ-iPHtaJGBy*xa%Cy!pQQK5;|;&5Kg=QMvi3wC)jk-6LY)Q7Q1K9C%dZ za$_~MA{UAU4^!nf&gvC?m*Zwrvk6U#b0+HHB)HGcoWO}WCgWDi2H?QcqJ?mh;66L^ zvU0wTa@GSUpcTQ+v~omWKXB@S1IM<>YSM7VfYS(^!YnwYwNd}I(wL=n!SuEVaUM!d z4>$nI;N&)R&tF0yO{)zrW&+$d+cy9OfIYvRo<9lTnhckIF1JW5>X3aKXWVg{ldnf~ zdjlxtd9>UJ99AAyZbp#n;}NUO2x{#_35_71=eJRLdf;$S9Y#=S8t05)4{zezXsGnSX`{-Gz;ER# zCPEs_weU~lej4<8sqsco?V`qK0KtPik4cpo&f=RwDG&7NXYj$r+m#MxbMDjK2U=3S zncoTv$}|uyfx{?%EBr+zlR%m`Qxj7`6lLP>11%|+GdX^{?B%k^*Cf6~=0OR*!BcxR zh#jP+QZ1#|lFV^R;tOTIFm6JdDCVpF4`nLSC7v-Qa*3Wwi4VzqC~oTK`Pw+ALCRC5 zrGPh49!>OANPMNtBQot~zBdI+#O0;_d@2snx;P(UeXFgo(Rw&zejsO!nWC5??0sWpUFE^ahs%)T*F*dv~%E zA}V$_TFfPZhD~>%WKE3bpv0HUe0ki|L}e!dVOOZP!J;NRxJKe@WsHVt6VaO_AoO+W z?F&it$Qg+*migjX*Y<_8H;(_=*xO@quAV11->HyFz^p2cn^?-@Y7wU;9@C>J*0OQ_ z@r6C#d*b>NaQV!yO97M}H5J88lvYQNTl#XT=&qOe2AOY&o18qksnj54Xw*_l)g0;! zvhm~tPJ@&~nMCZKKanHSFY$hv_s6#GzEO8G|DERdnh_+KhXsiNQ0@TG;wHMiVH15K z(*eD8xcfj$Qu;FZaxn6&^4cwT@48WV)ACO6ykcn|3UL@JZlpbVE~lB1!y!UI^s{@bpdrq2MRF(iBOZ=YDI}xcUjjd=2m`7 woHGEbNO_M9w-d|<@D8vK)U%SQn8UO$Q literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/auth_api.cpython-313.pyc b/be0/src/__pycache__/auth_api.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..27a8e944014f8ddb5ade227625047cdd1ba3bb44 GIT binary patch literal 74691 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 P2ps3SHqqw**Gm5d1?(Gt literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/auth_credential_middleware.cpython-313.pyc b/be0/src/__pycache__/auth_credential_middleware.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b74153bb71afcbf55ab7b70117812f29110c4772 GIT binary patch literal 3741 zcmb6cTWlN0agWER_|(gmOj$Zgie*L;X}NYCzv4QU#fl0PM-*M7w0u31OY!W}9kX|o zA^}R&=A(8SKs6d8D$q7Al8>Z7(FgM7d=#;4>6fZLq#T@wkrZkEY|BO3e0BDyqfFUA znk8;_c6MfVcXnoWxZ(BIAZUA@-*NGM7eb%Yf!WwH!fdYm8k(b@6&q&!Ik)sJXV?aZYr;+n9C5H#j+*}5N<*5AZP?`vJLz&M_m5IknXv+(ZvpQWe>wKf4JT=1hFG$CfQ5}pDdixj5y2A(=g zF)fK1p1K0~l$IyEVFNAjsXGA7p>&WVD_c`EYK(U7z`@PanEMm{ z4;!RvP5GpyazYJ0DN#~PpDb;SuChevzw%@w!O1jc;=A>(+)8yXj zP0MA$L90&hFi)>q65vM(RM8LPN4<&zze>>;#AvXJsnR4tf1ELiS-3HHiky}6*PNR< zGKG+^eSOz=S^wL1_-%3FL@{RgssYmmYz(2)F7p5>8n{L~U~wP;{Ew@)RmD?OOB+}t zhmNytD91R_@F(!ygzpXb&cYW9p&m3yEeH`^j!P%xdYZFPGVYmn!QO|ZRD`n++M@{8}w<6Qpk2X7ZR>Fx(VkBC=6D}7m2 zcvg!lPoG4cRg3B39xAnXT?8TJ?v1xA!kNXt$y{Cp;UZP>*Gz=aKr8+Lz?r^k&{5f_%ulPX=G+iHY5klt>*29K}LZ@nMn87c^$`e(Saui&A0Xh)v5H8;27 z=00j{U6@*J>{|18m7HClx$EXnTsg7kZe4M=F1)(xj?VRe;`Tqzbo9#6HFt2u9h~c5 z_cqL5xN>358(Q&(R=pi_19u$3h5j{1c*PN}mi)pOy570!>za$LJA6NV^^QNdaIVyK zvefci=}cn9KU#8*-m|i{By-1CH!uD|{4Ki>yMFpN!>di*tG=Gg>_aE=_5Ee%s`v2R zz!&~q^RutdUU#fFw!E3TntG? z{`Ho&`!=@0_q78BcF)gVnSJO(wGF@OUu)=IY3N>S*!S*BtAW1Dwsm*yFHe|)W2L}_ zieSAVI6q5;@3)}3;KHHR+Kx4MN6FgpV30w9y$_re)ByO;!vOL&zHt#A;|;{nLv26q zKj645o%!ZI2<-mI-~BbaxxnkQ-++Ek=YQ3I!NvV77(2oIjv0tpf8Ph#hkjF*0c{k-*ZZlxWfF1VO5yvuU>+vi;9}BJ|nO?b>ufl*b8VR7B{@t2=T6w!QdtZ6s` zRjcubK`rMM50rEfBQznT%lU@p5WR`gMOlQuHnbGvv*`$yp{U2@>|!LbC-6)_c9m2k z`52|B?&tYO{?fqCX(?y~`g@49QryT6&SM~1l<>3UG|(XWk@_M4@H&Rk(NF$~+CN9` zPtl$)(NiCzo-fghpQ6(rqwoW}Yma5lQ}VTMAQxkK*u*gV?>YA}oe=BySlWjMjs7#6 L_un=c8%zEV>y(ln literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/auth_jwt.cpython-311.pyc b/be0/src/__pycache__/auth_jwt.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7a622f47aa88b6bb82c3cf4cc6206b29a8a5e458 GIT binary patch literal 3143 zcma)8-ESMm5#QtSOLrn^eOR(;q*>d6W4a{XGNtt!HY$l`TQEDcHY$;e9p3QhA!LI1U9lco4WYll|Ny%U>n4N znQEbk)x5ic9aq)vC)2OkA(Db8tCnTEs%zMmGXs4wV->wH)!jU`JkfEf#sv3E5E>#v z!s|hS9o+@GU+#6<%otC>NYC1a0xNb}`_im_3g5F1;~^>|=YyZnD3ahIjj)TO#jlZs z?(=nN!tLqGlJHa5>9fvmzY<7f4!LqyqKy0!Bzm6@f}U7ciuCxUz7om^oF>h961Ys6 zo4PVPb<;zhJT>#hwb@_K+?blV>B&Sd=%$^^>Xz$CIck%<#!O#&t2+m0^Nx!bb?oRa zHY|KVY80lYY|AXBv=!A_)>BzMb#K*8Il2ZOOJxl!b#a1?QRmdDR~R;1`o#go&WfEk z0XI5UU2N(qEOT)JGjUaO0a(e17qA^KysW#rRq#UKeIw_EOnX(QUhiz)a*eD$MX60a z(bO$Z`a(7HEe0Sb+}ef1EQ|LB$JAVI%%R#CL!AP|7&uZY!4FjHTc90mlye-Gp-`luby-}AZ8uG-J{O+dw zZe4!AA;16J+?21^lmm9O2k>Ofo zcsqWgHZon0Uv0#%)}mKIf91w&*az}rY`-=&N<1d^BrpOl;~Cr1_l^@obAJPjQ4CRX zx6Rf%P=e_p_;`Sv2ktijY5H~2)*lZj5+I^KOo%wr z(N+CFLgt~+gANAEJ$4>m%ECeL`}a?6==SG$^1u zh@E5={Z71tR)u7^^yVzjIhf@;%~Xx7gPFOpO}X*YrO2WVX%Bj@n5n$br*jjR-T~Z6 zs<{mQa#ylWGD`c{7&uL(uCoeEKyl7ATzZW0MS!0dZe>%BKTrC+2n%vdCj*oj%M+=( z>afItF&$!zfz|}2wZ7uo_jQY&g%O5e=LwJsdMTgSK|*M-8r#AbH}SD%&?^7G;6J+;JSJu%rxOxB{4yW4Vn{W90vC47Z7cSnTn?C$P5 z{jk9oC(mR)LN7i_O!kZa>JxzO9n0qplD>88+9yAT<1kF(IQ}a%{E7Z>dVDC91q!!Z zSttlJKp+VMhB`O^&v%l)+ra=rWq}A?Z2D2|?;S=`2S2b<2S0%sxUBMv$q;vp7i5R? zg6wpZ9fb}8*>`Xxrxs0HB|aygY&%)iG~IDpPFC`cP8EaD6wE#ipJM=lR24r}P1WP$ zjre#4Qb#|x{f1usRc+`z{J8_#&Kp=SemC&=cJWU)uJiz-SMu| zcJXJvdXn`9VqNsZd zGACB}48QZrhY}EX23xCLTxQ*NSqgMq11oo2BbI)UFqA9sW`S$ZP)%Hb%LZ;etY;T> zLf|O_*Bp!ca|;VvVPPSe#y1SdF|6fQCV=N$Bqq`*RrY=xmq5M4hH!= zdlTtPHq|lc>8z%-S6f)XS=H56F!L%rqFlo?*+sa&nejB&M;TnbfL2HbJrQm~nr8i; zo5ft~rrU=ScY{#yf#?jo-j1%UEKxhFv{6TY3HsP%M*~tpI}++0VD9g0CXTH=XhsuX z%YT$NKB-4X8qtwTrWuJ=ihPJcst@wP#)Ur=)(dN~-R<7ve=gPJ(|mT9aN)HFECKYf z1dxDr{t{24fAnQU@kKxY3ZcL}<=(4o$d=CUpCJ0S*w}$S%M5k;1|4Yf*M&6u107C^Rc)fQZeYG zGzxumA$B!R<6T;UCe)M~djZ{w(xfV>@nDovWi=6uB(+aX1|zv7DrxT~9CP*N4O}FK zQzUqeSQSDUW@|bnMZ8kObJwq6>Ub_;_=N4?Ia+e;DQuQ24t4Q=8lPgl>X=0aoO-o} zb-9I3H;4V;X2B9wb5IE z!FZsLnJy(C+fVC^XSz%F|Cc1H(Vir*#IvO*NhqKbU1D(%p@N{sUWd6ZBeawXxzzX! z+@BXBloCro>y!ct*VW`}z#VXyM6zX)y|wOU88Ikvvt`rHPET$w60!8zB`%YQnXyW|)i*AUB5Qa0buI;+`o zEz6NzA)u@($y(+rVQ$SL@Q~aJnanY2O} zQ*HUw?%>g_QN2C5vemb;lbPL3NFTlW;omm%?Zlzo{=-{G-fZ_@-b!BHkp~*r9>ICY z=%R{`Q5_y!532uOO4XQ>fY4E;qF z`G)-e^qQk+DH)!a4`N<8>-6K`NlQIX2=A#zbKqZR#p7sfKY#zzynL2GI=l&^QMmCc zniWUUs-VQ{qj}65Kmh|G?Vsox7{jvGimZWS;xt5dz^ffO291&x~yP3g_`kngSV_((3 zy#4i|uT!neg{|a;-*)BxjdLHLgOD2=zMmQTS%m%xgXo0r4IG`DME53#FC@h8;sUhK z2jCa57lL&UaC>3S779WLCmjH*!u)mEr$a3v07WmX=c6FxnKF)+dZ2FAPxJc zp|KAJs0V|{qAxOUMTrGk^`o9=7X4_UaOD!LyOa)rSS3Qoq4N_Jy=FOj@hJ=ap=%-C zFbHEE3uzui8OyP!p6_BKtHf^fWBio6<%2B|w}tbqiM^ zx8&14MnQ^zOa$FZt=mdmi~;7QCJNE9XAs7Zk< zFvv~J<3A9x>QI6~M(-r8d(!eUF6*wbhPhGT%i@}r#S>0=k;w^(a-{hYqpC!G5weXh znzrkwZqzEBY!ag2B;O{4*`(v4$9Mwx?^Cr^>XfxEVCWPuW}&iqXd38O3H1%_WDe{k zGaK^9^5&&>@(P>zJa*)TkC0IR;;e+*Vm)6}* zUx6*bzWM>~2@~8A_O|f%QyzbVEi?xsrO!`jTG25y&6hN7)q@)lO`{w)l*erP5;T6? ztyRFLl*5LO@`fkNz~=%d2{iN%M0r^2=r#F98NMtQIY(cEHGm^x+?(L_f^Z+b`y(2E kfKuNh`QK>d0g@iaQ^Mfm!%1QI@kl}#{$)5V3t<8 literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/auth_mail.cpython-311.pyc b/be0/src/__pycache__/auth_mail.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7487c48ae94b297a1b82c79110255e2387919440 GIT binary patch literal 11099 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 MRwFK}PUii;0Ozw77XSbN literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/auth_mail.cpython-313.pyc b/be0/src/__pycache__/auth_mail.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01ca3aaf0283e715b3715355b900577d7ccfe29c GIT binary patch literal 10328 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 literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/auth_rate_limit.cpython-311.pyc b/be0/src/__pycache__/auth_rate_limit.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c831059f0b0e6ca5befb1e0da6677e558e9c9ade GIT binary patch literal 3583 zcmd5;-EZ606~FwDOo@>!C$enGPRiNEVr{A2WL~z~@vyA12dAy$+HRAmC9y-K8F~Cn*i|%3n!ul6bg$xh`3>3O5Jn}0Im{bel`rue zR2|pTXzw(4WOp7Qcc=pdZExE-0;r@W9u6Mez7cKjH# z;>U^R)QRbfE0(EMcxEUd)P3-9&-Q?Mgc_U^67GWA@AC~rE;hi8`JSY~Z!?}08|a|t znw}UK%thVCA+VA;*~&jAF? z30@+BVXefm9WDudT7T zRyIf-@^upu6Kf>MRAq{2f2wP?qm&K9+R<=vT3IR^Hq+}Tw1%fwmNtq5zUb&x%}-P< z$WY7FD}GEhtjac_3uPhW3x;O;aR@NT^rA>;2$4}rf>1opQtAK*MTMLJ#uurJ$poa|D;P%K#rn7~9+0CNrWCHgDx^gw4Z?8RV4=aWIiVyuX$}@4xMC~2 zDX8^ZmNUnGcW!Anc|6u@a~n{Gs8{`A^_{D?)TJA1f4s4wt}d>ri|<@re%n9&^6TZ* zuX!>FGsvqzZ2H&T7#U^(ss_f@TG_UDEP_c9RHmrzbs$Z2BoDfoORn-drI|~Iv&${{ zdRx9ui_Ne%nBA*0jj8?HZe|MF;V-VVJnxG0!BRp2&|k{`CSbC3B_>us)`-4&uNOB%(bwVd8SlZga!q}I zacz0&b|{MSKOtRSB`h++QIzjv+5H20I)S4GpA{7q7SEx7zYseT7gY{U5>K#o2OdJ?zuR*wc4m;3Lr9*jNpro(rSi z6`ch6*$CuB+k!I~>?{De3ZEksa(X@s!+zO)b=J*XhIaVdcU$sWTVCs<%3!~6kasf| zpdC(MYsrgkd9kn1hrJjNPC@is&h{U2igGDR(m+7Y_9t3~gH4L={eip?$XPLBa$1oH z)M(@tQ}nz+7Pu?$12u){8) zHO3d;rW-0fxB4PIsj|J*A5_)Qu^6mnK!N<6lJk`Ol9FjkW+m$fHo!i}~kDC2gUhvX)^ zJv8Y?+Y>3bmw1qFNuzCPv>Edzrw``8a$1wK?aA4u&W3PetX QNKq9wBXh#Lo+*amj|P=AzyJUM literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/auth_rate_limit.cpython-313.pyc b/be0/src/__pycache__/auth_rate_limit.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b778b2c395b68cb89ff33f92e82c627cfc0799e2 GIT binary patch literal 4110 zcmd5)>f zLnl%uLls5j%Aa14$PKx^Q5%2D0ioGki&Li=vC7 zm*AOm&pr2b&iT&0_odtIB2b1S!MlG1?mMhh3u`jW%V)qmCIdtuln9JqnP;w8sD($K zoo6X)e67?9U+cW>ik;eda+8gCssq)(edJ7~LLE2ledMO|3}XtYOR(MK1p6_l3Qe?G za8S44q#nUVy}GNXOOnAktMur9&{4qOK-0a!kE-OLvI33-NoBs-z4H@xQ(N+y>n99wZlm#4SdH zNzhsFXI}min8zf_tkxQb!Ld0@lpL=}Vxxpa!Ff1Z)H2>mNNfNlk&44>9f1*;s)KmY zsEyG4lM!Go3;l$oD!;I0k>c{g7M&BX%`HwZT@!_wDV@#B`7oi5+*( z#_Us9%T}rwdvtgE>~1hzc6c9h54f$#k3Iij3D;U}`ZgHeJfE{J-5+BQC-%@w(9IXX-#)zV;_ij0Id|asM{+5 zLd!y2@zje_D{9a#VDYaAKsLztF3-b}4@Y)gM>eLOIz3NXLdE_^-JA9&4$tQOlB4qp z2j7n-ipO`kzH(c~D<^SzHv=zR#OZzLe&F7UmmEjR-nLh6kUtFkD)6Jy0^@!*Fgd_} z?P^6j;F>($^c=Ho1`QLhCT9 z?E(1-a-MlRwXPwpb69haluk@*%`;_hM=@07H22Zc;5n`N{7W0#<9e{LIkoQ~!JcCL z(XGwda-gHsIkXcP+MIpb+EILOd-69oJ`e78kCuJGU%MW;iYGsDLkeySRhz!)9{t|i zx^?!WUli|r;~gxwcfAT2ko>^{qrdQz_I|DZwAMGK`Oo|=^wov0-qOx3?zSy$PCaS% zZwU_;_L}*fW`4Wv+vb5ESmIx1o&kD0Yv{#8_Or>}DL4D|B{$IDdl;l0LqPxFc1?9Q z9RM$87#2_8JHP;V0m5df2IXocV-r#h+bWnao2D!cJ=h9ZaZIDUzDLR$wk@u^;aZZC z)k>7juDdFAh_jV2Yh*b+U)#JuIGE{=!20U`h%&Bnqy$8UO(^0L;O~ zd`wZ^muZ5ALFF>)o&&N$o^al+VU0hb^`6vxr%GdUd)$>B?n>F~$CfvXH;VI`?|A9W zi+kLq9qtmy%bb@Y;NvxeL8WF)G|_)%%%gD8SX_5fNs-iX*n8MwZr#LAWJZ`VcRsfq zX3X8;gq7&_o0~H_*Y^fFj=TeZ1@UC=EnJ){PH4VkrSSMJH=$W4%(d5C3yQV1|E~kV zkIT;`#QPFWC)aB6&~i}R7#3ZI|Is#!)8czGk-6C$4bc($7p;(e&@Bg|BUCqFbp?1q zhTASZ0lHUF{{j%hidyTScI=!scwX~eC{0}3oTKl)>w!y-qrIT0oxcMD! zzA;ArFYH`3sI2B|M)p*UjIu@xAF$!l%5sBwgN^2`HoeZa-3M$-r4-zn;l2d66=6C$ z24-DB`<}1cw_iItruCoEd}mAN-`(RPJ6xm!!IACIHmCW9N+Z*I+{_LKxw!_xu$|(8 zzs@y0LdioP zf#fI>Onwxzgc0P1C%zy?MlcUe$HlCiC}gCc(J4^Ee4~5{9Rc|BDWSz5Eq@KkzO4b|2&4AG9&PeILVI+;;%? KcOMXA-TwqIhiaVw literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/awareness_manager.cpython-311.pyc b/be0/src/__pycache__/awareness_manager.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff744732ede718c7f4a69ffe7441ba3885bb6ac7 GIT binary patch literal 3698 zcmb7HU2Igx6`uRI|9>_%%L2x0Fm2W;c1oL+p9r#Gk~+mp6_b?QhhAOo9qfzm&t~Q> zW*s}S8dSM0YNIL=C`l_nRFo<_s@;T zsO`OT&zzZa=FIGzZ|0o+eL9^$@ci?~f5pFuBJ_9OXg~5Mvyl=Ix`9k&3N}((wV(=N zTU5ocmQ*RMWmOJqMOB~{?T8amBSLFTRE>%VM=!_P6PVI1u>2`}t!Gm&t|meoNi_+z zY^R*Gnszd3#>uK#C#U9|9<|5mRePNs>W*-XV)r@yYJbQ_?43?t&4+x{-sKe3LdeJL z-Oe6$PsqpZz0QC-5b_CopEIZqIz_eU45>rTes#ZqMD!Xmlh=@$x+TI2;cGqW0aKVl zrSwDItds;6Gj$*PmV;Tunsz;cOHn4DwW!aeDeSY@xvFn@u5L4V+%kNooUc`F%%rkg zXX!GnyT*i%iGI<>U@LJRU-i#=x`_$2lvm3WQ&Yg~djlIaLai(K^o&k)0|s!#sj6-H zz=KBr_iNMBmOK5bZs0S{MQobTpdjcmJZiaEqn2aYI%6YOG2Cv{h!hDo!AV``S`W7pR%SvEGLZF4EW^Y+Hh76=7X zoEA;VluhLmX#r^+RlmI`=(*hCSGL>g=q?D|z$38*0c^Dak6c*n_O_qw3`Gm*a!2oy zVDWZG_&ed3+)kw3u>gl-He3tI`Pp_aLT2<%ta}BEqTjP62bd$|Jf(W|?J_I6{Uh;^;M}qj!q~%&?Cl8%{ z>ClzKfV+g_nb_7`9Td`hH3^oRsGL7jIX`i(qW!S^MtO3Ii5_KgRrhCD1Yd>eD9Q8H z?*Ia)<=p8h!gq}H@^-v6S{pwFKTM|Bz7#661n$6{^8G0mi;oA!^lEjC5@SqnBTaK! zP#dk*Sxf`0S-z&t7ru_f(Y9zWn4+HmSwd@j_pglKJki{}x4Ca{Jt_|+))A0pxe*)q zR|2In*T46x6F)z3b8Iy^(nyZ1BPm`Hng(On|=3_13_}&Z-vp|*u|h=tQL$$!B~F%L7{Ye_)h6F^-uZL!bGDm z0RuA2@NU5RkkLUpH27J0AHI!`fcyXna5T50bER|9g1GZ62qv5%^GRX2Dt0Gz9PC)X2z4 z%j3X2{5L!$nGAzI>(#gJGOaa|rK)g^XoeTwAWWhBVk}AIAlFyksbNxQ5rS#W_S+aC z-{i6kfWofNEueD90=k^A{0cl$r5!TC2op9%6u zR`VAc`3phM*UNHbE!+Qre!bqv9{eKv{Qd0nw=1jJ-%a30I z9M&6u1_I6$K86^)AbyN~BP~jCqtl+{A|3L>${Qpb>^nOnj3E9 zhJ)Pjx&(qy8j>%$-dCJ#^*xh&^ytwuWuf5FgX}I~;>Wf~v-fC_Itn8n!OZbvP}8IE zOMcd0&Y<7!EDO>flfc~*WA$a655yvWH(PEK)B=7gWP5D;W1=Q!B|A2oq2b>4L`r@SuQ zmevukpF)QvG;?^99bD~>i@@49|EaKOzph4|s6I0{{R3 literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/chat_assistant.cpython-311.pyc b/be0/src/__pycache__/chat_assistant.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..06967dee4ff99920454b377f74e5d6e96390e49e GIT binary patch literal 14674 zcmc&*d2AcknICd^ixjEDx@CykjKbKuivZ5H4k|1bse`(JQDCwDqgXgefqE}sWDqbhfB^%GEVhgO!NAFGHve>g z-U&*ncjO6Gjy!rQ{NsobYm1 z%%`Qm#Oz#Jnw2s+MMwzAZ03R_D~Vhxn@OaF(Fq}8mCq}w%#3g>oleXqgz2m-%w^N5 z7uxO-5as>nQrvT;r)^PV*g`(_7d0(ahzjL-UU% z6bXZo()b9ybo%u11DBE#D};omR3?>6CDN&a6i>v{*_jzhPFk}@#KiOCXYg^IlQ^s+ zH|502I;MEZCAmc>;f2L`h9MX5;nwO19WP6Yl9-VSy(gtPSyBuv&_vUckR*J~>Lp^D zOU|Yx%?k|WP;4^l(R}fECNV3;<60mdpJmuYJQ$C^l~1IN9B(`>W|K(fm7J`F1 ziN%Q@Oq4>A#iNTye}LcOQF)zNb#;V&7ujngOys9Ll2`Ic{tA&*VnC~lA(Nj9pp?s& zs{o7FJ8U_NuP751v`t zakH-2zoXc(Q*|L_@3L!esk?Xa(3RJ(C9l4rx_S|o8AL?yjQUx98OD&a_A*q{t-c_u zpqeP!qXmhgPfE%hHXkEtJ+>yYWok)tv$dGi>T=mPrHm5KE0P%XKLNWvsL8Oa@u^I% z7N^~mOMn(Zc@e;lQ%15TrIzaI7Y{EU{=wl=?}o)A*V;blf4^IGZ9rIN5D^`N)SwN6 zK_t-gxC~IiAnkjxMRZ9n(G4Eyso)D7(F2a?6~U*ce16W)iLKz*F42#2eko8XS&4yX zyQ|4ttV2l;R>d{#tdy$6rhQ@%ebk8|DTuNm^jZI&YpNc7wTWT$6~T{qch#0*<$Piz z>V;XkCX{JjrHn^hhcXe7$9lJGAzE*XpBH`+5b#i16_Y1EoyaH`C7GD_6FD}u@QNZS zCY#S?g*iEUAtg!z)>%rYQ!`+G0$3#@GKh?`*?dlzI4#U%AviK50fwri!4Q)UtlN|X zi9qdt6L{;QlzA?;b9npNNiD$0k1q!vJAn!V+sj?wnM!Y&xQ-m{opMo>7LaFUqPQZdjOv91^nA ztSLi6>ns)vhz)J4rz3e-Fy$}_ePBH=GpQtq5Yi<5<;zyBLR)MLB(Ji?MsP7C6*k(E zKqRKH7KMwc+&O)a!$RT}C@*KSc_AT7LP9tvrRS#e=_*30&g3^yNPboF~K!siYJW zCZ?(GAeJtlQS!8cY}YHVHMO07pb|qEZV{tW0O&@Jb+y$Zy?|Y&^)@9 zsJT*^>8#ckPo9&KZ^pAsag75X8Hr6%3n`bCoHQH9O3cpXqP*r(r1W&9HHA0?HkwR| z0%?0h-SK###YXcnGkp-Hl=A?KTsgvZZn)yQ8ZNbTT}v-H5v_aJ5>+>iFSi^hwj7}E zk}r6*?t!oEzOPMfpS-&X0Abm8tmr$Y`i_-7oImuyx8c5T!?JI%=o?ghgJq9zeZq0Y zRrGbJEHJ^Cv=|)v_TopwetZ+D*CF9@Rk`P_xSd%o!18$ytaZty z%aOWhJOi9OwgC2AB?5eAUiDk_&hu-GxgtDPAEB>yBxf*=1^yjg9}&eaIxcdT_%qx^ zM>J5_xu1wsNKKoP7yJ%8nzj(ziC_^rL#Ol$iBvj4#BW=A8VD(|kB0%Sb2E@sZ?r8q zu&nrA`G_PRvEUW6@+Z!#?NBV7nv0AVjf3^Y&qz5WIAdEi$E-r+VQhzQ0(;X?$;s|} zu`pb-A=cP%vAyYRGLcqZM8vFn5KSr@0Twy6w7%G&)fFbIPJuQh44km7NbScBaH{O?-u`yWKJ}AKUg4F?bO#INoqfvIX+66Gz z)AD6fo^(+vq`-|cm%%xOXDL35NJf%G%04RzbNO@{<0y0=#2V?T#^|VkKHg!WP_U02 z@)p)65x_L7hCZPaKy(oQ)w{sJ(Y!U}n65EQ=QBFHPN#C0t%4+Dz^ZepbQLwp`Amk` z@nqDmd30u|xo92aZ9uMT=^;;TkOh^@tq$52*z9t;ZH)79v&f4Hvm{qOwbjrZTUaX8S*uKU3*wfn@~BLE1?!IQ<{Ni}#9 ztTfVm_4`Y2s^M)2rAYJpbq^x__apsfr*qvA#|Pq#Y?-5&4olG;Wxn#MCw{@Z8XCXk z0LnFh=OWLc*{{C}a*_5j=keE9fHAV}`@wE?{pg(p0K#%`tQZ_qgJVW^&;4KzhSG4v zfnGxO=p}?Uo(R6iQ+A_2ycM!9-yIzpb90}%1N$5JPs1a zWD9E^S<2<*Op=z(e29%O2@+uvWQJRC&AXP&T-#fOGG37>qVvW|Wto>d*RBl}neo?E zYdYmE^G*>q++S4LS>xOr0}Jl8TAX*EwB^HlftH=k}o5xd#FDy1@VdP%jily-*mR0H`OFIk&&5)VlfR{+r`B$BV7oh*eN) zp{8qmDbjX5{MX@9SI>=?N-U@+OC9k z@#`h69Zue)wb_6MQlNf_-{Zf7Vs?acFy#~M0ZIC^gnhH}0Sj?dHmw}4{t}%cU%mM= zaKn?b_aR@aTvgYS+pE8pEGAIh_ec1s*y>6+M|Okph#q_E?}Ev#Zt++5gE$csdk`lz zoISU^_QQY$QkBh2ZfSM3)0ZPX&Uof)m?|*fM;ie%9}23=K4J%{{jFv`$DKDFWzYyK z^}t*MVx1Vg;k*^986%(h1`0Dd?~Dfu^G=9?>XBEe(`G3s=JoSfp%0y6_*TTOTRONL z`3#LmY!Dl7HQ7tN3#Pl^ddGFqkrT{v9EYB7FSv94m6Z7!C4Y&0zAGO43NLt`V(iWH z?zP4)uABE<;1r+O!rm^imELkg&79g+nNzz_vxC*xAa=5MudQa6*j>FcSmE{d?+;xo zTeh{(10nP$a76Q3*#hwGe#c!gL+1IVD!QwnRP0UF7=UY(>l6F!Yr5c__bOdB+>`r# z#Ul>XQn0uIb*t%Zb?&io-doEhR_72o(pimXo?5o{&ziFy6rZur=2q0cPncP`-fWf8 zs?))}%hk~Dp#_(1ZEAMwg3mV3)%Vq=dEZ*JSwjb#^^&WuHrrj6(K(dZSFOsx`9=av(h5j4TOX_H z86=>sG3hN^zQaLF0kYb#H<*A|tVVCKojned+l%QYuhZPQ>|A^f?Gv@i;{cjh$GcK!UulXm zI}b|e#}3UWT}s05HJwfU4~V63V!uA*6*jGDtip*3HzNFvI7%WU znfp~H^OJ@KE1Y>K&SjO{@V0F`w(p1){FcjrAy4D+oq8X*ke{dZ9k22!)Xtrm1%ID8J8HiLWfRC<`#rUG&^gcw=jltdK|K3 zXK+eBB%Gxa8L0dDw3r#p2{^GKI>2tT_KG#q9K7Jnw;U@pA2Q03Gg?3185XpFbRm(( z%D|f-lez~QqO&fYkVv8z!UxDJKt={_0QffgDy|Q!YqmEk%|=_Q{0-Ee=7t|4HKzq$ zO{DV@Yf(N({jg(PW~;hI>1=*R15uFLzKWahR+)ET0r--@HQzPg*Iz}r=6;wvAs8?w zFuXUt_anV(A9+;zMiK6ftC8O2$cbX)gc>>Vu%*8w^p`p_Xug%~o>nvpKz2_fc}>3Z zan0Rju9+G(!NaberQmYcv&F7wA9QWM-?e?YYiF@*=QY2r#{;+L)xHB1j-O^-zEX_5 zqDEdJd<#pv)uthY+x|Oe)U5{*?nTwtzNa3h@UUaBG%#4|$zxEREf`d13kKCmvtQRz z4sgvqOHs9HGs11}o!#ozafEy8)RW&)Cn)IE%T7)>?!4K2s|RP^h*|Laj{VLrIqv(8 z$x0gKuhQ370j_m`;b-Jm`)>!AhxQeR_NkpC2y5Z1+z%{~w<*T;Mt>3f+u+SJcbv<@ zo}#elfiQAk7`f|*TTYlP3X^KE`%%*Xd}g7Pqsr5!4*c12WV9F=RU@ONfpb9JqiUz! zadpRmyW(Btu2|dw_uX+lEC){%gD2GBiO-vQN^Q|nOUL`!QrE!GkN))N&92*TEqCoM zcI_^EUCpqQ0YIM3y5EfX;;_I@KNi>okAe*k!)+Dc9E;JaJO1Y2je}QSw)yHtZim$M zBMA2fFr($*v10I;8a(!}u?MDgsrAU6m(|uI2*6kqS?Nva9RQ0NB6r-^Wv}6vJA$44 z-q_xW9o%P*p8dVtXM%&`y@5k*{AZiDj(76E?F=FQ+h@i$A8O)%*W|{>@7l&XQR<&| zG#?4`|Lhxe9trUO5^&=~3rwdZcqzz3&xC`v4n80{FJq^-@uZA1G&z-6;nMpd#_{;C zAZNavZBAO}3f-KYjKg_Hhc2!U-LMRK**_0g9l7&x48s@T!6CPy=+$G@r}9Yk=_l*A z{C52sjy-O5d%f1?`ysBF+A6oF_$ggBP^EFm} zjkRIf?`C;=i*@r}riBM#F+&(uPEKImR(lZY#jswo)(VD=8(8qXI9BbQpwLYF!<*)8vn8s1e*+lY9tCd3ctL!M9Y3Rm0=u!-uJ+ze( zLn$P$sdSNPr}`Y3?mrFbpS@_SOKKNsb~^g{3T=I{Kwt%ih68>S&>Vrn_5iFM zb{8li=vulYTDJ{sWMFBKv4TZB_GPN9z{gHqqa-D7ve15jX_NXARN)=gh&+iB@@oKv zj*5zDES5#9@^KXS*jJ@rvKxM+c-1I`GAW5spRWH&lu!IYo(9mGsz+;F5YU38sl^RL zmT8AF*?0;uAKD1?=ywI`^-iS*k^jw+4TL&JF50t&uCuOKORqEg(8%W-qfk{k*8jZyr|nC7 zm)o}%+qbGsD?)JV*Pn;G&@ahf6O_J<{GGV-th#=J!s*wU06tTs5uPyx@c!En!21!N zD1gblcyDxIG|GJvZQk$VKiN0x92?<39dYAB^O@I`wR-(>oW5q4a<)roCJIPG6L<{YAJJD20J+}36fM#5kGWo7KC|e`e$J2^D5*_-j zPOEC6Q^y1GOnp$+fFbtf2~eD-76~@am=3gR+zj6=uA?wT2l_-r03`{O+l{(znR5$>JS-!OeX@7ze{OxxK16LURr7tHkpg+Kou zW3Gu}Frfw$CUYIX{f62)j_{PswR^OW`=qaVY#;y0^I)#K_)mAa@qy#qek{&p)HyjB zbxcMZ*1l9um$_sYfgu7>0`P_y_x715h>7uTN@KEomSQr2nhX7Xlt!QzKSjY(E?R$O zC-3&bMgs`6yA!zYL2zli{=ReRp6fU7=3-Kw!q6SmU0vcVUA&A8+t z36~O}4$BfbLy3jXq_bzCD%%=8 zt9Q)GPJZlQsKFjmFjYp-avm`vXwKktjkukM`&8A~l`kOs2+Al#{)^mKEu2qKy@LjZd|JR`**X-hN ze0zaM{CnDq^4rLzIV*1gmU)N6fnDr?)@y+P=cr!{(O-!hQlAt`+N?fmM4<#V<0Gb4sLN$o9k`_6sebMHOpJLg;;RaWu@!|Ly1KN}$Af9MY*vK!w={|Zg@hVo^95kB62*LNqB0%gJ~wyds1W5oia! z#6pR%B%Du63N}{)`~#fojGxoE<1nVi9gc-ln(O4U-n+(*C06{5<{l3z5==&l!;i{i zr%#_c@=92ul^~-wmWZWdp?EAK1w+Aja&b|T!`QDG40@7B0mylWED+c$BHKhpW?=_y zG6ytrh%8cvrZ`F`yX;(a_?=n>66J)XD4|8^K_%45bk0i4vZNRU!8OMvA&jJ3(yIkD zPEN)p%?ZRxLA9H(M3?3Y1{0wrDHzoFU~q|&7Sf(z@O(NHH%h94!DviYQt?e}hoXl1FzIj}3G;5S6|AgyxEFXl ze)I@BL^~xdCs#m;-Nu_xwg(Xj$V4#Q>iPrK|Re* zi9e%Nq>?X72_=|Tq=-(=ia&;&J){`8VTXQb#R|xG0@3YzZD#3Sm*_Ei@`#lSl~BhEy?ZfFu7W8Q}#81)_`yGawy=WRx~B zgs9H)U^C#p(fT^!Mg&ttqC5slre!J;gb2t_6wLAppJ@#20%54^Dmf_u$jWRj$$||w z3Gp57xoxryomw=0yL2Y6o5iZ5mLz! zes@3^qsOoy`akRZ=%*Eqj((72X)zrSAv1>~cs?DIbv(lF2?(c*W4f4*MJT*TCWJ}x zq_F3ifq~uq!px+2x?ebY_|&Z4QxY~rE?z@a2C`pF$}cPYU_dwqi$r3}ijYo3-~&Ji zr~<{&c=96Ni7?1`xF?uhGL^jn;d!`d(G}bhl=?zwF%(NEsp7$CcOf~V@IwJ%9H<%& zvD$UQb>kI`nI11nuj76VqqyDOh&P7 zA8cJZp`>vK>5WyAOH%3_@PmFq39Sf9Qh@Q1B&l22E*s1@XNs@#gajwM?#`Y)GCz~B_%BdVJDWBQ+`(C6e%7p^rqmghl?enWJb(| zryGz`eaV>*nE8l3l+zHsLGnJ*()+EdTz%`+_^Ry^_pOTi^?r5x;f?wu_#@}?yj5|z z;-0H9>uOY6#%^uD)v7kl+;yGIJBYjTo~t+O>b>jg%R5}{A?6bIAFgI9qrw)Of>E6C zN15CQkoyjZnt903!>j?@&ENR#A||6rFmueT#pViiL}nK;0e>xd-lo`g5ye(Zjsc!Y zc9m^m6IrvxDccD^I~7DA2{vK-6WAm-$3DR(b+xH5HAu~|2Vk8cKtxaJ8{y}sAljjB znZCS4bl|9>bI@L3&x`ybuh;}wbHWxSon+o?cIPCa2|^OwQ$|Mkw<(COIqr#7S**QK zdA8iF-Gw=I08~hDW85OIf1^y49AZ02l<`+&h9>Y}3$dsvX@N%q7r<*rZ!fSFo#|u# zg-|RW!V9B0Xr`iZs3>S8(_;t-<7P+^V6kRxy#r-L_{4dFLgv2AV4nbL17K%hYRFZr zo{hTYT4#Voot9S+I_LsOhyhPatN{NJc4B%75(!C)VDVW=SWd^|Fpo^zvoLEt*O(nv z(C0fMWHO~Q2WbVm6OtrKo57eMF@XUi7t@D;i9`3+lmogvjiwVi(~HMaD^^A1)WGmk zv3L=E<#Zx}Ol`*R)*L!Z(YQD&oa~~MlKWw6Wkl_ii)hY^AsMS{ZcEG5?2;@eWxrhp zivW@LLZmTES_Rz{3TYILkrce2zMUw78m zovUwqH*!6BEvdEz@`Tyt&-wS>sD#wMDJIX_woEf0vRrjdo+Z9Lzx%*VT23*)eOyO; zO^=Af?X`w%%X->W;m~?$T~yo0?s~?pl8&sWBX6@+Pct90T&4FRf#~;nJ2d`8K^gEh z|G1xgw`IJCyX_cjh4gzpc8vY}#6I@D!QqMB><@R_A+32(E(Z-=PuI{;CDRlTb^;DEGW7EO0t*FeRmsY@9^!XqKIO;L zu{d01xO@hM1-v0t-8OFQ*r~}9>{`4XBlOw=n2{0rV2Rm{30p6iRwGj_9|7nlU5F*q zidj`b)`T+vzF}22+oz;gQX|4xBm!WSnx3E%<{@*5CBpG^L`TT{EIkbdzDBbWVrp|u z7C;7QS+q7@bLhCjZaNm*2IGbz6?&`MSCEN+5%#1*Y=6KE+degT>*s}KRt=puFXmt2TM zs^6-*T$QV9%{8>#Yw%|q{15GH6aSd7?uw5*#8aE|)V+1`^2uDIkZaq$*0XS(KE}B1i7Z2qi8(HwZKmyTXYWV(5{eWrDn^5pn0@n(J*ImK%kKu zW(c5ia{$1^nX3>G(4zT_hFLTrZVFwvw`F<(uE>iOq9k)gQpFjRIZk==L=W6qrng?F5hdJ!890kg+ zxKG?>u1NF`vpobLE*i*a0Ei3Ng+g32tLQt6HndGa^y&~-tP*|0Hi68e0Hr~!7Hh=X zVVeZ<0l{mOnHP*A&{{%&i?V9I+sv7>Aoy&rnOFFlr#|6$3hm0xtZvSZ;M<&;xMj|9 z0J@C8Nf+ylG%!rDK~IN3MJSnlqcQuYqIS(jT~IT{79-tL(ymo(E59GGgY8c%8RYb_ zipK8{g>uxPD>A8SAF~%!0oX^hG1CC_1)eAniImWM1oyV6SL>c7Tz>*xx6iqX=~^=9 z9Y&csL%Q##vhoZI3QK?HS3Fo)P(KmC`GbZmQ=Wfs2L?`xO)V{UQZ={sTfB&XpN!wU>ThHEc{?J41wvy5dS5UMjX?jw#qG}6Zvzv_Ol3odeF(jIdeGD)-^W20!!X%*KbUc#iO9^0t$8!L;#<~mEOjBSQr3S}9rtYXw z4=t3sy?#W{c{xmx{Dr3tU|9mK(mS~Ui6plkZYE)mdN2IgqMU9P13EghDAKP)*vsN`>nfmm$ zhp?>+qk|Z2!K!vJ#R6@CKN1;b1a%wv-8$qxjv4e~xXfwFpNBlnqi<9YS|(-9O+Vm0 zm9=V%m6*s|tJF(PxQ>WT2kH=0%c6W1yR4ajIa^+s0IuK?=S|xi=evf9nnZ)bWFkJ9T%uv%@p$;stg8MfGAvZF}{u=PLl^>sqdkULC#B_x)Wr zcir1Qn%zEnZ~Itw``G<@LG2vbs6U|k4nVcW)_V;*vJE?OZ9Uh6*Mhm;{(HS++1{~S zW5@NXYgKtCY3$DvuF-qxSl&Y_t92UzD(tJS@73OT;k#`&s?=v@Rq+e=#IsrPtU4Q3 z+aq^9Qr^*BS#!Uo^ZJ3mK9Fl}zrO3*u3TI1`+eJUojs^A)IW4OTWhYk@>2}y^#7gb zZ#*~7tlKt(1NVf{tT1}ZeP_>xFr#|feqGyBVslV?K7ZFYmg_mE`a0h)HCha+dym|T z+)~uN)9T>yI}>W#sk@%%ep%a*YxL*po3AF{PUc#Bt{=a4{6_2A`Hj}$hfc1}3(KqY z{`T=SL+ZL85qo8gHC$)b*QxgCc8RXBJBMM!tnaz|_1!t4JJ*tcWi-^}L6NEX{bLuY zYkNfMV9Bi=tDcS4o%dP?v#o<0twUGboBG?g_La4S+I2)db((fKhr1=L4sX=G15diru5X&9-c9!A$ zyivDv+l{*Kciikyw+*YqU(FNpIx}JW7(X+%hx!LHUt><%;3J%}6?%TmlZGB#Slf>8 zO{{q~`VXl)KBu;f7OgVh4GaAJW7shtEY{|(dEZlgzp9ZMb8y|vcb>g;!m{1aLsGR? zdE<4pee_NbY-QGS@_tPRXoIR&k%z>GQ+N&Ld>`pKYpaDvB>ZyXm z67pQS%TaU>+rS(|%BhhBxIGEB<#Q|{7npb08M}w{5Eq%@08Ao_q@f5*T2^$(Lon%> zM*{^7bC&A)R0RhgIcy4I#hjCZNpNvf^`W53dc`WE3=|7cBKU-3%u#^56iil!cpyIi z{Aaq!iaksvh_bNprS4Li5AX{SmVOqbNWpNF0AU7lg*xQ`bI}Sbw7BfKDe|sbel7D% zA!gIf)rNP^z?{W4Clm^nk{3(bB9K6=qLZU5)7S;ndt&7yd{^dsT|95}4xTjh^HVo1c>SO~ zIs>T@j#6w%lHlc6(fh69-9Zm5Jkk=BG(5Y~Uv*KlT5JisrCwTet74VqBz6Z6wZJW` zfFTGyga8A>@OnVu!Fwv2K15l9!Qj1FM1SVQ1D$xQ@QIBW;WdWO45F4opDTm}9d}9* z%c*J{37tJyI}^xQhoW?Q3OjJqreh?8Ei;*>BNQL$TTn!Iz}K<}HRJ_|GR+0hWNa2J z5?4MV$a7HPrmF}swO})Z5}P27jIhV=(h<$eSdOM0IS!FlTRdCC52|?(djt&?i{c$Q z29*_L3np^WU7}unR_Ig3S!u1M7^bxJSJ(%EOH0vA*VZS%%)#|9t{+$1ChvNtKnC_j z88y1BO6Nfw$czngSpJQsJaJVpd1pm8r+PX*Y9_wkoUdE;A5$m4tR4%h!I0W_HtRd9 z`nuoubg12DvYsz1!t=#P{7Sp|$e|sx~>1}o6``FtDhsJla@9nlj`n`QDlxi;X)uZOs zAL8g6@Jg!W5e!;{sg(NipDm><0~A+k)C8ki!4Uz+0;Cg4E^r6{N9Pi3kxXJmr|1G{ zguc@q*Y>kq0*=- z^JCt4mJ+farWmgz%!f-lk1}7bmWU#AKXuOsdIP`^VP;aPP`b}WQiWbU@LU)#GEBZ_ z4*JK56lBr*<1RkaqOSval0K+45fGE|3>+%?Bt#ka1YUadqhvfh|D5p-7k+I!^#@<- z8UsQ9auWOL)H$I(eA!WpIbutJBkC=RW=C0~ybPVflaK+NXd0sn5NXv?0^X#Ar3k!` z)n$t;W8vp9qTCG+r@`HH=2ou{5Hv_{w$pk_nOyl=?QrK56}qI%uD#(2W;e3F|JLDK z;`-;*k*Pb~YTL|R4?h0r^aB(5ud24()?I5}b=T0Y50BDUjw$xNWrD7-MQPHSiY6YWmk8jaWAY+6L6i|@J zH_1aAYj-{5o9rQGbudrxyZ*`@?gy`ZNYfwetFwFad&r?N1|F3@bO-E>@7Fiw?eK-| z?C4uf-I&hy?a#Ik=bf16A~ntU>@<%xLHzLEOOY#s@3L3+UruD*Z68)(_KVCsWB0D^ z$P@go^*^TH{5VSn_C9n!WB0ya*O0fv7lx?Wl6PXtMTBiP*lSxzQ|6v8DUSWuT z=R@aKXarU5@P$TAovXFiD)X2N4f*2G0?2K3=n@ndhW_~DZ*09k%0mM0)8 zd60w~fz*G5`|wWDbEGgp`*|#(VGjNm!!UEcs2se;w#>KrqY}7R0msP_+NF>uEyj~) z0ppciIIt<{vnb)9h1V0MVBWBv(PG5gz+0cf5-rnxoOqI{WT-53EmRyN{|a;eJK8%5_s(`x*IF zsOH}yqbEnE_A5tfWf>VHJxvkb$0U}G8X z5lP()zPsQ~Jp^cQNNZo~piGhER3rtA;)5-!S4qZ721$0QglbSK;p(y}MxO?a&`jLc?y37-{r%l=GDq9wy6 zxJ)1su+ltoYo;yHmI)?;nf63`rX$gj=}dH%`EN^fiCv4KO4o|pE3x`e@O}Wj{1E=O zv+A<)j!bu=+lu?-p3Kg~P71lu3=;frAfZKU`GAHx@V8weY~^u~7kDHuV&%0$UYoe9 zpvt zOQI~8zRM|D5>jdoN8Kj#lBB36GbN=}(|2}dNffN8_a#XcaY{DnSa#*}eb5;2?v%2U zDnaGEm`o+*+`@v0A-VmGikH*sG8SXml)RD`aoQfFO)F!5&;#Gsk%$tMfD*1(kw}Y- zKplfr!3ATY&A@4#%Pgti5-%-_&?(Q7^*INm-=oxMf+=D5ZTfY+IUQp2~MVY4Hg9P{FV1mYfKfz;+j@C&XtD3E8E zhk2L4yupAh+i^F{3nz7aILNCoDNlT zNm*qI$Td_7h1ZxjTQ);cZQqM}Xx0eLlJ64Bz1eb)?cQL!wVs1IJ7lm!8auSvKdSeS z8U15xjKTJ5mbbQ)NyCnJEE(BSKZO5-Sx~~K5CzL7P^&H zB``h2o-9;G0LH;>HD|IJQBhJ0qGEb;mWhK%ISC(x;`xCS80YW=KbPanEcyA|a#rA_ zERRKH3Cw~xWU@I$NiHh6Y<{~{o?j5Nq){>w6*TPxFFl0)1Kn!@X;PP!WEw_mdIV8T zN%CCOXL@qh7)>U9F{R>8XxgL~L@=UN<7;c%0g~euNvALo>Zlh#0|AbbbiXwy%eI+p zrFYgiOiDBZR;^@Z#ANl$i(^Y#iCIEntzLF z_03S5+aqs(uN2}-yQ8<#zgX0IV#IgHOWlL(w6^2GmY3Pl`UL{=kdU>LTO8WndtLsi zTpVA&pob0{p~Lq=V;iBdTap%A&_frE(8aZx``dOEy;|p9jobU>X4{S}#6Vm1^w;6u zBK5QBo6~DAm$=}Yr|xl)4KAYXn)x^Y1ia3jG`N!*cXG3%cYP94JErxHX`^FW5v( zCnp67kdZ>*pUr6k*n!XyImCMiIReD=SL-V#EsI$^PO=C-in?n8iU}seF+z?5QOH7D zB4`U>*JUvcFq+`uI#8C6HWS3AMV})~{{x6EkPgc@yxytx41?F%Lk4?DV-Ib)+I@RK zPL1t*OQHRx?LAv=n#d%OhlGGk`cZdZai_84;M$80X&PTYuk9KKuX7UyH=%J8TOKqx zygBg9y6@e&_vT97Lm=SWT0t@`5@`MMw;T#~Zy}d&PmMf*XKCBccg_^se|GNXIjwU* z;~Kpsu5RE+q;(ez``uU|HjM5J`{Re{JL3oA2k1`*DTse^zzx)7;FOXwJy--|kab9K z6#;C3HLf6lB5-`vf#t8tvva?$x&*5DXaz{AM+*&gh{RT9yvQ`G4wCO`DzQ(l3FxC) zO&>tQ#j2JmRuMsc>xBjcV9EKD>NIGLRrgbjjwvwB`uG$z-352EnkoUPWvAAmmPY;J z$ZJ4Kd069yl_J>bNh{@Pj-3ij!CmkO-m(TIXTq`r4SI0Z>)>3cUn`}+TCfFgv#~Cq z1tj2&jB0>8kV)6u%;6Bsqm8b)~rmZd&vuV5$5ZHcwdtw{`sS)t_9w z!5BM6^tMr>ZFG$(HA|~M!|X3kX+8VF>+Au8J)p4%9EyA&$=(NEXZIWIevRF~yc6H7Qmwzc%6$ITwLSg4z>IO$$kO6&czHa zrg5=fcXhwB`$k6ZIbif0_-p0XaeX*u49E1InBEmPy5eio4hAFZgW9eU@H#hYaHASG z3bvwuxYRcU*zgf6;kT@W#|NgPLdx928(dh6#6P|S1ia2o8r-DDP2TU@T>`&*PXjNo zJpp2X9wD~b0Pe(WknMJ7Bz|ma7=1eIKQTyuI^KVxkN)LO3gW-)a|3N|DRzM_{QqGo zidC%-b5(VX_Th(s(7s2XL)YmaF~n9prdET6aMb!gu@-`-z+6TO1EKpe!l0#zwiZ~T ztLxF}A~`QvuD}%QPTiGM(9ynyh7*0G1$hiV_wUHhX*Zf<2YNfN=4-%1wehGm*G_cA(#T>R4*eA|@Ypu7nn9%l^nyvy}V87p{5)D>% z1=cz5S-2V(f@RdfS4tsdp$?y;*Uj5*FZi1F8rl{*28% zI078Wt=ht${M+DfT87%6&$mo2D{3zD^5khK(q6OdNhO`bA~^tZbtkUFj;|3j*jORY z*Sc!qnkrPV8AAJ~q5Wt(eg%T2xMmtMOjmZvbf+**t>B~N+pSzo!DZD65~qb6yjjQ+ zT%<0TOg4uz_*JXSyqtp@B67Zr`fGRxpMwPaZ9;yZkUs!oGGtOLI%4{3gRs#L!I>5G zQ@NDwRK!dwt4is5^z%%!^JkNS7lEO2A13~P7yI=2TJMMXd*LG+;Ul+HJ$%dvAJamO zer*Pz&{GtuM@D1o&uP2H!0X%*gFB*eM;ySWNp@T4#xq)5ANb<8Z+W#n&w;-mIacZ& z1W&HKIslMZq`ddj{|7hjSl^chDvbwhVerNIQyWQ_~ufOt_ zkz!;s(tGn};;lCaE#rWSn|2NOy9?(Z;jM15Y?*FIgzYKjgq@DkcKKq(6 z`%rqj@VFK{Ue4w>g1pujyOjU} zUJu5NU|b8v@ACsC@Z*!(_&47_vA$0q7&8XOH1MyUZvc8<{`N_raq>2$MJB=P+?2sh zY1~wq{Qy~%`fG}2c+pC8fJSmCxibAO2VowGh4OYC8h!9;ocF3r0HZ_u)I0h7}<-0Lq(0vr(G za^3W%l$C5+%7IFQ=V?qZh3TD7!Ik=wWZj0F?9z&0DKPATOiVBvCSc9YlgT#Sz!4s9|B%EzIF%K08S-=`!UH$F^A0VmlC0QIUm@~cIsL)YHF@e% zB*l6IvzL`TTEdO~g+;i}v(~9$0l#98*{{ye;}s|h$3>*90^Oo1in@;i*R0=tv_o_H z5^A|-{pupE*Q{R&b!kptLVcRkm(U^Y@xFwPXiw)$s6(sx5^}xa`*G_JTGz0SIt(kh zgrb_$m(a7C({FhwD!7FnC2zg>1xbBa%ksD=a%cDmx#9bQq&}==`BjR7BVTiRWAF?YJX-Ouf)rW1-ACfFtvgJCCDSuP8;%ZGdiPR7@xuVxcF6G%( z5~)|*H9aH>S|Dk0M%-(G@|vKh1MUw6I3T`14zS$>u!93il}b%)9n?U3!1+J*tm1Ol z0C(>#xumGjSLcAk{c!{G&CHv5^YM1xo8QbluB&qr4B3B||DlPH-x!$^;Y;yjA4AA> zA`+3AAQWTE1QWJUi%{gTlr_pY${8_Fd5GBwYuH9@MjdX#9(GWNk>)3yVHb6U-P9ef zqjd()I^hX>sW!<;a3gIjwckXWXPT$>ZXQ}OJvHz}o@g6^(PA*9?Gvrx zHri&S9TV;0?R2|9T1ht%o$W+)&A3L`Vr}X-xNhLqZQ=$Dt_Qf@nT~SZ&Lk7`Jwn0B zAfs~^V%imqPb%|ii0jT=ym;Z{t8r;glQT+?)g9w8RXU$ZNGY8^B}=JQ5uwi{(d)?^S~l%la{Dw9k~ z6xj8mM(5+&Je5L9EVZyG(RguMMOvC8c`QKwI+-Dq5ec=33}tta8E%FbExVvZWT`dD z!HDWE&`f$xo06{0ORDy$0m^jioWc8Bn6gc`N25wCEk&ccGa5~267wlcyQ9%-^RZN^ z#2byy%2d@-vLY!NC}vfS>cEXDN=A#JZdBklN24m{;?bCLVmo zmX44q&a6?1r*N5U38KZ67sxa!-v}e67}_*_AxV-TJGmI>9{}4^2P#uAXV4G)MPG$4 zz~ZJFEE;P(qs+=l>V-yiYteFbJ|SJ6Pf{Q98o)e?HVhV0G~%E|=Ts>*OZ%XLit`c# zieDcM5Iq6dkH6AaS&jW>DXt!M2*{t4LW}>qt_Ll_-0rjamhckyU01>Ge#`Zy>%P5h z)!vr#?_IO^KjLJ6M7frocMRZ53V%?ZU zVnmO^hxl1$mOR6Dl3C^;JIe&Ui|3u9q$E@!CKNrYpk)N@iX?pX6-g0>`bUJ}aRFSa zloIH?qR2{8$S6jIR3;uv3D2ccv2;vOB?^wP-?`|#267;Fc=)o;%>x@34b7w^-3BX8 z0}rITG$}m?TM6zDNpqaAFY!h^(_oh8`9sGeW6J zVKy_bBm`LzsHDz;BbWMhdq!2GSJjNNxXq|9Bqaqq#TjVe84nBc2CNwvo(moRyc&(e zEOl!_(qeLIGU(8)8Dnlb7rzqID8Lb-vq=dy#qwra^oc<%;UydtMZ*l*D53+3a6oq& zlQOm^-ENM~n1{|tdYvH^HD`>jKq!WyUDcHaEk$w2dRHyus;bfjEvP>Q@(65f-A-D% zmz?XI$8nl@ur2V;H@^RkLW@x739iIHxcdIp)%H-XZM@L7bD7Px?){yOYxOLhS$C6d zo$sW+m&%SUU(UDezuz*l+A^{t=R(PR%ax_m51Tr&wp?Rh&fWLf18?hN!a?J|^>=2O zAD&t|UvT^0ns{^KzB{n$4&;P`H|tjZEZ1>*&3)!UL+A4Gn{}%Vr*iI7e^_^**yk$h z?lpFN8}oX|6>1>2>lsMjZg7q_vA6xh<6icTmxuJ7CahCs`KW2b@?R`lK3EJew}z>* zTdtIdOoXYn81NWn=R9TZMil{sXy7kIcFdw!z;|%iJ$Qzv?~#wN)jdYoiffOsrrnDc z(b~@%exeA4p&#%Q;QJNs%k=iOExn1h={j>d5zg#wkWnq20Gkyq#PycqEgr%?x#*VC zm!$;Q)-~afT9v&P_2Q3tLr=pQK$`wkISQjM0#OupN(K#x;EK9HG zJ`K=UDw>c1ou~-HiY*<3=D?6}eNY6O^av2$S(aCIHYF+aSuDa`I>=Xyj*cVu43gtO zRQy(kRTYh@`DG~%P!}=lCSaroYZ+I~st2*r6(DG2Rom8$bIXm()?E9*ntgEHQt#LS zR(F3}-!HcH69x-BJEMBXr-#RM9}8)ngX2L=w^9kb zwo+w?<*$Pv=4ro>4i@l0g|aEJZi4sB0pyVj$i)&G{cb!Y&0iRVR&2XPOCo8iVIQbc%y~DRr{EXv z`-fNk!}t9sR{bY#UCjH#fL>~HA68?U!IeO6@K~-RwB{bK^1nF&idMY2j$>=?&~KaD ze$;a#op0a!ck0SB`GF9Ct$g$N(y1zutYdlSa%-+*Xw5wg{=R#l(A5v{Y_qR-8ou5N zLpF!uUv>L)okwq8yBW^~#@F1(A9nQ=;McRmko!IE5#aDa?I%WVIcG2UgybP_QTPhlb!5GZ49J8YUhFfQ74b-UKY}|o%}J_O#h!b`SR%ubXf`& zhkh1N(-lCNFEiKKN#0#VX|*KE&io4!|7Xr!v_`nEz>E^$q9M&=I)-T*x?a(aj(56z z2A%Slv#-Q(j^?Jy8$*RJItI#TPh~6*AS|V)3ei>lb zbf;N4VlV64hpQMK0F2m9PBW8vgZ?Aj#0X~n5~OfEVGgy%Q6F(^nFEYNY`}g>kirpi z7?1+uEs&ydlHy5~ibyU3S#-kHzd60D>OF!axNHn1_9#au5o#MlBr>iqV9b z7@}uU*a|iG131BW2wFr4p1l6co&VzMuRprzIzF#zne_SN7obXgW!sLb@eGyl$mZU3 zM%rJM8`6DS1r~+MPz}&h#sS3!q5tQh|6o0R0iv3qXjH= z86iT@*+fQz8%r53C6hX*WN4ax-KaC0%D_Dho(O}^3YeiU0fR=7B#=lzbPknb&>`Jf znM4uVAUrprjR|fVRVf`)@L8ZzTi;dd#8{)^6=+aB1B(CO1~y#|&qIVTqpSYW6)o>S za=+!s(rE+LY+;#L0KY^p7WgFp=x~%FU^~|5&~;v~=P@WAhVAsyno1wZB>X z>&0w3*KuIYJ@Vfdwgr~L54;U;ANbC~js4&G=6j9Vi|@8AfA!tKgFxqxFa6b}?1_9} z;O~b%7<+&0gQM>sz15o^KK)PKKM&pu{=ENQf9{2^=ff|p4!^V-h~{Fc)j;ZPXTjI> zj_Z4_`@XJKUsrZw#ku^ax$YxtzGun>!m3Zm^&D7vaphdDE41bte<<`8;5T-Bb?mwK zPA>1x_l{K18$h~C%!^Eg@vlL$?HRr0$Y&daPk?pL-ePX5xxn!?_lZ)|y{O|RPZg}w zU_}9}3wHplYdYIQ?l_sV0q%~g71KS;S%JIL8>)xQ&qf%i^s~{E-H^H0ikW+DET;WN zI>2CBFldi6yq&$buP5BXe(Yl){c$}H^*?UmF}2uz~e=bnGAYsz8T@B zV7!IVlbAxYE+cBt;F5o+y(LOq1(y^=GPDHgu?!3*9pY8PTXz08^7Cn zd-C5fTYQ?r&VoYmy2&;NPpK*SGQ}%IxXzxJ6L8#uJ7wJ#Qx}xDoBa3NNFR5O%;aFODEPf;T}4=uuDqHI*A zF(r{nPECnyH_)eqBs_}qR)V*LdlDT{puDlFAZh5!U z?MI65rAO(vp(@_ms1lHMmSLFRkh*^+t-m5J6Wa=qF_KSEQjp zEN?iz<#@yMEzc5NBMqOD;HTukhLvG_Z=c;D5N%j3%#It54Fb`IQ)3v1y0-aANXZ3u@xKzOUbV-@SY9a?iS+1+=G+?dt&l?=o1JIw#Y8Bm+?32yD0q zn+z3z5qe@KIK)r7(f?hEW-Qb%=C zwo*nVsSMM5zIWNY>gpxK62b|D5jbu;T&nKY4%q4L`EjL~4l|5-mg_JUW*KvRyVH_f zW9+tLwGxHPYlTzd7d9wLDTmHV=pk3Bh6L}tKBvPZy1fZXQ_S1<*+fxav!L1eoXTdeYIbEs6WGcRi)AnSx#1LAfj=C?GCV(m*5e2C9jB= zVB(xe{G9aN-*@+&z0UKDpuK&bJvfXXq4;YN78eM6itd_7BIzq)&q|`GWRJ{D6xoxK z$!JB2xWcJ__OpM2ZAD5dadwRgWu#&?l59qXO`lx0`mVlz$){XctDNOdHJu-?=9?>6 z&TnrPMdDKcEgA*jGk~U*yKTr10G@VYJ1~afB_CO5{1JfAx^=?Ut+oK~#H2{}?(3L~ zuH)8Jyy_26yJtg@jS)6zWVElt%|_H3U{OSU$AnM$WLW!E9I|oX(%JB~^Ij~t!%<4V E0UWVVi2wiq literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/main_new.cpython-313.pyc b/be0/src/__pycache__/main_new.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd6491989dd1805e59f08833ce20faefb51cd701 GIT binary patch literal 2775 zcmbVO+i%-c7(b4k*ts@~lQzA9X|s(?B2{4*IMPy8!X3^g36cmN(I@zAL7FrN61lV;UgBs5F;{QGX_^4*-BcsxS* z^UXu+OPP@0d}c(38bWsd3=ndM=mdx^=z)?@3_w8RSS*P}2_zp&rC>1xAs+`za!~=r z$DvZV7=eh7alceg=o5NU zKUA5~Q!6seR}$zykG3!Gw>^Pz;ScT}<5nkxF`s;lpD({Q)a|8}$@1%a`61R-J-veQ ztsLx=dIBHy34GE|;FCGXXHQh-%V!U`Z)Vp*pPU(I6c#Fr`qb%n2r)5tSZmo*4sw-T zxwwCY1)@)5hTht*LFwv&(u@10`pgPeawFTw=4X2!Mb4X+t(l!p%c`25<=7gvJ-DiM z9Lx5!=d^R}4mfMHrk#Om)ADH5>jEuA-fmj1R-S|t-YBpWhknj3N+PdX6 zHT3RK&|KOv(T=b0)U~Q(S1H(TA@YEqFE23pP1E(xTs(&`arQ!4KX27)ExK+3MTu&< zSM4e~dGw0cHf^&(0mX_-Jl`8GxaL&5ZH$Y{B2}l2p)&FOEYzfC%WE>p?0U_6 z0VZ3vIfFGqwbJPWBqP7pTjz6qUD-!M_diY z&`K9hNo(XzP$QT43jXuo)(t@+t8@61Pl6h3ZlpE5pE)7CKj%vg-wtXuyB&vq&D0YveD5L{p#VZ(O)`;TI|VdnEPw zu}vwxA*Ba038n`j63jewvuJ!hu`JvYo|A4*E(_lZOVZuYvhba-Ec6!6j)SV5_tVRR ztzB%HUfqGVhIR2za0!#(_CiZQ5%l6qSXQ>#u?p|Ij-8iT(6hW2W%3%P7}L$7HR@Ku z@^jA=?rU_R#lpV352K?MIOE@{CokcGOKZzmBB2h@x^;yKOI1Ai_z5EV1b;icuSV$` zE6bxXITj$eG-k@u7>)t|%;;i4$`Lcj~3ju98W-Xs*vSHv!4TH&bY<&zo@XBUWBd*XMPk;h%RN%Yt_Vb58 z%eo9a7wjpF450sL=Afxvw;Ba(X@4>d1CTq|Xf!O0p%38S0>b8aN$uI10ywZcJ3!$k z>SHGU>B3wy{g~#_NCEyIgGIO=oGy+_h@t>uoWwcd`GN@~Y~iuP)=ba5Y`TsKvD|vl@CW%icwnHS4*$#w+%-8Y`!Pkzs zD;T7SoajeVn~~X#$ZS8B-i&D*F|D6G-jApIiOGKQ>3%#jm{8=vHE}RWgy_u|ZYi6| z!yC!N_sNl;NcL;>KAHcCsJG7ECt2J{kTywbgQRX9|LUdNFMV_Md(JKQi8vCkY^sYJ z>f+sVzT)KnP@Eq2$lZ5+k5i94PJZ>u?N{!TXZZbsH>N(Gx~YCLHwef=h+iWlY2dvEtOG4;iVpMAI!#*W(#5^2WI!@mH=n|c5M literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/memory_manager.cpython-313.pyc b/be0/src/__pycache__/memory_manager.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c1e520943cc0e3fc808a2aa15ded994dad4e9eba GIT binary patch literal 118 zcmey&%ge<80tBE5pjTO2mI`6;D2sdh!IKvf`9ib0Hz%#4hTMa)1J05tX(2LJ#7 literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/schemas.cpython-311.pyc b/be0/src/__pycache__/schemas.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..003bc8419e7307693a4be574fc8f3ab053ff5eee GIT binary patch literal 3779 zcmb_eJ#gE|6<+Y4;EzcC|C{u=gUkcxnx31wna&l6iSzi)nIVgm531t&J0DOF_VRH$&3j3-Ts6fRw)a7oXkz-;AAs&u^3RafOb5Y(@)$plz@*nRKq?t5>) zUEnXNluY2-|JUCu|40$?Z)|j~ZohKzRwCqELJ5@&Qk8Zj3F`qPPz~+`tD&8cL;~bF zp}{{88e-C)gM|DQp7ym9_H<#;MegV#o-PWy*d1Ne)5Sq2-_gZ9T>^B;JG!{1OMxzZ zM<>%Rn(^)|?WS4U19$Iv%#-w;2WUUd=n~EBNpt{OXgT`n@xB5@wz9+ta=WGw}Y*uj4HD{ob>lEDMsn zH|qmvKRi$A(oTZTZIS%^e{do4l88T1ZC0`%WZ{wrv!J=21uE?T6Ij^i0@kian}%o|cGw{Uk3n}4wd>WY%8%Cb zk=t>i{#vlNLL5dd0_Mcd1KbEPJJfjg$Hiu72+D`hkekXh)*5Rk@HE!iabe$2$Ay~f zab5A727uK;ktmwGR%gXzf2kCmu5&ywZ!g9e$`EjAY$U@CPcuD@jmE|aJdKUEXK4FB zdM0nu-Hv0iUEN@~cRuY_4rbv9eem3ME1hkW8l@9>8l|>dXnVsgq#s`YukDa3vzo;( z`GJ1%et>m8fYnW|R*icpCskqVIWmQCYIIdX<@uvdyYBLv@&lz!9RGd&gM{ zt=#MjxaLcx`K6`&ajvb(&9a5Pg}Ge8G~wi#%l)>la))u_D1Y47vA~?>ES}?*!SeZ- zNMB8)I|dVp%XV=|M1RLPZg7!)y1UCXM}NhXCG;gzX0LR1pgiMvIGWm#$gIQeP<*jx z6W%y585fB{4Gzv%j4Dr#;DC;9)_KjcnFw!bR*i`eI9LUFVu|hQrtY1LB3972Wgb;+ zJkilpk@gQ<{TtoToulJaC!Tg3A(1GzPh|ZY45FZER^4<|Y{X&f+I7QWRK$?`yvit? z?(%bRikqsbF(H>!6AmoQw@7T%823(S)exa_VX+9CCJmngjnO)1$~q(vy|p#2*U()d z5D((V0>WleGx6e5f7`E4~xv2zRwK3Vp&w#(p2y;S*+Gn&FQqt zy1fr|3s+mDycXn;IXWc1cN?<7(P?G70y(Kz2DKrY(3-Vpi)fpKwg|0m77@!un?D9l zfnQmO5tw7~qiwE2reSRGud{sVZI$mar%c<`b6ZgB-sS@Hc^!k1keJ6FzF-TzA_7oYVJA)d5!6q6?{y zdu17hhv4%m5m{y`g&mOfDX$bvx}|umAH2qP6e}IVi{UaXua-cVx;PxaW@l)XhC{bC2D=Id|mqRyN)vw+KQ%8O*tT zW36y-93%)b8Jl{&+#H#AhbGQsP|TnLKqk|3Zx5Tp%TU~@hnLmqxp%SVND<1%dkZd< zu{n42VJjZ)OScF@T)q!OAPkb+?Aus#^Y_i<3n;B%U|Md)$jH>+2H#bh$qgtV8%5dV zgV(Yxr4dWV{rX+#RHgc3bY7m=nT%g_hw!nHphR` ziiRdYgODU3&ds`?|Kk16n^W6RKskv@fFybR%lCuLi82(l@T-u40kA-j$;`Y9IfW4> zAHaktM@W2D7>#UGhai2XmaURIE?8=SPiU7; H_b>2Yc^nlK literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/staff_profile_domain.cpython-311.pyc b/be0/src/__pycache__/staff_profile_domain.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5b3ba384b9f10bfc70699d0c979f0ab74463d7df GIT binary patch literal 6425 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 Ta$W1h3ECqZ+>ECH#-INMQgZd! literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/staff_profile_domain.cpython-313.pyc b/be0/src/__pycache__/staff_profile_domain.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ae156bd456a5d92499ab6f2b5e69d98e33d559d GIT binary patch literal 5993 zcmaJFZEzFEbx+^6WXZNLHsFu7F$RGV1oP#Ox-r-Un_wKDO+pM@&eGWuNGEmg1hGRp zW}2BmnGVo~0n*IGG|7;(%}kQ%Oj|si>BODC~!Qcv7hj>H~&+(=M+<=LgFddr@ zn2DJ^EyMy(%K__vl~@OC#KzXG2kZk5;ut6)B?G0Tl+D==I0szBHBd&%Fgj`;t}Q%N zA2%W1zJt@p#B;Q~2|a~)$4M?fSHYJYt>jDB0|@WsovYCa3-R(Rco!S3;H&sDHmc%R z@@_U-$yf6pHma^iOMiR~U(RN0_*Hxb8?6HDN;XU72Nv7XH@Zr$9z`PeIMt!>+0ZSHo#iJCnjhgBgQ)a<8#X4pk5IT0Hd z)UXDMx+UfoFQMPS?fuOO~W%#XUe^ zY@kop@=Q7muLVrXEhDrXYnB+7|8yrpV|hmLc?*`fN#rvPCI(=?gY)lTIW+$(87qr( zALEmu`QOQtIJ7wTP8>%T=U&Gf+P<3kMcX!fa{f#VyC;&4iMwXx z+DIA!AwVT+v^-nif*H!zVPmD>IsQY0M!Dfayxgk6RuLq;Jj4^V(llzy??44(Hx7MR zFq2>nR5&lf@?Ma`&t&|U%b(ql@u|hR3s@yF5KmB)LpZuP_g(;pVvDza9{>?9-uf_( z)%jn`x|D)3{MF1GhCGAwe+0Au>m&2G0)C%`01b##&~6Rw)ko0mOl2F3n7pPeL@KbuBqw;aSe&+0%l)YhgPo}vY{@%NUDH%vWp}M;T}RU1@TlC%U}qqOyZ0~9n8(Qtq`8dD zVAcZIMQt?i9mIzuF`74#LmbZmuA>MMUSU=b+Yc?lEcNt7ZOmg#z&eEZ;%0cdhu|uU zp}@0IOMwQvtk6e=eO{}o3bkr^SYxN$_pL14M;`-T&7-rt$?uXugNLcE6g9)|deLF? zVT)qkiWHL_iwTX4xW}n*q}pPk=T~ zw?7{Wt5PJas92H3xDtw~EaHoaAOuwDlq9Q)e_VnjK-wZssE`J17~l^L?(z8teJ-!eg`QC5JY}}XHxG%l#nT6^#*Y>(#8qi8WLqLKM0|DF=R11P0p>C6P`(dDTLr48*$=PYh8{ql+ z)Y++&y(a6axHx@&`rVqFwKr*a=tA&-VxK2*E=Q=I)$!SF**naMP0`v+GU>L$ST;3 zCKi*D>a%Dj|5maMW;82v3z|)L5(;Gq#xpZ|)=|8svT8`Q51oZ-d-q=~t=N*9BY)#amu2_SZmE8h zos6lB6pBxA=GeKE6fL%vcyr7fr)AM=`f&|o{!lrk0(K0 zv>YC9?sx$qu@_t^w_I*x>?^`xjM_VE%flKp4(Br;1AeJKGxjU`YKwsjbS|4M@#xvY zx~?58nNqw=zO+>RiD01^jnOdA+Vb#EqSl~_w|2Y+4$M4iehmFK1CsHXs%lX)@E+mg zGO)&8Pom7&uZWNN%NthaIR+EEfR4S3w{FTf3dJ6jd6x?-K9zj-(c;`M15hf2p`x4n zFpOWGzY)OEupIIyXmOUSLTD`l^~U^c6mOvgBD~O&#IIS|Jwk#S(V$+2$cuB=!#J1# z1E;$N^fKnt-y8_R&Y#Zt33Vz7nhQQ!;RIL#s!|e`scn`@A%CKN=|FnI2aKNgpvs{Y zEZx8lIUoigJ$kL9BT8gJO30ErR{rE8nr4njGNEb?HIk2l)5MKXPp>&bq9V}bEf+_O zY^^?58U@gjEN5dkDGI&qA%lP%f}ip&bTeq7v|-kC(|yC8F5NP-_Y0?+)vm*7 zH=lIy3zphz{B29)9cNj_*$Dq^u><^h($NW7LB?K}ve(U4r|lacGst*a;QzOQkD4;u z`cvEb)Bb^se<EP6zfPdAV7vWMXw); zjc#M7or6x-zzvl@44N2*Yr9jD5FGbM;odKz5XL3S3e8T{5iUc?TKZO!zpi5iiRKZ6 zf?H4^0Rxn7AFXsmiw!+PrMU)+l}wL6Kunf;e06lesfmcxO^yQ)O&pZ-(A_n29H$}v zAE@bTRPuM^{1UDI68Tcd_fOP$XJyUR6F)zZY}}Dvxig8pi<`Fp$5dvu-7|YyO-WR9 z&3_MhIMe-R3s-gDZQ&+4+P?yQId_1&-(uxf-z|4>U0GM%T{ArIIBV}(;R)rCt@OSf RM)&J&T*J4OUQSPb{tx|-9aR7T literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/structure_analysis.cpython-311.pyc b/be0/src/__pycache__/structure_analysis.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..608a291f4a7e5b6bddf3baeff16adc2f9cc4b96b GIT binary patch literal 3150 zcmb6bU1%FediGCRDRN>*wqse1yEKMIj*0VQ;>0yRH+I@fn(URje=%1URlBmcmUfky zahw?WYG_K;;zAR0INVXHmXfB;LH*e0_MtC*u#CX&fFW>Cep7J|a)E>UX7y(&4cD99 z{buI-=KE&8-`THPT7rnT{J-t@{0RNin^b^O>32q9u!2lv3Mn*I?u97<=3+`rOH-17 zMD!vur5nhUE%~m5(0%yIZAvkPNfcAI`BaYyOnxOkYb7dFuM;bion9EMAPaHXQ=;iJ z#T&wu#N|%Orr%UxR+gr+wn>WT5_Aq*&pYwd^#u#-iK-kfPKJ-4VMhnBg0eyu889lh zX#_K&I-^xR0bvTJ_z&qWNV;E>25W2Dm~lS?l_|i%o(#3kz7+D2^wni-B7vRw{}20G{w2HSJN)Ks=KfAUH8VsyZ5R zt|YO^(ad$G8iwuI)G!vhA2Y(yDh+&z$YB6WXtVRFoV>hev#T4rmaWLadu^*%)*@#L zku$}}8Nlt$!LtKvQG*HjS^2k<0stmN??7K}`I_;n7~W@vyMVu6qX~^XHG}TvxeJr~ zHN>Kr6(`Zxf*zA_3t(Ay#Z*Ssf<_X%X@dfq?Vq-&f#c4sWg3|o948h5w!NH9<_mBL zhJ+lpDKn=<3o&J3ISBFQwQ)E7w(VG^1__Pbcw%N) zdyQDy%YT~GXncCwcBZwYi?yVkq88?}xMONbY+aeNoWymH#^W0(HIc8**hGWZO;XFz z=!~VMT|zZ0nY0qrz6w)|&bVf6J@Z3hO4(#4u`9R`55F*cJMDn|tGEJ6WzkzV{tRKY zDDWiA*X^)tWTHlAk-jIcYzh$4@x+x)2F7Dq;kV06V@lxutni_HRltXL*YD;aF{N34 z6QFP152gIB6sjxL%yCm(exKKPCh9s9FI-^a>u0xlx&bOkXdF{AZ$qW`+a#WH=F&`p z($4}ZE1iy0d%=n+Ogwsw$-Le(DdQ3rtkE!+;ARZ(E+E_q5GG4YUTu059051cgqzNc zF8twtS0w{hdck#cB;_XJDKZL!s(=`XCV2o4p=Kdp*WNn#dEoRtx;nYpeqcqvrRN6= z?T3r)hf4}9deA@ct^SSv-C&`Atk^$R#!bBVqyD|VHu*+j;?3g3n;sI%U`J%-;;oA} z_1k*M&*iIt$p_ck`{3PbKX6CBdH(kKym0H%CznbA)E3=n?Oku}&A(b`?JKtS@#W8a z0yM8YF7%wk81le&w5n2Cr3l-uGpy;c7S{F#*i*OY#j2e#YvE=zs5@&pV0WgZ+<6N# zKbYKr>|zqJC<}0Vd*)_Jx$}>^a}J~%+rtxwTQTiA-mT%?7c6k@TEfau*qA99NJ<(D zU|uJf!ae6OpSK}lYGof%W(!p=D7c@jD@?HPv%rJ-1x$El^~!;!jg6J>p-A%`Q}gD+ zk^jT>yjqd}0C>p#9E=7!w<3LoNPjWXpS$ocAL=}mAN*?gi{Z~lz8ooZ94mGl%e?^T zuBY!@z4`9#cXNM)N%z4|FMnqI&A4~DaB!%2aHtSDU5uR0T_}kSVN(yF&5?Z)c$k~scI+QK6wj3 qNfHEM69tyM=K{&ALmB}A}A1rO`r;$po-_j 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 literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/utils.cpython-313.pyc b/be0/src/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..58984178668e4d3c0ad5b260dc72c9035763ba3d GIT binary patch literal 18963 zcmbt+d2k!onP)fdiv&sVmMFGJU7$qLx+RL1%tHqyk&+3)QbH*(2!JFk5TLsONkrnc z;+dULGs#Nw#3gEzS<#hLnVy-NWwy3v=$hJ!EV*iwsniT;FecO1&bkuKPUW91XsjfT z{m=fs*9Sm|wv(Ec(Ea-L>v#40e&6}{aY>1TqhscG(Vup3+%MUUdMZ(`AGaAe?hVez z`S=r@s9brGXZHrtfUDtz@wiDe9XE^S;}+3!+$vg++eDk7l(ma?R@Nap*wraIaW$TB z9WN0}1g@5A;(Vrh&S&0@cIXgGeU|g)^KLWJy9_>SJ@=AHEc4mUbAwH+z>1U4e!ha0 zFJVeK z%U85-^Hr$t)%rZBi+V?;Eaxrg|Be-1XE*0f=PNN_taPKmxGZ<};~@SVXp+nn*?y@nB3g`{PJ> zdD(P2m>dfgPL8rJIqu`*$bEx5&(Vs*YU4$t&mfw7#`9*M2|qK&Vpaa_Qs&U&v#@^H zeAe@J^vcHW96r0~9Cdh|veg#}QTxAgqULnZU}Qp!NRfC_5`scF5lW3CEl5fk;c_%N zCL|{(BHM&eBA&#fZ9*^}7KRh4csLp#MdC`E>{5#dsHALFOUkBUJPnx&hD9sfh~LLs zaC(C~R~FMBo)cocc608Tm`V#AH_CbWfwzsaiRu=KD8*8oye8QsMPehOi*odAiCPnI zpig3`J(Y~cq_&Ak*%k;y@NN5Y&AM;9mMd$&WB(YL zoNN&z$&?rm>AgcHwZg`sd>ubGNJWbdd#mqe-*U~GTi>|(4UB{C9$QUijvQyQ zJH%>~@*3G3D$_j)y7IRiE=s+u${N%Z!cRY_o9yd_F}H7U>$oBP(IDj)9lO>0I5(t!xj}1DA>8_S zKUd1_H0)Ma3h>|H=V%5q9_|HRGPHA&!NVOx ztBG=-k={OS=tqrCPT=B(IPW)XDC`~0VA0_-<@?pnZ9shjesO+;Th9#<5wXL{1vhY9 z+*mMDxArW5^_l&~l_RD8HF7>n+}MS6tXxM>_p`=EZiM$*)1!`~k)#k5VgN!RF(L#n z1*5Uxa4aHVsYPOvFd`<#g;TLua6H)Na11hwY zN*!9Yz_y7*T#9rEvPEf0HnVHGX&|8$8X+_jqH(o-L3=5^?yx8(L}4V742==mt*E<0 z81UL;ODGlvEXpPtnQVzAMn@x}Y>LK560##IMdMiZ@eok)a!`z;4%vSAN+`lOfNYLX z^?)+B(zSrru(%16Ejw8*qe@BUBRaMzU{E<4Ne1+3B#Ol}io{{-@sr-d=_;2ib(t$3 z)(dyG-roAX)+zTxTj_P@HD{)}ZT8hn+o4S5bBngFkDQ(xuV$T_{<>o>ad&KC&#A@d z{YzV4SlIf)^4=qNue`tabhiF^oM$65HQztDu=n)TiA??T%bs-^*Sbfpit7W{1~O|7 zExMjt_Eb&v{qEoG+?qlDlP3<&RrN7vK>KLabD+0?`5y1+Za3a@)^)cS?`_~I-D1XBHcbRa zBeGo@OI!{lBUh3kov2VJksKrR?=nJf2dMylRr16${qXS`am0;&BM9wpKm;bgNg-69 zvEv-ag;8@qm_?p!R|E1U4sV%!W)r8^Xhx2iNOl+}Eq#gyky!ke)qq3;91RXw%O?sU z#AiJh86SXfiFE1|jUoW&lidg7F{OaOD}#dYN2Y`LWT`h{y3ruSt_He5aO%kr8j^Rlz-M)|dUH(vhEvkyHrxAwojf7(B@ zW2S5RD_PIxsov$L&9nTQTNax3+=xtfy*@VIJ$3XWclGsGuDx>o)jxanVg34&Y$`5*>}#~4gbUFyQ2@1|9tr;moq!hX6w)0ur8O?-g4h`&sZ1Bw)~rC?T!5; zQ4HK1nAtTudDnPjAY0k{xQeT&&DCfMfbL*)(B3FeU#!Ur0i@Vl=PWcZ_p#WxhkL3NF{DLweQRNSMYol!u0F#~hP!-(Trz~61@mcuF;N5?_1 zLO7TV3PDL2o`jsI@OGV;&%6zVXCxJiDdf5j4a>%f_~<}--4O@{k+6_V2r5+w`RY0Z z2&oRDU6v=w##kgScG26G)6pyA!3o(i5fq6R^Ksc6j7^LMNn&PQ00>BfN4v#V6c@d8 z+J=+Yu2AtQs>DW%mN9XbVhr4LMU0G~bI~ixV;UA%PV~xl5h5KY5rbjGoF{0HPSC8I z0eVFoM!9Z!VG$>AWmARu*=11dp__+hO*al@Jgrls&8$5ed~1i&c)joXLn^Qo|)>&S-3LK_0+Z04e8e8&B+C~Fta`D z-mtu8-JJus56q;pYg%vG9zhTI`s-i+;jXtj-t3r_vNbzzn3mTx-s!yEIcv_YX}e*A zrf|dbQAPDs4_cc#m22Q!8*@#ZtL&2}X2!vJ^RvXkORtq?>h{j_b5f?}z>=*qW9wXY zy1~Nn{8OnG1ADD|NB3Ut-d;yfv++J(+q2Gif1Md=@kKmjBFzdoAQ76u!EqH1d^}7k z#G?&^1o1@&5wJ#1gGBp9hsHJI00e-64grJok%|sK?*n)D8{2uGxsA@2{RSd`jhh8Q z5Fh|E6W*a9`E1}|R%(>x07kTXpN(=MeCxn5s2Pq5+Vt7Ao(tTl1H`EFW0e>!U-G`w zZ+VIs4RK4CWz^+k*8*ZJNq3wU6QPJCsno-i_^!S}vSQ2@l5G%Xh{-hQH5wchgX2;g zqsf7w4WC4#=wK|SFlQ#BC&!{%y^$;6Td_%^^C)C{@W`MjwF$imA)dg!QuQ_=9FaoM z_+UangHad}L|7ca@5dra|JsC8anzDXB_~o!wipS9nQp+kIhjbo9)Z`Hp+dYwf;hdL z5Tm0}G$M>7#Bpeqlux#aSP*ZZeMuNkg~sSQk&vV)bd5HlZ-ibI(Xxb2vSDpg#r?2i z>}VQ-UclOATET^;HrbStBBB?vrkIE+CJ5Q0V360KNa{A|9uja*lC5frG5+*3eep}d zSTw9_6lz}!wH@ISw1h-l(M8hrLwy9G9YRkc6$=+mgy2F zwc6WVH2D$+Il>V20x2rPK`F9lmuw&2vnvu0L0yn7;Rs#D%~-0kDKrMPond1e?io~i zt$ErNqg2X4D5=(DK~KgflCn)#TNo%3U8|s|$T-yjwaW@}2WUM8f>Ch-1;2uy^!GRs zDmIu~KD1Q<*Q(bpRc(B~YU9jUwyJ%pYR^K|o@~{=DF+@^yl9%TJh5<<^|ub(Jg{6* zgS>|3<;IQ6jo#@P3?1baIisbzG-u{28*`k!(lKTGO*Mp*Y5UB%Y;EhDcmB!)`@*5Y z?2bW#*S-bMrkTkF&-RRKJ1H|Kubs@)omh08T=uko^2k|98cd6>!6^7wbzsrf`OxiU z`b*2F(mM42wXTM)J>0uO6`l7ux|@vmxZ3UpBqx~)q$FL(hYItL9G5~36E841YZbusTQpE2i9&}yh@1dWo8O@4 z9E9reg>VWmg(Z@BYbkX9okp!*fg92-5$E#qi`{S6>xQH=qywg3hsn4D@arJRgPfvE zRzBOgZ>^@ZKpxVi8+1ZFkgH2LS{{JgI!Gq4qN70Var%veWU?wc{LcKS@@h{I3KG7~ znxewE^;=2d@j02w^8l(NzDNI0Rn-SJ5-@?a+FM`(&5+1k1+(0!J60ya>ZKBytMvg{Pl$!k>%EEB3N8UU;=N)1lh&WO*W@w8RJlnd$d)5X9e z5;sgc^*7qu($y_3N_jzppbIT64u@=JeSqPSIJP+v{s~NZV>amCQ%`?Y7sBgViI%UrpD(zJe8<#wr2(+ZEXX{k& zzqmci_02b|zX2pRKLjeCxOpPev~#ZSF8`zcxkGoiW`u#o%2N*;cg*d|Hul^&_AfP! z4>xtrpUZAKGVNU6&^bRe-=AqZK5cyjeQ@*a-oHEhqqFnjACJB>dN=tGm*2gdIelj7 z^x1{eXEVFbW$VAPQYHMqo^`7zghoK`;|8v*^0Ad$)0S&vs^KS3>|9wL;M89AP&FDg z?3sIcE|gi*vFJMRzg?9Cr}ipEmi$2v*VAga-|FbCH{ah|+goLRugZ+HY?}y=1SZH` zQV5y^GYmAJsIue`GL+-XWXZVk8Q6M;bbuTp{>2Wz4rChX16HAWLQ*uYDpi`ba&Ze_ z+gbpAL0!qT<>Bt!FiE`ojdAhn#m)%#plLrZaDE5O!A{*A4A4vb{b0G$ z+rqOZ3&ThZ2qYshOuf!?hl$bW6~WakH8Aa(eHQ8spxjz87dCBd$hQ^b>yV9n`_2N} zQ2#BZ<#W4s!P29eo1M(utOGEB54;n=r^EMZ(Cc#)17C#mISKd*;La7C#HV+t7{vrc z(l`rLNzDi&Y$5S(n<6|(+k_;Env$37za)5)T$KOATVN;hb_nFK zqsmDTrFKT3xVABCwQhotdzy7=8+BP%-=W ztV1bXY&d8B#vw9twe_l=y=?+HB!?h|Z&U2*bbH|fOzUSb5|sr&1*b9T-awM7;T%Ls z@>`0K)km0JPID8&?tTRBDuT@wZZe`svLJp82?C-F7$vU7hmR-H`7{C|Jp z?HA^{-u}uw|6|KLmid?8u`g`vW_B#pZ@d_pOq)-WNkd_FwhrM4`)X;csy(FLsu-@V zuXOcOayn*{*jR~Tt}rEFO_a@IFb-FNAKj4caG8aHYPcuUAL9)0ZzjYf^PUxOH=<0D z2`#vMky5sH^7k(2d-~m%b2Vqd zOpAYp`u{6_l9h3^TBG{B>(M`rPTR7j>!*y%?c1kH9@RE{KQz<)y|Mr1CL2ff zt!HmOJ5!tWw7_OrQ#W-C;(f#RxytO?uBrZq4clhVWKR1R8eW(hfULjPyRc^e)JddD z>Xuv$3;5sEouNd}V++^E`}yo9|3A9s&%X8Qo3DPbdEZZ53!D5?{R^&UlI~AjJ2Aa+ z#x}eCP1p2)nmzYFL-VcKJ;yS;`ySLhtZ$f(XX{%tb#1eWoRP0P^c#*Zd5(YF%++nl zZGo*N<6fV!t^eJ>JGsp-z`s$d`Zr2-?VxowI1B&A_C?!{Wn0N-(=x)rt)TfT&rYU0k7>)Z^! zjX_<+*Gix^vyX=fm;4(g`CKjK(~Y^{kwnPsSWU2p%}3`btN9`{J{Py0d(CfT+)Wqx zS2b#qx!9y|w?ap4|Mk320)jaiv)G{u=Jo@Q4tv8 zIv8z=-=>F72CPbZ;<`4QWlnPJ*<^y52XK|b|nP%WXDz6!+Y5{37g|&Sbh2FzY z`X`)V7OCbuRaZ~sOl9T+4?iC`ftl6yw}xIHnyDvK$&qDPjN5Ov&sN`Pboy0v$l zx1BSGv$ZW#eGlszm+HLl*Li2X*}7dzbq5ye4rJ?|ojSg}p>5i>;99R4L)Ji7c)-7N zetrOUkq=xaAJ-8c!a!V;F;#!^#H!ke59lbUd3l#?9T{84N6u=L`z&YW%?A|G{*Svi zc5mhGZFTh28t?6{?eQ4zd(23$5FK=&y99-?C`N-+$f5%=bP%O|#{C8ZhiC|XHP>w1 z0LLu;Y2XEMnp@5NdRB)gR)Xv?MTZ7Eh4u}HKpU@4HjlC7JjpmbPQmz<4EKm51HoZqE&qVP(5Q#A_@a4r4u*iQCpOd`o z!LV#dNU|jrM_AfL*?P*y8dHR3vh5V3I|*b3ux?dEE<3f4@HRA7u6=A$ADFR0=11g; z6}gIAu~eDdfXbv5lqUm%it-A?d#Ky66ou7tRIT_HO8qN-l9OjJUs-Q1eN%d_J>g@B^0*vNWE3_N447d08?4RCO%c4iw1C#EW06 z>Mi4LmGoGQKirMfyLII~HO6;aY?R(v-Lr+eXYWM%zQxf~X}w=&rF4y>XQTE0dY0bO z)LUYF&tX9RdoG^RB}Ph@IePaQ->a|d-D!Mprx|In1FfwPu}L^K;`i}a@m-kGifi$q z;kb^3a2OT@2CX7s=R=H$tB_Z|pggUZq64vm{7S?d(uMI=MQ%hN68|GE=QogTHHcn- zE3v3xI4E_lxf)*r?@~vp^sdN7xq%diFR8&&924k=(FZXDRy8J&T?^3Fmi{M)UsAvo zk-QKz0j`Asc7%h9YmdPgGn2q>kr4WW;>B>{a(o4}vd5}%hU8y%uh*bRz$`{iv*1u6 zB?G14;bX#Y-j!GkxqwYYuvUwpvsyn`TL?XDIt*{vq|!39;!Dvm3X$NNuQk2)NZwh= zy2zZftRDz)P~3@P9A8gN;FNCuJsgPXW(8dHttee!H7gU!LKVdxs+L4s7W^PG7~G6o ziilFAZf03fJbxxe8O*)Ox}rdv3B?K86kMxTnp8rUm@kXPZ;F43QiN*K50GFItgGyL z$F+{@o!2_GP=Xrrp)I>TQ_h^pZLXwj7MQqWZui{gOeJB$Lr)z=NWjMjqc{2Uni2g= z{=6JQ3#+GVv(EM8!8^X-*_?50*5uW?gNv?1cwy><=DuTm1<4(Z(Kpg2QIUH3-gy+)qW8_hV&P9?mPg=DS}S9Q=h%|PSJ ztj~EcV8ZGua8Xc$?s`Kf)gw==g<-{7?rQL$0oH27uRUXt7rGFtwZ(C(S(qVgGjSV& zn_h4=CtMRZRHYTj2XRq^B%sO&K^?$ZeB^Br03#l@Kg)nzieQ6r?4AfHU*HVH7ON8N zBgwYN6~x{K5*KCuG89S^Q4?Z?tW*>dhHQt1byA7TMW|l#Bw1BtbMN79|51@JRdz(K zOn^I+n_srVnVpDTiil_FiFy2DI4a6cl0lB42>kz|z+MT4!)k`tB>ok$6s6IIMiq&{ zFfvvYvFo8N2+?2+dUPPD4n+K4cy%k$$~Td?$~`QpTq;@je#yE_V|%vbnX5-&=rTLM zb?Td^zI*gm|IPkIQ_~~2=f;VwTUc^$U2t#Bx?7jryBFNMv+lhJ5;VD}?2@T&!BjW1 zVXiVmsXdSEWlQ$8@7vdY-#1hHy$f@m1^e!+-ODD&lBsIJRCQx`(NwqWEWg^%?4B!D zHnBqbeM}1j+n`vGyU(zbwksG1f@J7pW^Y1;E|@Li2;IU)4a8rh!qH?xr0|NMB8W1) zqpbrFP!Y!+M@WYnBmzV7crcFL0YYdj6~8EL)2k#=5usRQJqKQg5N{BRQarUFVt)cG z6Y)H6NRB07P#+zOO)6+lJyPPxg#-ndD3KV)eWwOk90&z?QQQc{ULa6nJc<~R_~YU0z|@OL3Mh!`r;I;at4M}B}ARtN|ixX)Jy1zlPCLyGePO1>PVoHt#BWR z#QN|)#itaHUZ0jIYlh~4Mt=r7VANP^g(S!((~9=+VKHmlgs#}-;3Rqhzrdx4 zKHwv4T04YJX;Okr%_8#;3Z0BLcoCkT(3(^!6PQI`!puP@o$1_4J3&F=`7!Z=C?5 zAOs2v3c+L`B?OdV%f@gt7KJQFv2JlV!cxQVm$CMW<{k3@MLmX~wm7Ex5+Dh4Jr4R9 zMU(;p&uGgWRSAt%A`363|57v*S#5%MwFy3Kk&67GtF-UrVUQxDK_Q${){C}IRbr#9 zSCQ40E8fKn($oLgYNdLVwg3kdUKb}{VmQS-hEFY4ouJ2pVwkOK0y2G(uUwHytk6&b z3>iEFemp{+Rf&8z;t17X7U4)Fh&_9e-*b6_*am3|u@WYQ%VPkD5EvK%&WKWvGNwqJ zR%^JOg(1e1PpwW?jnJvS^BD;1V>3! z(l&C^L;%sT6oJ$%EOb0Vpc$1A@7d(g$Dt?ov?2PCYPj-66NnJWyS)HvPkP1^)5y#r|={005ZSM-$F46zaZdNQ*Vy6RoSsb~x?Zx%r+ z{uod9kq}JAYAB2rOPM|A;vA(@mK?XiZO@goxLi4|#q4@oK-E13!mAaGVTK|@qciU4&G0STT@3*$_> zb|^+Qd=CAfNoJ8CzhECJ@3GFu^v8K0HvOzHi|L<6uZIj;FpNFJGgB8iSynNfY5FX* z$5rFBwCA+LZ#7++v{TJV#5M)3=EJt+%dDnT`>X}e-TD4&DZLLCj5u%DTP2oj#geeH zz|nkpJ#I>?m*e07!QvEX&w`~LGZnWs!i&CvD{z+Rt`?Nt2-BWzNY}#Q7%AHJ<8usA zs0=&&x;y9@Y)Xc45ASx_;Nxc30^^=@Al*EcOipyPx3l1zv4oWD*tP4K9qo`qpsF)F zQD8f>bdk#bl9Y(cHpoZ;N{Rc?TH5;Ji!ah$+K7bgDg>f*jbid*kj(Tpw!k|CEi{Gz zh2qi`od8>Q(7s#-#X*V$ASbiE=qX9ON5zaV50KhF5etIv_fVFZ;&CL|P0P8L#mFlz z1tMG7JOLRi$;<%QKt#aCh9MhMVvL!}WFs6gA}t#HEd+pZJQ8MGlc0ntRx|<+0#|k@ zrnGYKdGw9^4cODqreiDUmhB8=nYhUZGCfsz9AqW^KU50~LV3eMM^#w(ecZiL>rDwkXv7hD?`U7MFH>Tk8)Z2j|gwy~(D z{?_Qt(dn0Fo3hodQ%9Gp8*U|TCT5~@71`=tOVynV)t&SHyWQF9qf6EO3)TJE>Qhrk zu~WZf_rtn|JN38gXO3p;w%xG6T~S#-)lY8X%GxE*W|->WDk#~#T)$;@@2oddx9g_m zBTwB_Km5ZbyFbI8VI@R#<3m?@rgH0S*X+(rMf;-bndQnguw9gY?}dzK!_QnB$Z%)h z_^H&5Az$y>*L{e)XRGRNET)>wCP$`^`M1 zy=I)#WvBC@%?u%xC<7yy8f>!U*{44PAwkl?jtGHn?We;dgD`Bs9+aYcGrcCsF72BU z=!yvUR)USOT{KPU1uBY~=smfN0<8*HwaiDAC<3;ovM4JNrK{^iwYipQEoiGklu>4s zzW)>wV4kiT^Tr<wfI!D!1k;IM>>YX^k2{mv+5;ITGK|w);}7WACt+r!YYZ z`S$YZiZ(#QsMOwrZ71D{WE*b0O)N}9@z>o!G1-E@`T+%4G~z}y(TRA1NPJo(e2`}s;Fjf4JR0<94&O`$MkuU%A@fno4-bV-Ba>IuBnl9iG{FJNkq}GS^bhS57DI zOx~W%aY)XcVVC*v-M#OOKcSr5rYgP+y9A@Rqd5-AxgK_zKX%vu&Z#Gq^SG^%Z<+Du zI9%w@Y2fmp=ht+3+|92=^&9SZZ+mkbl5-o_^&Ab#}h$J#ECKiS!GAAb%j=cPY$~`{A zH}W;pm3JC$H&8!n<_@#V-QEYD2cZYG?+#G6YI5ED2DEhM&V}0-APl0l)SQ35XYQ*D z?aw`-``k%>9bY-)$#J;M`q*X8_k`{qKgT=yiW%RpIb3q}t-R}ol;d!jmY&ch_ceYi Pzjj*sHB7K;*_ZqOz*LQ4 literal 0 HcmV?d00001 diff --git a/be0/src/__pycache__/utils.cpython-38.pyc b/be0/src/__pycache__/utils.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..241e0d325d992421d23b5fd25ddbf0e540ddf3e0 GIT binary patch literal 2789 zcmZ`*OOG4J5$^7Ja5&_0SF5$8^>b{%2gc5>BiRw4IF2H(Y#$85u;oVvFa{%bFL$)_ zQg?G7#Lx$854q?UcnwI${T==nea%Vd!qcTK>20s z=FWA{{(+72r3H=KP}RRdaKdR$;@OwR6xu9jg%djz`<&b@yx1%J*tcz(x8jyv6U1%% z4dc*$J8?&Jo^-kUlu}|f4>cI`^QEE}_vY)D4{q z9o{|i9?|%+SQDODTPJ+!nDHK8K62xi_-kT~UxHby{PK|(U*WIAEco#&#}2>BuR-5c z{)X-IQEsz!BF?lmzJBcSm-#C&?;59Hk$&_H5Fmp-HA|n}yZ6Q2gQ2K(R+W8b+|M(m zjk}c%b=+3Es`o3&RqWN{a#zCw_seuw3~~AS!k}>*s(KZKAP8s7IOXh!#14133tyLe zVA?nBt#MHtev;*4h|3lQbqe)ksLBPw$yelr4o(Pqr9{4M=z$Diz>4RWHq*M^RC2gE z)>*DL>OL_<`h$pbp2nmr<#B*4cvaN35VUiz~Z0&EZ5cZ{PB zmGPD4)mU3&eJ0U^xTDfNk<16qe6?T%Wi=bPf`;)(gt^oQf1xt@610Ik@8MX;L*q-K z$FjWOxC1kgYRmasplG<2oN?zdo6?3pp$);i1R8rvrmSHQK9qo!k z8>Z2=tcpnQhzM~}Da4bwT^b_AuYvx2 zbz?KHMr!lpomAhc^aeD5^$vVSLfWwU{{UWx4K#T|IeiMuBWMr<4gv^Yw}Av_fn_k_ zHEaef0y>C$;vg+*2-BGzL-+BfL+A>)uPyA%%8_Y<|DRh&OrSs&dm=a9*4;Z_e`-2- zpFc}Wo(pNhl=Ha`_`Csb8Gp8O%(i!4gynKpW;#prY$B3$?g0j|ve}%5PS@!HLb8we zbn-kc(i9I3q+?zTL+alkuP2I+6T<${}ZmpA>Wa>P8t8u=C`{EMKc0*ZHGW95#T+t2>c)Rg_ zB0n=8K4w4CZ^;Lcx~AR_-go40q-5XOMAq*Nj5}6Bp0L=JRW3|xSe06o`re5n-vz%+ zPza^ck+AnrinNgLV0UZYE#HIIvwuM-KY!(}l$DG|q7*XKKusits;g3oThR$K9i>w} zR>=^MF+LYM&GHl1_?5*jc83sKrhS_9rM(!wbwd6gjK~{kcWJSwjeX+{ci;suRJ=&v zrMVj-SuFSfmaA(Z2xZiT8dCNj-*X|zSD8;&sJsnheecX7L=41@PO@^l0xDCP7HUQ% zW`{+=e0)mf-=Q%X0Ez4;2<*lI>HO?X-8s1~(C1llecl3cJswQ`#H=% zM{q}?X!79Q1$_c~qI5VM1C=7OL2;lXU7d1jx0!|aMs&dnucETjQ2{SAPd z*4Vk3as!}}n;`DVKcI?XCErJZAtjM6`9~BtQQQL2Z=Z1^|BAhLK$uQeDlNxD{HtJt zME)7aK0@&q6fe9FE2p6`Kh_pZQkds4GT`?sM8~fIM8^M!5uDqn@aDq^jKn}P;oOfryB~YyxTp_fR@U-QFc!OI zu6N^rSNmmNr95s+fHlER`!Gm!+rEsZH{Yd5OSrDmxc93nx33*MqgvWTZnKq*bBPBz thm6H&_I!Rg`*ZLSK2H{w71m`d3?KXeWC*`54V<3q($#RSz0z4*{tb#Lh=c$D literal 0 HcmV?d00001 diff --git a/be0/src/admin_audit_routes.py b/be0/src/admin_audit_routes.py new file mode 100644 index 0000000..dd6236f --- /dev/null +++ b/be0/src/admin_audit_routes.py @@ -0,0 +1,235 @@ +"""Admin-only audit log query API (GET /api/v1/admin/audit).""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Header, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy import asc, desc, func, select + +from src.auth_jwt import decode_access_token_user_id, decode_bearer_token +from src.initiative_db.engine import get_session, init_engine, is_postgres_enabled +from src.initiative_db.models import AuditEvent + +router = APIRouter(prefix="/admin", tags=["admin-audit"]) + + +def _jwt_role_strings(authorization: str | None) -> list[str]: + p = decode_bearer_token(authorization) + if not p: + return [] + r = p.get("roles") + if isinstance(r, list): + return [str(x) for x in r] + return [] + + +def require_admin_uid(authorization: str | None) -> uuid.UUID: + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.") + if "admin" not in _jwt_role_strings(authorization): + raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.") + return uid + + +class AuditEventListItem(BaseModel): + model_config = {"from_attributes": True} + + id: int + occurred_at: datetime + actor_user_id: Optional[uuid.UUID] = None + actor_email: str + actor_role: str + action: str + entity_type: str + entity_id: Optional[str] = None + metadata: dict[str, Any] = Field(default_factory=dict) + request_id: Optional[uuid.UUID] = None + has_before: bool = False + has_after: bool = False + + +class AuditListResponse(BaseModel): + items: list[AuditEventListItem] + total: int + page: int + page_size: int + + +class AuditEventDetail(BaseModel): + id: int + occurred_at: datetime + actor_user_id: Optional[uuid.UUID] = None + actor_email: str + actor_role: str + action: str + entity_type: str + entity_id: Optional[str] = None + before: Optional[dict[str, Any]] = None + after: Optional[dict[str, Any]] = None + metadata: dict[str, Any] = Field(default_factory=dict) + request_id: Optional[uuid.UUID] = None + + +_AUDIT_ACTIONS = frozenset( + {"create", "read", "update", "delete", "login", "logout", "login_failed"} +) + + +def _parse_sort(sort: str) -> bool: + """True when sorting occurred_at ascending.""" + s = (sort or "occurred_at:desc").strip().lower() + if ":" in s: + col_name, direction = s.split(":", 1) + else: + col_name, direction = s, "desc" + if col_name != "occurred_at": + raise HTTPException(status_code=400, detail='sort chỉ hỗ trợ occurred_at (+ asc|desc)') + return direction in ("asc", "ascending", "old", "older") + + +def _where_audit( + *, + from_ts: datetime, + to_ts: datetime, + actor_user_id: Optional[uuid.UUID], + actor_email: Optional[str], + entity_type: Optional[str], + entity_id: Optional[str], + actions: Optional[list[str]], + request_id: Optional[uuid.UUID], +): + parts = [ + AuditEvent.occurred_at >= from_ts, + AuditEvent.occurred_at <= to_ts, + ] + if actor_user_id is not None: + parts.append(AuditEvent.actor_user_id == actor_user_id) + if actor_email: + parts.append(AuditEvent.actor_email == actor_email.strip().lower()) + if entity_type: + parts.append(AuditEvent.entity_type == entity_type.strip()) + if entity_id is not None and entity_id.strip() != "": + parts.append(AuditEvent.entity_id == entity_id.strip()) + if actions: + parts.append(AuditEvent.action.in_(actions)) + if request_id is not None: + parts.append(AuditEvent.request_id == request_id) + return parts + + +@router.get("/audit", response_model=AuditListResponse) +async def list_audit_events( + authorization: Annotated[str | None, Header()] = None, + from_: Annotated[ + Optional[datetime], + Query(alias="from", description="Inclusive lower bound (UTC). Default: now−7d"), + ] = None, + to: Annotated[ + Optional[datetime], + Query(description="Inclusive upper bound (UTC). Default: now"), + ] = None, + actor_user_id: Optional[uuid.UUID] = None, + actor_email: Optional[str] = None, + entity_type: Optional[str] = None, + entity_id: Optional[str] = None, + action: Optional[str] = Query( + None, description="Comma-separated audit_action values" + ), + request_id: Optional[uuid.UUID] = None, + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + sort: str = Query("occurred_at:desc", description='e.g. "occurred_at:desc"'), +): + require_admin_uid(authorization) + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL để đọc audit.") + + await init_engine() + now = datetime.now(timezone.utc) + end = to or now + start = from_ or (end - timedelta(days=7)) + if end < start: + raise HTTPException(status_code=400, detail="Tham số to phải >= from") + + actions_list: Optional[list[str]] = None + if action: + raw = [a.strip().lower() for a in action.split(",") if a.strip()] + bad = [a for a in raw if a not in _AUDIT_ACTIONS] + if bad: + raise HTTPException(status_code=400, detail=f"action không hợp lệ: {bad}") + actions_list = raw + + asc_order = _parse_sort(sort) + offset = (page - 1) * page_size + wh = _where_audit( + from_ts=start, + to_ts=end, + actor_user_id=actor_user_id, + actor_email=actor_email, + entity_type=entity_type, + entity_id=entity_id, + actions=actions_list, + request_id=request_id, + ) + + async with get_session() as session: + cnt_stmt = select(func.count()).select_from(AuditEvent).where(*wh) + total = int((await session.execute(cnt_stmt)).scalar_one()) + + order_clause = asc(AuditEvent.occurred_at) if asc_order else desc(AuditEvent.occurred_at) + stmt = select(AuditEvent).where(*wh).order_by(order_clause).limit(page_size).offset(offset) + rows = (await session.execute(stmt)).scalars().all() + + items = [ + AuditEventListItem( + id=r.id, + occurred_at=r.occurred_at, + actor_user_id=r.actor_user_id, + actor_email=r.actor_email, + actor_role=r.actor_role, + action=str(r.action), + entity_type=r.entity_type, + entity_id=r.entity_id, + metadata=dict(r.metadata_) if isinstance(r.metadata_, dict) else {}, + request_id=r.request_id, + has_before=r.before is not None, + has_after=r.after is not None, + ) + for r in rows + ] + return AuditListResponse(items=items, total=total, page=page, page_size=page_size) + + +@router.get("/audit/{event_id:int}", response_model=AuditEventDetail) +async def get_audit_event_detail( + event_id: int, + authorization: Annotated[str | None, Header()] = None, +): + require_admin_uid(authorization) + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cần PostgreSQL để đọc audit.") + + await init_engine() + async with get_session() as session: + row = await session.get(AuditEvent, event_id) + if row is None: + raise HTTPException(status_code=404, detail="Không có sự kiện audit.") + return AuditEventDetail( + id=row.id, + occurred_at=row.occurred_at, + actor_user_id=row.actor_user_id, + actor_email=row.actor_email, + actor_role=row.actor_role, + action=str(row.action), + entity_type=row.entity_type, + entity_id=row.entity_id, + before=dict(row.before) if isinstance(row.before, dict) else row.before, + after=dict(row.after) if isinstance(row.after, dict) else row.after, + metadata=dict(row.metadata_) if isinstance(row.metadata_, dict) else {}, + request_id=row.request_id, + ) diff --git a/be0/src/admin_user_profile_routes.py b/be0/src/admin_user_profile_routes.py new file mode 100644 index 0000000..7da96e0 --- /dev/null +++ b/be0/src/admin_user_profile_routes.py @@ -0,0 +1,609 @@ +"""Admin APIs for staff profile verification queue (conditional updates + audit).""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Any, Optional + +from fastapi import APIRouter, Header, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import delete, func, select, text, update +from sqlalchemy.exc import IntegrityError, ProgrammingError +from sqlalchemy.ext.asyncio import AsyncSession + +from src.audit import AuditAction, record_audit, resolve_actor_fields +from src.auth_api import _policy_admin_emails +from src.auth_jwt import decode_access_token_user_id, decode_bearer_token +from src.initiative_db.engine import get_session, is_postgres_enabled +from src.initiative_db.models import ( + AcademicTitle, + ApplicationAdminResult, + ApplicationArtifact, + ApplicationReviewDocument, + AuditLog, + Initiative, + Unit, + User, + UserRoleRow, + UserStaffProfile, +) +from src.staff_profile_domain import staff_row_for_audit + +router = APIRouter(prefix="/admin/user-profiles", tags=["admin-user-profiles"]) + +SYSTEM_DRAFT_USER_ID = uuid.UUID("00000000-0000-4000-8000-000000000001") +_FRONTEND_ROLES = frozenset({"admin", "editor", "viewer"}) + + +def _jwt_role_strings(authorization: str | None) -> list[str]: + p = decode_bearer_token(authorization) + if not p: + return [] + r = p.get("roles") + if isinstance(r, list): + return [str(x) for x in r] + return [] + + +def _require_admin_uid(authorization: str | None) -> uuid.UUID: + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(status_code=401, detail="Đăng nhập để thực hiện thao tác.") + if "admin" not in _jwt_role_strings(authorization): + raise HTTPException(status_code=403, detail="Chỉ tài khoản quản trị mới thực hiện được.") + return uid + + +class PendingProfileItem(BaseModel): + userId: str + email: str + fullName: str + employeeId: Optional[str] = None + jobTitle: Optional[str] = None + verificationSubmittedAt: Optional[datetime] = None + version: int + + +class RegisteredUserItem(BaseModel): + """Active accounts with staff profile snapshot (admin read-only directory).""" + + userId: str + email: str + fullName: str + phone: Optional[str] = None + createdAt: datetime + employeeId: Optional[str] = None + jobTitle: Optional[str] = None + unitNameFreetext: Optional[str] = None + unitCatalogName: Optional[str] = None + academicTitleLabelVi: Optional[str] = None + academicTitleOther: Optional[str] = None + profileVerificationStatus: str = "draft" + roles: list[str] = Field(default_factory=list) + adminFromPolicy: bool = False + policyAdminLocked: bool = False + + +class ProfileDetailResponse(BaseModel): + userId: str + email: str + fullName: str + phone: Optional[str] = None + unitId: Optional[str] = None + unitCatalogName: Optional[str] = None + staffProfile: dict[str, Any] + + +class VerifyBody(BaseModel): + expectedVersion: int = Field(..., ge=1) + + +class RejectBody(BaseModel): + expectedVersion: int = Field(..., ge=1) + reason: str = Field(..., min_length=1, max_length=2000) + + +class RemoveUserBody(BaseModel): + confirmEmail: str = Field(..., min_length=3, max_length=320) + + +class SetUserRolesBody(BaseModel): + admin: bool = False + editor: bool = False + viewer: bool = False + + +class UserRolesStateResponse(BaseModel): + roles: list[str] + adminFromPolicy: bool + policyAdminLocked: bool + + +@router.get("/pending", response_model=list[PendingProfileItem]) +async def list_pending(authorization: str | None = Header(None)) -> list[PendingProfileItem]: + _require_admin_uid(authorization) + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + + async with get_session() as session: + stmt = ( + select(User, UserStaffProfile) + .join(UserStaffProfile, UserStaffProfile.user_id == User.id) + .where(UserStaffProfile.profile_verification_status == "pending", User.is_active.is_(True)) + .order_by(UserStaffProfile.verification_submitted_at.asc().nulls_last()) + ) + rows = (await session.execute(stmt)).all() + out: list[PendingProfileItem] = [] + for user, sp in rows: + out.append( + PendingProfileItem( + userId=str(user.id), + email=user.email, + fullName=user.full_name, + employeeId=sp.employee_id, + jobTitle=sp.job_title, + verificationSubmittedAt=sp.verification_submitted_at, + version=sp.version, + ) + ) + return out + + +@router.get("/registry", response_model=list[RegisteredUserItem]) +async def list_registered_users(authorization: str | None = Header(None)) -> list[RegisteredUserItem]: + """All active user accounts (successful registration) with HR fields for review / export.""" + _require_admin_uid(authorization) + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + + async with get_session() as session: + stmt = ( + select(User, UserStaffProfile, Unit, AcademicTitle) + .outerjoin(UserStaffProfile, UserStaffProfile.user_id == User.id) + .outerjoin(Unit, Unit.id == User.unit_id) + .outerjoin(AcademicTitle, AcademicTitle.code == UserStaffProfile.academic_title_code) + .where(User.is_active.is_(True)) + .order_by(User.created_at.desc()) + ) + rows = (await session.execute(stmt)).all() + by_user_roles: dict[uuid.UUID, list[UserRoleRow]] = {} + if rows: + uids = [r[0].id for r in rows] + role_stmt = select(UserRoleRow).where(UserRoleRow.user_id.in_(uids)) + for rr in (await session.execute(role_stmt)).scalars().all(): + by_user_roles.setdefault(rr.user_id, []).append(rr) + policy = _policy_admin_emails() + out: list[RegisteredUserItem] = [] + for user, sp, unit, title in rows: + status = "draft" + if sp is not None: + status = sp.profile_verification_status or "draft" + ur = by_user_roles.get(user.id, []) + role_set = sorted({str(x.role) for x in ur if str(x.role) in _FRONTEND_ROLES}) + admin_fp = any( + str(x.role) == "admin" and bool(x.admin_from_email_policy) for x in ur + ) + email_norm = user.email.strip().lower() + policy_lock = email_norm in policy + out.append( + RegisteredUserItem( + userId=str(user.id), + email=user.email, + fullName=user.full_name, + phone=user.phone, + createdAt=user.created_at, + employeeId=sp.employee_id if sp else None, + jobTitle=sp.job_title if sp else None, + unitNameFreetext=sp.unit_name_freetext if sp else None, + unitCatalogName=unit.name if unit is not None else None, + academicTitleLabelVi=title.label_vi if title is not None else None, + academicTitleOther=sp.academic_title_other if sp else None, + profileVerificationStatus=status, + roles=role_set, + adminFromPolicy=admin_fp, + policyAdminLocked=policy_lock, + ) + ) + return out + + +async def _detail(session: AsyncSession, user_id: uuid.UUID) -> ProfileDetailResponse | None: + stmt = ( + select(User, UserStaffProfile) + .join(UserStaffProfile, UserStaffProfile.user_id == User.id) + .where(User.id == user_id) + ) + row = (await session.execute(stmt)).first() + if row is None: + return None + user, sp = row + unit_name: str | None = None + if user.unit_id is not None: + u = await session.get(Unit, user.unit_id) + if u is not None: + unit_name = u.name + staff = { + "employeeId": sp.employee_id, + "academicTitleCode": sp.academic_title_code, + "academicTitleOther": sp.academic_title_other, + "unitNameFreetext": sp.unit_name_freetext, + "jobTitle": sp.job_title, + "profileVerificationStatus": sp.profile_verification_status, + "verificationSubmittedAt": sp.verification_submitted_at, + "verifiedAt": sp.verified_at, + "verifiedByUserId": str(sp.verified_by_user_id) if sp.verified_by_user_id else None, + "rejectionReason": sp.rejection_reason, + "version": sp.version, + } + return ProfileDetailResponse( + userId=str(user.id), + email=user.email, + fullName=user.full_name, + phone=user.phone, + unitId=str(user.unit_id) if user.unit_id else None, + unitCatalogName=unit_name, + staffProfile=staff, + ) + + +@router.get("/{user_id}", response_model=ProfileDetailResponse) +async def get_profile_detail( + user_id: uuid.UUID, authorization: str | None = Header(None) +) -> ProfileDetailResponse: + _require_admin_uid(authorization) + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + + async with get_session() as session: + detail = await _detail(session, user_id) + if detail is None: + raise HTTPException(status_code=404, detail="Không tìm thấy người dùng.") + return detail + + +@router.patch("/{user_id}/roles", response_model=UserRolesStateResponse) +async def set_user_roles( + user_id: uuid.UUID, + body: SetUserRolesBody, + authorization: str | None = Header(None), +) -> UserRolesStateResponse: + """Replace app-facing roles (admin / editor / viewer) for a user.""" + admin_id = _require_admin_uid(authorization) + if user_id == SYSTEM_DRAFT_USER_ID: + raise HTTPException(status_code=400, detail="Không thể sửa vai trò tài khoản hệ thống.") + + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + + desired: set[str] = set() + if body.admin: + desired.add("admin") + if body.editor: + desired.add("editor") + if body.viewer: + desired.add("viewer") + if not desired: + raise HTTPException(status_code=400, detail="Chọn ít nhất một vai trò.") + + policy = _policy_admin_emails() + + async with get_session() as session: + user = await session.get(User, user_id) + if user is None or not user.is_active: + raise HTTPException(status_code=404, detail="Không tìm thấy người dùng.") + + email_norm = user.email.strip().lower() + if email_norm in policy and "admin" not in desired: + raise HTTPException( + status_code=400, + detail="Không thể gỡ quyền Quản trị: email thuộc danh sách quản trị hệ thống.", + ) + if user_id == admin_id and "admin" not in desired: + raise HTTPException( + status_code=400, + detail="Không thể tự gỡ quyền Quản trị của chính mình.", + ) + + stmt = select(UserRoleRow).where(UserRoleRow.user_id == user_id) + existing = list((await session.execute(stmt)).scalars().all()) + current_front = {str(r.role) for r in existing if str(r.role) in _FRONTEND_ROLES} + before_roles = sorted(current_front) + + to_remove = current_front - desired + to_add = desired - current_front + + for role in to_remove: + await session.execute( + delete(UserRoleRow).where( + UserRoleRow.user_id == user_id, + UserRoleRow.role == role, + ) + ) + + for role in to_add: + session.add( + UserRoleRow( + user_id=user_id, + role=role, + admin_from_email_policy=bool(role == "admin" and email_norm in policy), + ) + ) + + if to_remove or to_add: + user.credential_version = int(user.credential_version or 0) + 1 + user.updated_at = datetime.now(timezone.utc) + + after_roles = sorted(desired) + if before_roles != after_roles: + actor_email, actor_role = await resolve_actor_fields(session, admin_id) + await record_audit( + session, + actor_user_id=admin_id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="user_roles", + entity_id=str(user_id), + before={"roles": before_roles}, + after={"roles": after_roles}, + metadata={"action": "set_roles"}, + ) + + await session.flush() + stmt2 = select(UserRoleRow).where(UserRoleRow.user_id == user_id) + final_rows = list((await session.execute(stmt2)).scalars().all()) + role_list = sorted({str(r.role) for r in final_rows if str(r.role) in _FRONTEND_ROLES}) + admin_fp = any( + str(r.role) == "admin" and bool(r.admin_from_email_policy) for r in final_rows + ) + return UserRolesStateResponse( + roles=role_list, + adminFromPolicy=admin_fp, + policyAdminLocked=(email_norm in policy), + ) + + +@router.post("/{user_id}/verify") +async def verify_profile( + user_id: uuid.UUID, + body: VerifyBody, + authorization: str | None = Header(None), +) -> dict[str, str]: + admin_id = _require_admin_uid(authorization) + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + + now = datetime.now(timezone.utc) + async with get_session() as session: + sp = await session.get(UserStaffProfile, user_id) + user = await session.get(User, user_id) + if sp is None or user is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.") + before = staff_row_for_audit(sp, user.unit_id) + + stmt = ( + update(UserStaffProfile) + .where( + UserStaffProfile.user_id == user_id, + UserStaffProfile.profile_verification_status == "pending", + UserStaffProfile.version == body.expectedVersion, + ) + .values( + profile_verification_status="verified", + verified_at=now, + verified_by_user_id=admin_id, + rejection_reason=None, + version=UserStaffProfile.version + 1, + updated_at=now, + ) + ) + res = await session.execute(stmt) + if res.rowcount == 0: + raise HTTPException( + status_code=409, + detail="Không thể xác minh: trạng thái đã đổi hoặc phiên bản không khớp (vui lòng tải lại).", + ) + + await session.refresh(sp) + after = staff_row_for_audit(sp, user.unit_id) + actor_email, actor_role = await resolve_actor_fields(session, admin_id) + await record_audit( + session, + actor_user_id=admin_id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="user_profile", + entity_id=str(user_id), + before=before, + after=after, + metadata={"action": "verify"}, + ) + return {"status": "verified"} + + +@router.post("/{user_id}/reject") +async def reject_profile( + user_id: uuid.UUID, + body: RejectBody, + authorization: str | None = Header(None), +) -> dict[str, str]: + admin_id = _require_admin_uid(authorization) + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + + reason = body.reason.strip() + if not reason: + raise HTTPException(status_code=400, detail="Cần lý do từ chối.") + + now = datetime.now(timezone.utc) + async with get_session() as session: + sp = await session.get(UserStaffProfile, user_id) + user = await session.get(User, user_id) + if sp is None or user is None: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ.") + before = staff_row_for_audit(sp, user.unit_id) + + stmt = ( + update(UserStaffProfile) + .where( + UserStaffProfile.user_id == user_id, + UserStaffProfile.profile_verification_status == "pending", + UserStaffProfile.version == body.expectedVersion, + ) + .values( + profile_verification_status="rejected", + verified_at=None, + verified_by_user_id=None, + rejection_reason=reason, + version=UserStaffProfile.version + 1, + updated_at=now, + ) + ) + res = await session.execute(stmt) + if res.rowcount == 0: + raise HTTPException( + status_code=409, + detail="Không thể từ chối: trạng thái đã đổi hoặc phiên bản không khớp (vui lòng tải lại).", + ) + + await session.refresh(sp) + after = staff_row_for_audit(sp, user.unit_id) + actor_email, actor_role = await resolve_actor_fields(session, admin_id) + await record_audit( + session, + actor_user_id=admin_id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="user_profile", + entity_id=str(user_id), + before=before, + after=after, + metadata={"action": "reject"}, + ) + return {"status": "rejected"} + + +@router.post("/{user_id}/remove") +async def remove_user_account( + user_id: uuid.UUID, + body: RemoveUserBody, + authorization: str | None = Header(None), +) -> dict[str, str]: + """ + Permanently delete a user row (cascades roles, staff profile, OTP tokens, etc.). + Blocked for admins, the system draft user, self-delete, and accounts that still own initiatives. + """ + admin_id = _require_admin_uid(authorization) + if user_id == admin_id: + raise HTTPException(status_code=400, detail="Không thể xóa chính mình.") + if user_id == SYSTEM_DRAFT_USER_ID: + raise HTTPException(status_code=400, detail="Không thể xóa tài khoản hệ thống.") + + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + + confirm = body.confirmEmail.strip().lower() + if not confirm: + raise HTTPException(status_code=400, detail="Cần nhập email để xác nhận.") + + async with get_session() as session: + user = await session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="Không tìm thấy người dùng.") + if user.email.strip().lower() != confirm: + raise HTTPException(status_code=400, detail="Email xác nhận không khớp tài khoản.") + + admin_stmt = select(UserRoleRow.user_id).where( + UserRoleRow.user_id == user_id, + UserRoleRow.role == "admin", + ) + if (await session.execute(admin_stmt)).first() is not None: + raise HTTPException(status_code=403, detail="Không thể xóa tài khoản quản trị.") + + own_count = ( + await session.execute( + select(func.count()).select_from(Initiative).where(Initiative.owner_id == user_id) + ) + ).scalar_one() + if own_count and int(own_count) > 0: + raise HTTPException( + status_code=409, + detail="Tài khoản còn sáng kiến/đơn (owner). Xóa hoặc chuyển dữ liệu trước.", + ) + + await session.execute( + update(UserStaffProfile) + .where(UserStaffProfile.verified_by_user_id == user_id) + .values(verified_by_user_id=None) + ) + await session.execute( + update(ApplicationArtifact) + .where(ApplicationArtifact.uploaded_by == user_id) + .values(uploaded_by=None) + ) + await session.execute( + update(ApplicationReviewDocument) + .where(ApplicationReviewDocument.created_by == user_id) + .values(created_by=None) + ) + await session.execute( + update(ApplicationAdminResult) + .where(ApplicationAdminResult.created_by == user_id) + .values(created_by=None) + ) + await session.execute( + update(ApplicationAdminResult) + .where(ApplicationAdminResult.updated_by == user_id) + .values(updated_by=None) + ) + await session.execute(update(AuditLog).where(AuditLog.actor_id == user_id).values(actor_id=None)) + + async with session.begin_nested(): + try: + await session.execute( + text("UPDATE authors SET user_id = NULL WHERE user_id = CAST(:uid AS uuid)"), + {"uid": str(user_id)}, + ) + except ProgrammingError: + pass + + before_user = { + "userId": str(user.id), + "email": user.email, + "fullName": user.full_name, + } + actor_email, actor_role = await resolve_actor_fields(session, admin_id) + await record_audit( + session, + actor_user_id=admin_id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.delete, + entity_type="user", + entity_id=str(user_id), + before=before_user, + after=None, + metadata={"action": "admin_remove_user"}, + ) + + # Delete staff profile before User: ORM would otherwise try to NULL user_staff_profiles.user_id, + # which is the row's primary key (AssertionError on flush). + sp_row = await session.get(UserStaffProfile, user_id) + if sp_row is not None: + await session.delete(sp_row) + await session.flush() + + try: + await session.delete(user) + await session.flush() + except IntegrityError: + await session.rollback() + raise HTTPException( + status_code=409, + detail="Không thể xóa: tài khoản còn được tham chiếu (ví dụ: minh chứng, đánh giá hội đồng).", + ) from None + + return {"status": "deleted"} \ No newline at end of file diff --git a/be0/src/application/__init__.py b/be0/src/application/__init__.py new file mode 100644 index 0000000..3e0635b --- /dev/null +++ b/be0/src/application/__init__.py @@ -0,0 +1,6 @@ +"""Application layer — use cases that orchestrate domain objects via ports. + +Depends on ``domain`` + ``shared_kernel`` only. Knows nothing about FastAPI, +SQLAlchemy, JWT, or argon2 — those arrive as ``ports`` (Protocols) injected by the +composition root. A use case is one business operation, testable with fakes. +""" diff --git a/be0/src/application/identity/__init__.py b/be0/src/application/identity/__init__.py new file mode 100644 index 0000000..1f2eb9e --- /dev/null +++ b/be0/src/application/identity/__init__.py @@ -0,0 +1 @@ +"""Identity use cases (Login is the first cut-over reference).""" diff --git a/be0/src/application/identity/dto.py b/be0/src/application/identity/dto.py new file mode 100644 index 0000000..6c64150 --- /dev/null +++ b/be0/src/application/identity/dto.py @@ -0,0 +1,24 @@ +"""Application DTOs for Identity — the inputs/outputs of use cases (not API schemas).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from src.domain.identity.entities import User + + +@dataclass(frozen=True) +class LoginCommand: + email: str + password: str + client_ip: str + + +@dataclass(frozen=True) +class AuthenticatedUser: + """Result of a successful authentication. The API layer assembles the public + response (incl. staff profile) from this + a profile read.""" + + user: User + roles: list[str] + access_token: str diff --git a/be0/src/application/identity/ports.py b/be0/src/application/identity/ports.py new file mode 100644 index 0000000..0006c28 --- /dev/null +++ b/be0/src/application/identity/ports.py @@ -0,0 +1,54 @@ +"""Driven ports for the Identity application layer. + +Each is a structural ``Protocol`` implemented by an adapter in ``infrastructure``. +The use cases program against these, never against the concrete library. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Protocol + + +class PasswordHasher(Protocol): + """Argon2id in production (``infrastructure.identity.argon2_hasher``).""" + + def hash(self, plain: str) -> str: ... + def verify(self, plain: str, hashed: str) -> bool: ... + + +class TokenIssuer(Protocol): + """Signs the access token from claims built by the domain.""" + + def issue( + self, + user_id: uuid.UUID, + email: str, + roles: list[str], + credential_version: int, + ) -> str: ... + + +class LoginRateLimiter(Protocol): + """Per-(email, ip) sliding window; returns False when the request must be denied.""" + + def allow(self, email: str, client_ip: str) -> bool: ... + + +class Clock(Protocol): + """Injectable time source (keeps use cases deterministic in tests).""" + + def now(self) -> datetime: ... + + +class AuthAuditSink(Protocol): + """Append-only audit of authentication outcomes.""" + + async def login_succeeded( + self, *, user_id: uuid.UUID, email: str, roles: list[str] + ) -> None: ... + + async def login_failed( + self, *, email: str, user_id: uuid.UUID | None, reason: str | None + ) -> None: ... diff --git a/be0/src/application/identity/use_cases/__init__.py b/be0/src/application/identity/use_cases/__init__.py new file mode 100644 index 0000000..e494b47 --- /dev/null +++ b/be0/src/application/identity/use_cases/__init__.py @@ -0,0 +1 @@ +"""One module per use case — one business operation each.""" diff --git a/be0/src/application/identity/use_cases/authenticate_user.py b/be0/src/application/identity/use_cases/authenticate_user.py new file mode 100644 index 0000000..85a250a --- /dev/null +++ b/be0/src/application/identity/use_cases/authenticate_user.py @@ -0,0 +1,80 @@ +"""AuthenticateUser — the ``POST /auth/login`` orchestration, framework-free. + +Behavior mirrors ``auth_api.login`` exactly so the cut-over is invisible to clients: +institutional-email normalization → rate limit (429) → credential check (401) → +email-verified check (403) → role reconcile → audit → signed token. + +Depends only on ports + domain; unit-tested with fakes (no DB). Status mapping +(DomainError subclass → HTTP code) happens in the API layer. +""" + +from __future__ import annotations + +from src.application.identity.dto import AuthenticatedUser, LoginCommand +from src.application.identity.ports import ( + AuthAuditSink, + LoginRateLimiter, + PasswordHasher, + TokenIssuer, +) +from src.domain.identity.errors import EmailNotVerified, InvalidCredentials +from src.domain.identity.repository import UserRepository +from src.domain.identity.value_objects import InstitutionalEmail +from src.shared_kernel.errors import RateLimited + +# Vietnamese messages preserved verbatim from auth_api.login. +_INVALID_CREDENTIALS_MSG = "Email hoặc mật khẩu không đúng." +_EMAIL_UNVERIFIED_MSG = ( + "Vui lòng xác minh email trước khi đăng nhập. Kiểm tra hộp thư " + "hoặc dùng chức năng gửi lại mã OTP trên trang đăng ký." +) +_RATE_LIMITED_MSG = "Quá nhiều lần đăng nhập. Vui lòng thử lại sau." + + +class AuthenticateUser: + def __init__( + self, + *, + users: UserRepository, + hasher: PasswordHasher, + tokens: TokenIssuer, + rate_limiter: LoginRateLimiter, + audit: AuthAuditSink, + ) -> None: + self._users = users + self._hasher = hasher + self._tokens = tokens + self._rate_limiter = rate_limiter + self._audit = audit + + async def execute(self, command: LoginCommand) -> AuthenticatedUser: + email = InstitutionalEmail.parse(command.email) # raises 400 on bad domain + + if not self._rate_limiter.allow(email.value, command.client_ip): + raise RateLimited(_RATE_LIMITED_MSG) + + user = await self._users.get_by_email(email.value) + # Wrong creds and unknown/inactive email are indistinguishable → 401. + if ( + user is None + or not user.can_authenticate() + or not self._hasher.verify(command.password, user.password_hash) + ): + await self._audit.login_failed( + email=email.value, + user_id=user.id if user is not None else None, + reason=None, + ) + raise InvalidCredentials(_INVALID_CREDENTIALS_MSG) + + # Correct creds but unverified email → 403 (distinct from 401). + if user.requires_email_verification(): + await self._audit.login_failed( + email=email.value, user_id=user.id, reason="email_unverified" + ) + raise EmailNotVerified(_EMAIL_UNVERIFIED_MSG) + + roles = await self._users.roles_after_reconcile(user) + await self._audit.login_succeeded(user_id=user.id, email=user.email, roles=roles) + token = self._tokens.issue(user.id, user.email, roles, user.credential_version) + return AuthenticatedUser(user=user, roles=roles, access_token=token) diff --git a/be0/src/audit.py b/be0/src/audit.py new file mode 100644 index 0000000..aa32814 --- /dev/null +++ b/be0/src/audit.py @@ -0,0 +1,176 @@ +"""Append-only audit events (PostgreSQL audit_events table).""" + +from __future__ import annotations + +import enum +import logging +import uuid +from typing import Any, Mapping, Optional, Sequence + +from sqlalchemy import select +from sqlalchemy.exc import InvalidRequestError, ProgrammingError +from sqlalchemy.orm import object_session +from sqlalchemy.ext.asyncio import AsyncSession + +from src.initiative_db.engine import get_session_factory, init_engine, is_postgres_enabled + +logger = logging.getLogger(__name__) + + +class AuditAction(str, enum.Enum): + create = "create" + read = "read" + update = "update" + delete = "delete" + login = "login" + logout = "logout" + login_failed = "login_failed" + + +def _audit_table_missing_error(exc: BaseException) -> bool: + parts: list[str] = [str(exc)] + orig = getattr(exc, "orig", None) + if orig is not None: + parts.append(str(orig)) + chain = getattr(exc, "__cause__", None) + if chain is not None: + parts.append(str(chain)) + blob = " ".join(parts).lower() + return "audit_events" in blob and "does not exist" in blob + + +async def record_audit( + session: AsyncSession, + *, + actor_user_id: Optional[uuid.UUID], + actor_email: str, + actor_role: str, + action: AuditAction, + entity_type: str, + entity_id: Optional[str] = None, + before: Optional[dict[str, Any]] = None, + after: Optional[dict[str, Any]] = None, + metadata: Optional[dict[str, Any]] = None, + request_id: Optional[uuid.UUID] = None, +) -> None: + """ + Insert one audit row in a SAVEPOINT so missing ``audit_events`` (migration not applied) + does not abort the surrounding business transaction. + """ + from src.initiative_db.models import AuditEvent + + row = AuditEvent( + actor_user_id=actor_user_id, + actor_email=actor_email, + actor_role=actor_role, + action=action.value, + entity_type=entity_type, + entity_id=entity_id, + before=before, + after=after, + metadata_=metadata or {}, + request_id=request_id, + ) + try: + async with session.begin_nested(): + session.add(row) + await session.flush() + except ProgrammingError as e: + if _audit_table_missing_error(e): + # If the ORM row is still tracked, drop it so outer commit() does not retry the INSERT. + if object_session(row) is session: + try: + session.expunge(row) + except InvalidRequestError: + pass + logger.warning( + "audit_events table missing — apply be0/migrations/008_audit_events.sql; skipping audit (%s)", + action.value, + ) + return + raise + + +async def persist_audit_standalone( + *, + actor_user_id: Optional[uuid.UUID], + actor_email: str, + actor_role: str, + action: AuditAction, + entity_type: str, + entity_id: Optional[str] = None, + before: Optional[dict[str, Any]] = None, + after: Optional[dict[str, Any]] = None, + metadata: Optional[dict[str, Any]] = None, + request_id: Optional[uuid.UUID] = None, +) -> None: + """ + Commit a single audit row in its own transaction (e.g. failed login where the + main request transaction rolls back). + """ + if not is_postgres_enabled(): + return + from src.initiative_db.models import AuditEvent + + await init_engine() + factory = get_session_factory() + async with factory() as session: + session.add( + AuditEvent( + actor_user_id=actor_user_id, + actor_email=actor_email, + actor_role=actor_role, + action=action.value, + entity_type=entity_type, + entity_id=entity_id, + before=before, + after=after, + metadata_=metadata or {}, + request_id=request_id, + ) + ) + try: + await session.commit() + except ProgrammingError as e: + if _audit_table_missing_error(e): + logger.warning( + "audit_events table missing — apply migration 008; skipping standalone audit (%s)", + action.value, + ) + await session.rollback() + return + await session.rollback() + raise + + +async def resolve_actor_fields(session: AsyncSession, user_id: uuid.UUID) -> tuple[str, str]: + """Return ``(email, roles_csv)`` for denormalized audit columns.""" + from src.initiative_db.models import User, UserRoleRow + + user = await session.get(User, user_id) + if user is None: + return "unknown@invalid", "none" + stmt = select(UserRoleRow.role).where(UserRoleRow.user_id == user_id) + roles = (await session.execute(stmt)).scalars().all() + r = ",".join(sorted({str(x) for x in roles})) if roles else "none" + return user.email, r + + +def roles_from_jwt_list(roles: Sequence[str] | None) -> str: + if not roles: + return "none" + return ",".join(sorted({str(x) for x in roles})) + + +def jwt_payload_actor_email(payload: Mapping[str, Any] | None) -> tuple[str, str]: + """Extract ``(email, roles_csv)`` from JWT payload claims.""" + if not payload: + return "", "none" + email = str(payload.get("email") or "") + raw = payload.get("roles") + if isinstance(raw, list): + return email, roles_from_jwt_list([str(x) for x in raw]) + return email, "none" + + +jwt_payload_actor_email_role = jwt_payload_actor_email diff --git a/be0/src/auth_api.py b/be0/src/auth_api.py new file mode 100644 index 0000000..6210225 --- /dev/null +++ b/be0/src/auth_api.py @@ -0,0 +1,1527 @@ +""" +Registration and login API — passwords hashed with Argon2id; JWT access tokens (HS256). +Requires PostgreSQL (users + user_roles tables). + +New accounts verify email with a **6-digit OTP** (email via SMTP or ``AUTH_MAIL_LOG_ONLY``); see ``/auth/verify-otp``, +``/auth/resend-otp``, migrations ``013_email_verification.sql`` + ``014_registration_otp.sql``, and optional env ``REGISTER_OTP_TTL_MINUTES`` (default **1**). +Legacy ``/auth/verify-email`` (magic link) remains for old tokens only. + +Registration requires a complete staff profile (same rules as profile verification submit): +employee id, academic title (+ detail when «other»), unit (catalog UUID and/or freetext name), job title. + +Server-derived roles: + - Emails in AUTH_ADMIN_EMAILS (comma-separated env) get role ``admin`` with + ``user_roles.admin_from_email_policy = TRUE`` (reconciled on register/login/refresh/me/profile). + - When AUTH_ADMIN_EMAILS is unset, a built-in UMP allow-list applies (institution defaults). + - All other allowed institutional emails get ``viewer`` on register (Người nộp đơn). + - Client-supplied ``role`` on register is ignored (privilege escalation fix). +""" + +from __future__ import annotations + +import hashlib +import hmac +import os +import re +import secrets +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Literal + +import jwt +from argon2 import PasswordHasher + +try: + from argon2.exceptions import InvalidHashError, VerifyMismatchError +except ImportError: # some envs ship argon2 bindings without InvalidHashError + from argon2.exceptions import VerifyMismatchError + + class InvalidHashError(Exception): # noqa: N818 — mirror argon2-cffi + pass + +from fastapi import APIRouter, Header, HTTPException, Request, Response +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError, ProgrammingError + +from src.auth_jwt import ( + decode_access_token_user_id, + decode_bearer_token, + jwt_credential_version_from_payload, + jwt_secret as jwt_secret_key, +) +from src.auth_mail import ( + deliver_email_verification_email, + deliver_password_reset_email, + deliver_registration_otp_email, + mail_delivery_configured, +) +from src.auth_rate_limit import ( + allow_forgot_password, + allow_login, + allow_resend_registration_otp, + allow_resend_verification, + allow_reset_password, +) +from src.staff_profile_domain import ( + apply_reverify_from_verified, + assert_complete_for_submission, + assert_employee_id_shape, + assert_unit_exclusive, + material_staff_fields_changed, + normalize_employee_id, + staff_row_for_audit, +) +from src.audit import ( + AuditAction, + jwt_payload_actor_email, + persist_audit_standalone, + record_audit, + resolve_actor_fields, +) +from src.initiative_db.engine import get_session, is_postgres_enabled +from src.initiative_db.models import ( + AcademicTitle, + EmailVerificationToken, + PasswordResetToken, + RegistrationOtpCode, + Unit, + User, + UserRoleRow, + UserStaffProfile, +) + +router = APIRouter(prefix="/auth", tags=["auth"]) + +_pwd = PasswordHasher() + +MAX_PASSWORD_INPUT_CHARS = 512 + +RESET_TOKEN_TTL = timedelta(hours=1) +VERIFY_EMAIL_TOKEN_TTL = timedelta(hours=48) +_register_otp_minutes_raw = os.getenv("REGISTER_OTP_TTL_MINUTES", "1").strip() +try: + _REGISTER_OTP_MINUTES = int(_register_otp_minutes_raw or "1") +except ValueError: + _REGISTER_OTP_MINUTES = 1 +REGISTER_OTP_TTL = timedelta(minutes=max(1, min(_REGISTER_OTP_MINUTES, 24 * 60))) +REGISTER_OTP_MAX_FAILED_ATTEMPTS = 5 +_OTP_VERIFY_REJECT_DETAIL = "Mã OTP không đúng hoặc đã hết hạn." + +FORGOT_PASSWORD_RESPONSE: dict[str, str] = { + "message": "Nếu email tồn tại trong hệ thống, hướng dẫn đặt lại mật khẩu đã được gửi.", +} + +RESEND_VERIFICATION_RESPONSE: dict[str, str] = { + "message": ( + "Nếu tài khoản cần xác minh, mã OTP đã được gửi đến email (kiểm tra hộp thư/spam)." + ), +} + +RESEND_OTP_RESPONSE: dict[str, str] = { + "message": "Nếu tài khoản cần xác minh, mã OTP đã được gửi đến email (kiểm tra hộp thư/spam).", +} + +VERIFY_EMAIL_SUCCESS: dict[str, str] = { + "message": "Email đã xác minh. Bạn có thể đăng nhập.", +} + +PASSWORD_RESET_SUCCESS: dict[str, str] = { + "message": "Mật khẩu đã được cập nhật. Vui lòng đăng nhập bằng mật khẩu mới.", +} + +# UMP or UMC faculty email (authoritative allow-list for *domain*; role allow-list is separate). +INSTITUTIONAL_EMAIL_RE = re.compile( + r"^[a-zA-Z0-9._%+-]+@(ump|umc)\.edu\.vn\Z", re.IGNORECASE +) + +# Default policy admins when AUTH_ADMIN_EMAILS is not set (must stay in sync with migration 007 cleanup list). +_DEFAULT_POLICY_ADMIN_EMAILS: frozenset[str] = frozenset( + { + "thaontt@ump.edu.vn", + "nltanh@ump.edu.vn", + "ldbaochau@ump.edu.vn", + "htchuong@ump.edu.vn", + "ththinh@ump.edu.vn", + "lhthinh@ump.edu.vn", + } +) + +ROLE_LITERAL = Literal["admin", "editor", "viewer"] + + +def _policy_admin_emails() -> frozenset[str]: + """ + Emails that receive ``admin`` from institutional policy (not self-service). + + If AUTH_ADMIN_EMAILS is set, **only** that comma-separated list is used (lowercased). + If unset, the built-in UMP allow-list above applies. + """ + raw = os.getenv("AUTH_ADMIN_EMAILS", "").strip() + if raw: + return frozenset(part.strip().lower() for part in raw.split(",") if part.strip()) + return _DEFAULT_POLICY_ADMIN_EMAILS + + +def _hash_password(plain: str) -> str: + return _pwd.hash(plain) + + +def _verify_password(plain: str, hashed: str) -> bool: + try: + _pwd.verify(hashed, plain) + return True + except (VerifyMismatchError, InvalidHashError): + return False + + +def _assert_password_policy(password: str) -> None: + if len(password) < 6: + raise HTTPException(status_code=400, detail="Mật khẩu tối thiểu 6 ký tự.") + if len(password) > MAX_PASSWORD_INPUT_CHARS: + raise HTTPException(status_code=400, detail="Mật khẩu quá dài.") + if not re.search(r"[a-z]", password): + raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất một chữ cái thường.") + if not re.search(r"[A-Z]", password): + raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất một chữ cái hoa.") + if not re.search(r"\d", password): + raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất một chữ số.") + if not re.search(r"[^A-Za-z0-9]", password): + raise HTTPException( + status_code=400, + detail="Mật khẩu phải có ít nhất một ký tự đặc biệt (không chỉ chữ và số).", + ) + + +def _normalize_institutional_email(email: str) -> str: + e = email.strip().lower() + if not INSTITUTIONAL_EMAIL_RE.match(e): + raise HTTPException( + status_code=400, + detail="Email phải là địa chỉ UMP hoặc UMC hợp lệ (dạng ten@ump.edu.vn hoặc ten@umc.edu.vn).", + ) + return e + + +def _hash_reset_token(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _otp_ttl_label_vi() -> str: + """Human-readable validity window for API copy (matches REGISTER_OTP_TTL).""" + total = max(1, int(REGISTER_OTP_TTL.total_seconds())) + if total >= 3600: + return f"{total // 3600} giờ" + if total >= 60: + m = max(1, total // 60) + return "1 phút" if m == 1 else f"{m} phút" + return f"{total} giây" + + +def _register_success_otp_message() -> str: + label = _otp_ttl_label_vi() + return ( + "Đăng ký thành công. Mã OTP 6 số đã được gửi đến email UMP/UMC " + "(kiểm tra cả thư mục spam). " + f"Mã có hiệu lực trong {label}. Nhập mã trên trang đăng ký để kích hoạt tài khoản." + ) + + +def _maybe_raise_otp_table_missing(exc: ProgrammingError) -> None: + blob = str(exc).lower() + if "registration_otp_codes" not in blob: + return + if "does not exist" not in blob and "undefinedtable" not in blob.replace(" ", ""): + return + raise HTTPException( + status_code=503, + detail=( + "Cơ sở dữ liệu chưa có bảng mã OTP (registration_otp_codes). " + "Khởi động lại API để áp dụng migration, hoặc chạy be0/migrations/014_registration_otp.sql." + ), + ) from exc + + +async def _delete_pending_registration_otps(session: Any, user_id: uuid.UUID) -> None: + try: + await session.execute( + delete(RegistrationOtpCode).where( + RegistrationOtpCode.user_id == user_id, + RegistrationOtpCode.used_at.is_(None), + ) + ) + except ProgrammingError as e: + _maybe_raise_otp_table_missing(e) + raise + + +async def _issue_registration_otp(session: Any, user_id: uuid.UUID) -> str: + try: + await _delete_pending_registration_otps(session, user_id) + plaintext = f"{secrets.randbelow(10**6):06d}" + otp_hash = _hash_reset_token(plaintext) + session.add( + RegistrationOtpCode( + user_id=user_id, + otp_hash=otp_hash, + expires_at=datetime.now(timezone.utc) + REGISTER_OTP_TTL, + failed_attempts=0, + ) + ) + await session.flush() + return plaintext + except ProgrammingError as e: + _maybe_raise_otp_table_missing(e) + raise + + +def _client_ip(request: Request) -> str: + xf = request.headers.get("x-forwarded-for") + if xf: + part = xf.split(",")[0].strip() + if part: + return part + if request.client and request.client.host: + return request.client.host + return "unknown" + + +def _issue_token(user_id: uuid.UUID, email: str, roles: list[str], credential_version: int) -> str: + now = datetime.now(timezone.utc) + exp = now + timedelta(hours=int(os.getenv("JWT_EXPIRE_HOURS", "12"))) + payload: dict[str, Any] = { + "sub": str(user_id), + "email": email, + "roles": roles, + "cv": int(credential_version), + "iat": int(now.timestamp()), + "exp": int(exp.timestamp()), + } + return jwt.encode(payload, jwt_secret_key(), algorithm="HS256") + + +async def _load_roles(session: Any, user_id: uuid.UUID) -> list[str]: + stmt = select(UserRoleRow.role).where(UserRoleRow.user_id == user_id) + rows = (await session.execute(stmt)).scalars().all() + out = [str(r) for r in rows] + return sorted(set(out)) + + +async def _reconcile_policy_admin(session: Any, user: User) -> None: + """ + Mandatory policy sync for ``admin`` tied to AUTH_ADMIN_EMAILS / defaults. + + - Allow-listed email: ensure ``admin`` row with admin_from_email_policy=TRUE. + - Not allow-listed: delete ``admin`` row only when admin_from_email_policy is TRUE, + preserving exceptional manual admin rows (FALSE). + """ + email_norm = user.email.strip().lower() + policy = _policy_admin_emails() + + stmt = select(UserRoleRow).where( + UserRoleRow.user_id == user.id, + UserRoleRow.role == "admin", + ) + admin_row = (await session.execute(stmt)).scalar_one_or_none() + + if email_norm in policy: + if admin_row is None: + session.add( + UserRoleRow( + user_id=user.id, + role="admin", + admin_from_email_policy=True, + ) + ) + else: + admin_row.admin_from_email_policy = True + elif admin_row is not None and admin_row.admin_from_email_policy: + await session.delete(admin_row) + + await session.flush() + + +async def _roles_after_reconcile(session: Any, user: User) -> list[str]: + await _reconcile_policy_admin(session, user) + return await _load_roles(session, user.id) + + +async def _load_staff_profile(session: Any, user_id: uuid.UUID) -> UserStaffProfile: + sp = await session.get(UserStaffProfile, user_id) + if sp is None: + sp = UserStaffProfile(user_id=user_id) + session.add(sp) + await session.flush() + return sp + + +async def _assert_academic_title_active(session: Any, code: str | None) -> None: + if not code: + return + row = await session.get(AcademicTitle, code) + if row is None or not row.active: + raise HTTPException(status_code=400, detail="Học hàm / học vị không hợp lệ.") + + +async def _assert_unit_exists(session: Any, unit_id: uuid.UUID | None) -> None: + if unit_id is None: + return + if await session.get(Unit, unit_id) is None: + raise HTTPException(status_code=400, detail="Đơn vị không tồn tại trong danh mục.") + + +def _staff_profile_api_dict(user: User, sp: UserStaffProfile) -> dict[str, Any]: + return { + "employeeId": sp.employee_id, + "academicTitleCode": sp.academic_title_code, + "academicTitleOther": sp.academic_title_other, + "unitId": str(user.unit_id) if user.unit_id else None, + "unitNameFreetext": sp.unit_name_freetext, + "jobTitle": sp.job_title, + "profileVerificationStatus": sp.profile_verification_status, + "verificationSubmittedAt": sp.verification_submitted_at, + "verifiedAt": sp.verified_at, + "verifiedByUserId": str(sp.verified_by_user_id) if sp.verified_by_user_id else None, + "rejectionReason": sp.rejection_reason, + "version": sp.version, + } + + +def _user_public_dict(user: User, roles: list[str], sp: UserStaffProfile | None = None) -> dict[str, Any]: + out: dict[str, Any] = { + "id": str(user.id), + "email": user.email, + "name": user.full_name, + "roles": roles, + "phone": user.phone, + "emailVerified": bool(getattr(user, "email_verified", True)), + } + if sp is not None: + out["staffProfile"] = _staff_profile_api_dict(user, sp) + return out + + +class RegisterBody(BaseModel): + model_config = ConfigDict(extra="ignore") + + fullName: str = Field(..., min_length=2, max_length=200) + email: str = Field(..., min_length=5, max_length=254) + password: str = Field(..., min_length=1) + passwordConfirm: str = Field(..., min_length=1) + # Deprecated: ignored; server derives roles (admin vs viewer) from email policy. + role: ROLE_LITERAL | None = Field(default=None, description="Ignored.") + employeeId: str = Field(..., min_length=1, max_length=40) + academicTitleCode: str = Field(..., min_length=1, max_length=64) + academicTitleOther: str | None = Field(default=None, max_length=200) + unitId: uuid.UUID | None = None + unitNameFreetext: str | None = Field(default=None, max_length=300) + jobTitle: str = Field(..., min_length=1, max_length=120) + + @field_validator("fullName") + @classmethod + def strip_name(cls, v: str) -> str: + s = v.strip() + if len(s) < 2: + raise ValueError("Họ tên quá ngắn.") + return s + + @field_validator("academicTitleCode", mode="before") + @classmethod + def strip_title_code(cls, v: object) -> str: + if v is None or v == "": + raise ValueError("Chọn học hàm / học vị.") + s = str(v).strip() + if not s: + raise ValueError("Chọn học hàm / học vị.") + return s + + @field_validator("academicTitleOther", "unitNameFreetext", mode="before") + @classmethod + def strip_optional_text(cls, v: object) -> str | None: + if v is None or v == "": + return None + s = str(v).strip() + return s or None + + @field_validator("employeeId", mode="before") + @classmethod + def strip_employee_required(cls, v: object) -> str: + if v is None or v == "": + raise ValueError("Vui lòng nhập mã số nhân sự.") + s = str(v).strip() + if not s: + raise ValueError("Vui lòng nhập mã số nhân sự.") + return s + + @field_validator("jobTitle", mode="before") + @classmethod + def strip_job_required(cls, v: object) -> str: + if v is None or v == "": + raise ValueError("Nhập chức vụ công tác.") + s = str(v).strip() + if not s: + raise ValueError("Nhập chức vụ công tác.") + return s + + @model_validator(mode="after") + def staff_cross_field(self) -> RegisterBody: + if self.academicTitleCode == "other": + if not self.academicTitleOther or not str(self.academicTitleOther).strip(): + raise ValueError( + "Khi chọn «Khác», vui lòng nhập nội dung học hàm / học vị." + ) + has_unit = self.unitId is not None or ( + self.unitNameFreetext is not None and len(str(self.unitNameFreetext).strip()) > 0 + ) + if not has_unit: + raise ValueError("Chọn đơn vị công tác hoặc nhập tên đơn vị.") + return self + + +class LoginBody(BaseModel): + email: str = Field(..., max_length=254) + password: str = Field(..., min_length=1, max_length=MAX_PASSWORD_INPUT_CHARS) + + @field_validator("email") + @classmethod + def strip_login_email(cls, v: str) -> str: + s = v.strip() + if not s: + raise ValueError("Vui lòng nhập email.") + return s + + @field_validator("password") + @classmethod + def reject_blank_password(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Vui lòng nhập mật khẩu.") + return v + + +class ForgotPasswordBody(BaseModel): + model_config = ConfigDict(extra="ignore") + + email: str = Field(..., min_length=5, max_length=254) + + +class ResetPasswordBody(BaseModel): + model_config = ConfigDict(extra="ignore") + + token: str = Field(..., min_length=10, max_length=512) + newPassword: str = Field(..., min_length=1) + newPasswordConfirm: str = Field(..., min_length=1) + + +@router.post("/register") +async def register(body: RegisterBody) -> dict[str, Any]: + if not is_postgres_enabled(): + raise HTTPException( + status_code=503, + detail="Đăng ký tạm thời không khả dụng (cơ sở dữ liệu chưa cấu hình).", + ) + + if body.password != body.passwordConfirm: + raise HTTPException(status_code=400, detail="Mật khẩu xác nhận không khớp.") + + _assert_password_policy(body.password) + email_n = _normalize_institutional_email(body.email) + policy = _policy_admin_emails() + + pwd_hash = _hash_password(body.password) + + mail_out: tuple[str, str] | None = None + async with get_session() as session: + existing = ( + await session.execute(select(User.id).where(User.email == email_n)) + ).scalar_one_or_none() + if existing is not None: + raise HTTPException(status_code=409, detail="Email này đã được đăng ký.") + + user = User( + id=uuid.uuid4(), + email=email_n, + password_hash=pwd_hash, + full_name=body.fullName, + email_verified=False, + ) + if body.unitId is not None: + user.unit_id = body.unitId + session.add(user) + await session.flush() + + await _assert_unit_exists(session, user.unit_id) + await _assert_academic_title_active(session, body.academicTitleCode) + emp = normalize_employee_id(body.employeeId) + + staff = UserStaffProfile( + user_id=user.id, + employee_id=emp, + academic_title_code=body.academicTitleCode, + academic_title_other=body.academicTitleOther, + unit_name_freetext=body.unitNameFreetext, + job_title=body.jobTitle, + ) + try: + assert_unit_exclusive(user, staff) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + try: + assert_complete_for_submission(user, staff) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + session.add(staff) + try: + await session.flush() + except IntegrityError as e: + raise HTTPException( + status_code=409, + detail="Mã số nhân sự đã được sử dụng hoặc dữ liệu không hợp lệ.", + ) from e + + if email_n in policy: + session.add( + UserRoleRow( + user_id=user.id, + role="admin", + admin_from_email_policy=True, + ) + ) + else: + session.add( + UserRoleRow( + user_id=user.id, + role="viewer", + admin_from_email_policy=False, + ) + ) + try: + await session.flush() + except IntegrityError: + raise HTTPException(status_code=409, detail="Không thể gán vai trò — thử lại.") from None + + roles = await _roles_after_reconcile(session, user) + actor_email, actor_role = await resolve_actor_fields(session, user.id) + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.create, + entity_type="user", + entity_id=str(user.id), + after={ + "email": user.email, + "fullName": user.full_name, + "roles": roles, + }, + metadata={"source": "auth_register"}, + ) + otp_plain = await _issue_registration_otp(session, user.id) + public_user = _user_public_dict(user, roles, staff) + mail_out = (email_n, otp_plain) + + otp_delivery = "none" + if mail_out is not None: + try: + ch = await deliver_registration_otp_email(mail_out[0], mail_out[1]) + if ch in ("smtp", "log_only", "none"): + otp_delivery = ch + except Exception as e: + import logging + + logging.getLogger(__name__).exception("register: OTP mail failed: %s", e) + otp_delivery = "smtp_failed" if mail_delivery_configured() else "none" + + return { + "message": _register_success_otp_message(), + "email": email_n, + "emailVerificationRequired": True, + "otpTtlSeconds": int(REGISTER_OTP_TTL.total_seconds()), + "otpDeliveryChannel": otp_delivery, + "user": public_user, + } + + +@router.post("/login") +async def login(body: LoginBody, request: Request) -> dict[str, Any]: + if not is_postgres_enabled(): + raise HTTPException( + status_code=503, + detail="Đăng nhập qua máy chủ yêu cầu cơ sở dữ liệu.", + ) + + email_n = _normalize_institutional_email(body.email) + if not allow_login(email_n, _client_ip(request)): + raise HTTPException( + status_code=429, + detail="Quá nhiều lần đăng nhập. Vui lòng thử lại sau.", + ) + + user: User | None = None + roles: list[str] = [] + staff_profile: UserStaffProfile | None = None + login_ok = False + needs_email_verify = False + failed_uid: uuid.UUID | None = None + failed_roles = "none" + + async with get_session() as session: + stmt = select(User).where(User.email == email_n, User.is_active.is_(True)) + user = (await session.execute(stmt)).scalar_one_or_none() + + if user is not None and _verify_password(body.password, user.password_hash): + if not user.email_verified: + needs_email_verify = True + failed_uid = user.id + _, failed_roles = await resolve_actor_fields(session, user.id) + else: + roles = await _roles_after_reconcile(session, user) + actor_email, actor_role = await resolve_actor_fields(session, user.id) + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.login, + entity_type="auth", + entity_id=str(user.id), + metadata={"path": "/auth/login"}, + ) + staff_profile = await _load_staff_profile(session, user.id) + login_ok = True + elif user is not None: + failed_uid = user.id + _, failed_roles = await resolve_actor_fields(session, user.id) + + if needs_email_verify: + await persist_audit_standalone( + actor_user_id=failed_uid, + actor_email=email_n, + actor_role=failed_roles, + action=AuditAction.login_failed, + entity_type="auth", + metadata={"event": "login_failed", "reason": "email_unverified"}, + ) + raise HTTPException( + status_code=403, + detail="Vui lòng xác minh email trước khi đăng nhập. Kiểm tra hộp thư " + "hoặc dùng chức năng gửi lại mã OTP trên trang đăng ký.", + ) + + if not login_ok: + await persist_audit_standalone( + actor_user_id=failed_uid, + actor_email=email_n, + actor_role=failed_roles, + action=AuditAction.login_failed, + entity_type="auth", + metadata={"event": "login_failed"}, + ) + raise HTTPException(status_code=401, detail="Email hoặc mật khẩu không đúng.") + + assert user is not None + + token = _issue_token(user.id, user.email, roles, int(user.credential_version)) + return {"accessToken": token, "user": _user_public_dict(user, roles, staff_profile)} + + +@router.post("/forgot-password") +async def forgot_password(body: ForgotPasswordBody, request: Request) -> dict[str, str]: + """Always same response for unknown / inactive email; rate-limited.""" + if not is_postgres_enabled(): + return FORGOT_PASSWORD_RESPONSE + + email_n = _normalize_institutional_email(body.email) + ip = _client_ip(request) + if not allow_forgot_password(email_n, ip): + raise HTTPException( + status_code=429, + detail="Quá nhiều yêu cầu. Vui lòng thử lại sau.", + ) + + if not mail_delivery_configured(): + import logging + + logging.getLogger(__name__).warning( + "forgot-password: mail not configured (SMTP_HOST or AUTH_MAIL_LOG_ONLY)" + ) + return FORGOT_PASSWORD_RESPONSE + + mail_to_send: tuple[str, str] | None = None + async with get_session() as session: + user = ( + await session.execute( + select(User).where(User.email == email_n, User.is_active.is_(True)) + ) + ).scalar_one_or_none() + + if user is None: + return FORGOT_PASSWORD_RESPONSE + + raw = secrets.token_urlsafe(32) + th = _hash_reset_token(raw) + await session.execute( + delete(PasswordResetToken).where( + PasswordResetToken.user_id == user.id, + PasswordResetToken.used_at.is_(None), + ) + ) + session.add( + PasswordResetToken( + user_id=user.id, + token_hash=th, + expires_at=datetime.now(timezone.utc) + RESET_TOKEN_TTL, + ) + ) + await session.flush() + actor_email, actor_role = await resolve_actor_fields(session, user.id) + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="auth", + entity_id=str(user.id), + metadata={"source": "auth_forgot_password", "event": "password_reset_requested"}, + ) + mail_to_send = (str(user.email), raw) + + if mail_to_send: + try: + await deliver_password_reset_email(mail_to_send[0], mail_to_send[1]) + except Exception as e: + import logging + + logging.getLogger(__name__).exception("forgot-password: mail send failed: %s", e) + + return FORGOT_PASSWORD_RESPONSE + + +@router.post("/reset-password") +async def reset_password(body: ResetPasswordBody, request: Request) -> dict[str, str]: + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + + if not allow_reset_password(_client_ip(request)): + raise HTTPException( + status_code=429, + detail="Quá nhiều yêu cầu. Vui lòng thử lại sau.", + ) + + if body.newPassword != body.newPasswordConfirm: + raise HTTPException(status_code=400, detail="Mật khẩu xác nhận không khớp.") + _assert_password_policy(body.newPassword) + + raw = body.token.strip() + if not raw or len(raw) < 10: + raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.") + + th = _hash_reset_token(raw) + now = datetime.now(timezone.utc) + + async with get_session() as session: + row = ( + await session.execute( + select(PasswordResetToken).where(PasswordResetToken.token_hash == th) + ) + ).scalar_one_or_none() + + if row is None or row.used_at is not None or row.expires_at <= now: + raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.") + + user = await session.get(User, row.user_id) + if user is None or not user.is_active: + raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.") + + user.password_hash = _hash_password(body.newPassword) + user.credential_version = int(user.credential_version or 0) + 1 + user.updated_at = now + row.used_at = now + await session.flush() + + actor_email, actor_role = await resolve_actor_fields(session, user.id) + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="user", + entity_id=str(user.id), + before={"password": "[redacted]"}, + after={"password": "[changed]"}, + metadata={"source": "auth_reset_password", "event": "password_reset_completed"}, + ) + + return PASSWORD_RESET_SUCCESS + + +class VerifyEmailBody(BaseModel): + model_config = ConfigDict(extra="ignore") + + token: str = Field(..., min_length=10, max_length=512) + + +class ResendVerificationBody(BaseModel): + model_config = ConfigDict(extra="ignore") + + email: str = Field(..., min_length=5, max_length=254) + + +class VerifyRegistrationOtpBody(BaseModel): + model_config = ConfigDict(extra="ignore") + + email: str = Field(..., min_length=5, max_length=254) + otp: str = Field(..., min_length=6, max_length=32) + + @field_validator("email") + @classmethod + def strip_email_votp(cls, v: object) -> str: + s = str(v).strip().lower() + if not s: + raise ValueError("Vui lòng nhập email.") + return s + + @field_validator("otp") + @classmethod + def otp_six_digits(cls, v: object) -> str: + s = str(v).strip() + if not re.fullmatch(r"\d{6}", s): + raise ValueError("Mã gồm đúng 6 chữ số.") + return s + + +class ResendRegistrationOtpBody(BaseModel): + model_config = ConfigDict(extra="ignore") + + email: str = Field(..., min_length=5, max_length=254) + + @field_validator("email") + @classmethod + def strip_email_resend_otp(cls, v: object) -> str: + return str(v).strip().lower() + + +@router.post("/verify-otp") +async def verify_registration_otp(body: VerifyRegistrationOtpBody) -> dict[str, str]: + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + + try: + email_n = _normalize_institutional_email(body.email) + except HTTPException: + raise HTTPException(status_code=400, detail=_OTP_VERIFY_REJECT_DETAIL) from None + + otp_hash_expected = _hash_reset_token(body.otp) + now = datetime.now(timezone.utc) + + async with get_session() as session: + user = ( + await session.execute( + select(User).where(User.email == email_n, User.is_active.is_(True)) + ) + ).scalar_one_or_none() + + if user is None or user.email_verified: + raise HTTPException(status_code=400, detail=_OTP_VERIFY_REJECT_DETAIL) + + stmt = ( + select(RegistrationOtpCode) + .where( + RegistrationOtpCode.user_id == user.id, + RegistrationOtpCode.used_at.is_(None), + RegistrationOtpCode.expires_at > now, + RegistrationOtpCode.failed_attempts < REGISTER_OTP_MAX_FAILED_ATTEMPTS, + ) + .order_by(RegistrationOtpCode.created_at.desc()) + .limit(1) + ) + row = (await session.execute(stmt)).scalar_one_or_none() + + if row is None: + raise HTTPException(status_code=400, detail=_OTP_VERIFY_REJECT_DETAIL) + + if not hmac.compare_digest(row.otp_hash, otp_hash_expected): + row.failed_attempts += 1 + await session.flush() + raise HTTPException(status_code=400, detail=_OTP_VERIFY_REJECT_DETAIL) + + user.email_verified = True + user.updated_at = now + row.used_at = now + await session.flush() + + actor_email, actor_role = await resolve_actor_fields(session, user.id) + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="user", + entity_id=str(user.id), + after={"emailVerified": True}, + metadata={"source": "auth_verify_registration_otp"}, + ) + + return VERIFY_EMAIL_SUCCESS + + +@router.post("/resend-otp") +async def resend_registration_otp(body: ResendRegistrationOtpBody, request: Request) -> dict[str, str]: + """Enumeration-safe envelope (same response whether email exists / needs OTP).""" + if not is_postgres_enabled(): + return RESEND_OTP_RESPONSE + + try: + email_n = _normalize_institutional_email(body.email) + except HTTPException: + return RESEND_OTP_RESPONSE + + ip = _client_ip(request) + if not allow_resend_registration_otp(email_n, ip): + raise HTTPException( + status_code=429, + detail="Quá nhiều yêu cầu. Vui lòng thử lại sau.", + ) + + if not mail_delivery_configured(): + import logging + + logging.getLogger(__name__).warning( + "resend-otp: mail not configured (SMTP_HOST or AUTH_MAIL_LOG_ONLY)" + ) + return RESEND_OTP_RESPONSE + + mail_to_send: tuple[str, str] | None = None + async with get_session() as session: + user = ( + await session.execute( + select(User).where(User.email == email_n, User.is_active.is_(True)) + ) + ).scalar_one_or_none() + + if user is not None and not user.email_verified: + code = await _issue_registration_otp(session, user.id) + await session.flush() + actor_email, actor_role = await resolve_actor_fields(session, user.id) + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="auth", + entity_id=str(user.id), + metadata={"source": "auth_resend_registration_otp"}, + ) + mail_to_send = (str(user.email), code) + + if mail_to_send is not None: + try: + await deliver_registration_otp_email(mail_to_send[0], mail_to_send[1]) + except Exception as e: + import logging + + logging.getLogger(__name__).exception("resend-otp: mail send failed: %s", e) + + return RESEND_OTP_RESPONSE + + +@router.post("/verify-email") +async def verify_email(body: VerifyEmailBody) -> dict[str, str]: + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + + raw = body.token.strip() + if len(raw) < 10: + raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.") + + th = _hash_reset_token(raw) + now = datetime.now(timezone.utc) + + async with get_session() as session: + row = ( + await session.execute( + select(EmailVerificationToken).where(EmailVerificationToken.token_hash == th) + ) + ).scalar_one_or_none() + + if row is None or row.used_at is not None or row.expires_at <= now: + raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.") + + user = await session.get(User, row.user_id) + if user is None or not user.is_active: + raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.") + + user.email_verified = True + user.updated_at = now + row.used_at = now + await session.flush() + + actor_email, actor_role = await resolve_actor_fields(session, user.id) + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="user", + entity_id=str(user.id), + after={"emailVerified": True}, + metadata={"source": "auth_verify_email"}, + ) + + return VERIFY_EMAIL_SUCCESS + + +@router.post("/resend-verification") +async def resend_verification(body: ResendVerificationBody, request: Request) -> dict[str, str]: + """Same envelope whether or not the account exists / needs verification (enumeration-safe).""" + if not is_postgres_enabled(): + return RESEND_VERIFICATION_RESPONSE + + email_n = _normalize_institutional_email(body.email) + ip = _client_ip(request) + if not allow_resend_verification(email_n, ip): + raise HTTPException( + status_code=429, + detail="Quá nhiều yêu cầu. Vui lòng thử lại sau.", + ) + + if not mail_delivery_configured(): + import logging + + logging.getLogger(__name__).warning( + "resend-verification: mail not configured (SMTP_HOST or AUTH_MAIL_LOG_ONLY)" + ) + return RESEND_VERIFICATION_RESPONSE + + mail_to_send: tuple[str, str] | None = None + async with get_session() as session: + user = ( + await session.execute( + select(User).where(User.email == email_n, User.is_active.is_(True)) + ) + ).scalar_one_or_none() + + if user is not None and not user.email_verified: + code = await _issue_registration_otp(session, user.id) + await session.flush() + actor_email, actor_role = await resolve_actor_fields(session, user.id) + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="auth", + entity_id=str(user.id), + metadata={"source": "auth_resend_verification"}, + ) + mail_to_send = (str(user.email), code) + + if mail_to_send: + try: + await deliver_registration_otp_email(mail_to_send[0], mail_to_send[1]) + except Exception as e: + import logging + + logging.getLogger(__name__).exception("resend-verification: mail send failed: %s", e) + + return RESEND_VERIFICATION_RESPONSE + + +@router.post("/refresh") +async def refresh_session(authorization: str | None = Header(None)) -> dict[str, Any]: + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(401, detail="Thiếu token.") + raw = authorization.split(None, 1)[1].strip() + try: + payload = jwt.decode(raw, jwt_secret_key(), algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=401, + detail="Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.", + ) from None + except jwt.PyJWTError: + raise HTTPException(401, detail="Token không hợp lệ.") from None + + try: + uid = uuid.UUID(str(payload["sub"])) + except (ValueError, KeyError): + raise HTTPException(401, detail="Token không hợp lệ.") from None + + jwt_cv = jwt_credential_version_from_payload(payload) + + async with get_session() as session: + user = await session.get(User, uid) + if user is None or not user.is_active: + raise HTTPException(401, detail="Tài khoản không còn hiệu lực.") + if not user.email_verified: + raise HTTPException( + status_code=403, + detail="Vui lòng xác minh email trước khi tiếp tục.", + ) + db_cv = int(user.credential_version or 0) + if jwt_cv != db_cv: + raise HTTPException( + status_code=401, + detail="Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.", + ) + roles = await _roles_after_reconcile(session, user) + sp = await _load_staff_profile(session, user.id) + + token = _issue_token(user.id, user.email, roles, db_cv) + return {"accessToken": token, "user": _user_public_dict(user, roles, sp)} + + +@router.post("/logout") +async def logout(authorization: str | None = Header(None)) -> Response: + """Client clears JWT locally; audit row only when Bearer decodes cleanly.""" + if is_postgres_enabled(): + payload = decode_bearer_token(authorization) + if payload: + uid = decode_access_token_user_id(authorization) + email, roles_csv = jwt_payload_actor_email(payload) + await persist_audit_standalone( + actor_user_id=uid, + actor_email=email if email else "", + actor_role=roles_csv, + action=AuditAction.logout, + entity_type="auth", + entity_id=str(uid) if uid else None, + metadata={"path": "/auth/logout"}, + ) + return Response(status_code=204) + + +@router.get("/reference/academic-titles") +async def reference_academic_titles() -> list[dict[str, Any]]: + if not is_postgres_enabled(): + return [] + async with get_session() as session: + stmt = ( + select(AcademicTitle) + .where(AcademicTitle.active.is_(True)) + .order_by(AcademicTitle.sort_order, AcademicTitle.code) + ) + rows = (await session.execute(stmt)).scalars().all() + return [{"code": r.code, "labelVi": r.label_vi, "labelEn": r.label_en} for r in rows] + + +@router.get("/reference/units") +async def reference_units() -> list[dict[str, str]]: + if not is_postgres_enabled(): + return [] + async with get_session() as session: + stmt = select(Unit).order_by(Unit.name) + rows = (await session.execute(stmt)).scalars().all() + return [{"id": str(r.id), "name": r.name} for r in rows] + + +@router.get("/me") +async def get_current_profile(authorization: str | None = Header(None)) -> dict[str, Any]: + if not is_postgres_enabled(): + raise HTTPException( + status_code=503, + detail="Hồ sơ yêu cầu cơ sở dữ liệu.", + ) + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(401, detail="Vui lòng đăng nhập.") + + async with get_session() as session: + user = await session.get(User, uid) + if user is None or not user.is_active: + raise HTTPException(401, detail="Tài khoản không còn hiệu lực.") + if not user.email_verified: + raise HTTPException( + status_code=403, + detail="Vui lòng xác minh email trước khi tiếp tục.", + ) + roles = await _roles_after_reconcile(session, user) + sp = await _load_staff_profile(session, user.id) + return _user_public_dict(user, roles, sp) + + +class UpdateProfileBody(BaseModel): + model_config = ConfigDict(extra="ignore") + + fullName: str | None = None + phone: str | None = None + employeeId: str | None = None + academicTitleCode: str | None = None + academicTitleOther: str | None = None + unitId: uuid.UUID | None = None + unitNameFreetext: str | None = None + jobTitle: str | None = None + + @field_validator("fullName", mode="before") + @classmethod + def strip_full_name(cls, v: object) -> str | None: + if v is None: + return None + s = str(v).strip() + if len(s) < 2: + raise ValueError("Họ tên phải có ít nhất 2 ký tự.") + return s + + @field_validator("phone", mode="before") + @classmethod + def strip_phone(cls, v: object) -> str | None: + if v is None or v == "": + return None + s = str(v).strip() + if len(s) > 32: + raise ValueError("Số điện thoại quá dài.") + return s + + @field_validator("academicTitleCode", mode="before") + @classmethod + def strip_title_code(cls, v: object) -> str | None: + if v is None or v == "": + return None + s = str(v).strip() + return s or None + + @field_validator("academicTitleOther", "unitNameFreetext", "jobTitle", mode="before") + @classmethod + def strip_optional_text(cls, v: object) -> str | None: + if v is None or v == "": + return None + s = str(v).strip() + return s or None + + @field_validator("employeeId", mode="before") + @classmethod + def strip_employee(cls, v: object) -> str | None: + if v is None or v == "": + return None + return str(v).strip() + + +@router.patch("/profile") +async def update_profile( + body: UpdateProfileBody, authorization: str | None = Header(None) +) -> dict[str, Any]: + if not is_postgres_enabled(): + raise HTTPException( + status_code=503, + detail="Cập nhật hồ sơ yêu cầu cơ sở dữ liệu.", + ) + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(401, detail="Vui lòng đăng nhập.") + + patch = body.model_dump(exclude_unset=True) + if not patch: + raise HTTPException(400, detail="Không có dữ liệu để cập nhật.") + + now = datetime.now(timezone.utc) + async with get_session() as session: + user = await session.get(User, uid) + if user is None or not user.is_active: + raise HTTPException(401, detail="Tài khoản không còn hiệu lực.") + sp = await _load_staff_profile(session, user.id) + if sp.profile_verification_status == "pending": + raise HTTPException( + status_code=409, + detail="Hồ sơ đang chờ xác minh — không thể chỉnh sửa cho đến khi quản trị xử lý.", + ) + staff_before = staff_row_for_audit(sp, user.unit_id) + user_before = {"fullName": user.full_name, "phone": user.phone} + + if "fullName" in patch and patch["fullName"] is not None: + user.full_name = str(patch["fullName"]) + if "phone" in patch: + user.phone = patch["phone"] if patch["phone"] else None + + if "unitId" in patch: + user.unit_id = patch["unitId"] + if patch["unitId"] is not None: + sp.unit_name_freetext = None + await _assert_unit_exists(session, user.unit_id) + + if "unitNameFreetext" in patch: + sp.unit_name_freetext = patch["unitNameFreetext"] + if sp.unit_name_freetext: + user.unit_id = None + + if "employeeId" in patch: + emp = normalize_employee_id(patch.get("employeeId")) + try: + assert_employee_id_shape(emp) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + sp.employee_id = emp + + if "academicTitleCode" in patch: + code = patch["academicTitleCode"] + await _assert_academic_title_active(session, code) + sp.academic_title_code = code + if code != "other": + sp.academic_title_other = None + + if "academicTitleOther" in patch: + sp.academic_title_other = patch["academicTitleOther"] + + if "jobTitle" in patch: + sp.job_title = patch["jobTitle"] + + try: + assert_unit_exclusive(user, sp) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + staff_after_partial = staff_row_for_audit(sp, user.unit_id) + if material_staff_fields_changed(staff_before, staff_after_partial): + sp.version += 1 + if sp.profile_verification_status == "verified": + apply_reverify_from_verified(sp, now) + + user.updated_at = now + sp.updated_at = now + try: + await session.flush() + except IntegrityError as e: + raise HTTPException( + status_code=409, + detail="Mã số nhân sự đã được sử dụng hoặc dữ liệu không hợp lệ.", + ) from e + + staff_final = staff_row_for_audit(sp, user.unit_id) + roles = await _roles_after_reconcile(session, user) + actor_email, actor_role = await resolve_actor_fields(session, user.id) + user_after = {"fullName": user.full_name, "phone": user.phone} + if user_before != user_after: + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="user", + entity_id=str(user.id), + before=user_before, + after=user_after, + metadata={"source": "auth_profile"}, + ) + if staff_before != staff_final: + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="user_profile", + entity_id=str(user.id), + before=staff_before, + after=staff_final, + metadata={"source": "auth_profile_staff"}, + ) + return _user_public_dict(user, roles, sp) + + +@router.post("/profile/submit-verification") +async def submit_profile_verification(authorization: str | None = Header(None)) -> dict[str, Any]: + if not is_postgres_enabled(): + raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.") + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(401, detail="Vui lòng đăng nhập.") + + now = datetime.now(timezone.utc) + async with get_session() as session: + user = await session.get(User, uid) + if user is None or not user.is_active: + raise HTTPException(401, detail="Tài khoản không còn hiệu lực.") + sp = await _load_staff_profile(session, user.id) + if sp.profile_verification_status not in ("draft", "rejected"): + raise HTTPException(status_code=400, detail="Chỉ gửi xác minh khi hồ sơ ở trạng thái nháp hoặc bị từ chối.") + try: + assert_complete_for_submission(user, sp) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + before = staff_row_for_audit(sp, user.unit_id) + sp.profile_verification_status = "pending" + sp.verification_submitted_at = now + sp.rejection_reason = None + sp.verified_at = None + sp.verified_by_user_id = None + sp.version += 1 + sp.updated_at = now + await session.flush() + after = staff_row_for_audit(sp, user.unit_id) + roles = await _roles_after_reconcile(session, user) + actor_email, actor_role = await resolve_actor_fields(session, user.id) + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="user_profile", + entity_id=str(user.id), + before=before, + after=after, + metadata={"source": "auth_submit_verification"}, + ) + return _user_public_dict(user, roles, sp) + + +class ChangePasswordBody(BaseModel): + currentPassword: str = Field(..., min_length=1) + newPassword: str = Field(..., min_length=1) + newPasswordConfirm: str = Field(..., min_length=1) + + +@router.post("/change-password") +async def change_password( + body: ChangePasswordBody, authorization: str | None = Header(None) +) -> dict[str, Any]: + if not is_postgres_enabled(): + raise HTTPException( + status_code=503, + detail="Đổi mật khẩu yêu cầu cơ sở dữ liệu.", + ) + if body.newPassword != body.newPasswordConfirm: + raise HTTPException(400, detail="Mật khẩu mới xác nhận không khớp.") + _assert_password_policy(body.newPassword) + + uid = decode_access_token_user_id(authorization) + if uid is None: + raise HTTPException(401, detail="Vui lòng đăng nhập.") + + async with get_session() as session: + user = await session.get(User, uid) + if user is None or not user.is_active: + raise HTTPException(401, detail="Tài khoản không còn hiệu lực.") + if not _verify_password(body.currentPassword, user.password_hash): + raise HTTPException(400, detail="Mật khẩu hiện tại không đúng.") + + user.password_hash = _hash_password(body.newPassword) + user.credential_version = int(user.credential_version or 0) + 1 + user.updated_at = datetime.now(timezone.utc) + await session.flush() + actor_email, actor_role = await resolve_actor_fields(session, user.id) + await record_audit( + session, + actor_user_id=user.id, + actor_email=actor_email, + actor_role=actor_role, + action=AuditAction.update, + entity_type="user", + entity_id=str(user.id), + before={"password": "[redacted]"}, + after={"password": "[changed]"}, + metadata={"source": "auth_change_password"}, + ) + roles = await _roles_after_reconcile(session, user) + sp = await _load_staff_profile(session, user.id) + user_payload = _user_public_dict(user, roles, sp) + new_cv = int(user.credential_version) + + token = _issue_token(uid, user_payload["email"], roles, new_cv) + return {"accessToken": token, "user": user_payload} diff --git a/be0/src/auth_credential_middleware.py b/be0/src/auth_credential_middleware.py new file mode 100644 index 0000000..414ffc1 --- /dev/null +++ b/be0/src/auth_credential_middleware.py @@ -0,0 +1,72 @@ +"""Reject JWTs whose credential_version no longer matches the user (password change / reset).""" + +from __future__ import annotations + +import uuid +from typing import Awaitable, Callable + +from fastapi import Request, Response +from starlette.responses import JSONResponse + +from src.auth_jwt import decode_bearer_token, jwt_credential_version_from_payload +from src.initiative_db.engine import get_session, is_postgres_enabled +from src.initiative_db.models import User + +# POST-only auth paths that accept requests without cv check (unauthenticated or pre-migration tokens). +_AUTH_PUBLIC: set[tuple[str, str]] = { + ("/api/v1/auth/register", "POST"), + ("/api/v1/auth/login", "POST"), + ("/api/v1/auth/forgot-password", "POST"), + ("/api/v1/auth/reset-password", "POST"), + ("/api/v1/auth/verify-email", "POST"), + ("/api/v1/auth/resend-verification", "POST"), + ("/api/v1/auth/verify-otp", "POST"), + ("/api/v1/auth/resend-otp", "POST"), +} + + +def _is_public_auth_path(path: str, method: str) -> bool: + return (path, method.upper()) in _AUTH_PUBLIC + + +async def auth_credential_version_middleware( + request: Request, call_next: Callable[[Request], Awaitable[Response]] +) -> Response: + if not is_postgres_enabled(): + return await call_next(request) + + auth = request.headers.get("authorization") + if not auth: + return await call_next(request) + + path = request.url.path + method = request.method.upper() + if _is_public_auth_path(path, method): + return await call_next(request) + + payload = decode_bearer_token(auth) + if payload is None: + return JSONResponse({"detail": "Token không hợp lệ."}, status_code=401) + + try: + uid = uuid.UUID(str(payload["sub"])) + except (KeyError, ValueError): + return JSONResponse({"detail": "Token không hợp lệ."}, status_code=401) + + jwt_cv = jwt_credential_version_from_payload(payload) + + async with get_session() as session: + user = await session.get(User, uid) + if user is None or not user.is_active: + return JSONResponse( + {"detail": "Tài khoản không còn hiệu lực."}, + status_code=401, + ) + db_cv = int(user.credential_version or 0) + if jwt_cv != db_cv: + return JSONResponse( + {"detail": "Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại."}, + status_code=401, + ) + + return await call_next(request) diff --git a/be0/src/auth_jwt.py b/be0/src/auth_jwt.py new file mode 100644 index 0000000..89ea5ea --- /dev/null +++ b/be0/src/auth_jwt.py @@ -0,0 +1,58 @@ +"""JWT decode helpers shared by API routes (no Argon2 import — avoids heavy auth stack).""" + +from __future__ import annotations + +import os +import uuid +from typing import Any + +import jwt + + +def jwt_secret() -> str: + secret = os.getenv("JWT_SECRET", "").strip() + env = os.getenv("ENVIRONMENT", "development").lower() + if not secret: + if env in ("production", "staging"): + raise RuntimeError("JWT_SECRET must be set when ENVIRONMENT is production or staging") + return "dev-only-change-me-jwt-secret-min-32-chars!!" + if len(secret) < 32: + raise ValueError("JWT_SECRET should be at least 32 characters") + return secret + + +def decode_bearer_token(authorization: str | None) -> dict[str, Any] | None: + """Return JWT claims dict or None.""" + if not authorization or not authorization.lower().startswith("bearer "): + return None + raw = authorization.split(None, 1)[1].strip() + try: + return jwt.decode(raw, jwt_secret(), algorithms=["HS256"]) # type: ignore[no-any-return] + except jwt.PyJWTError: + return None + + +def decode_access_token_user_id(authorization: str | None) -> uuid.UUID | None: + payload = decode_bearer_token(authorization) + if not payload or "sub" not in payload: + return None + try: + return uuid.UUID(str(payload["sub"])) + except ValueError: + return None + + +def jwt_credential_version_from_payload(payload: dict[str, Any] | None) -> int: + """ + Credential version embedded in JWT (``cv``). Missing claim defaults to 0 so legacy + tokens issued before ``credential_version`` match users still at version 0. + """ + if not payload: + return 0 + v = payload.get("cv") + if v is None: + return 0 + try: + return int(v) + except (TypeError, ValueError): + return 0 diff --git a/be0/src/auth_mail.py b/be0/src/auth_mail.py new file mode 100644 index 0000000..dd7cd87 --- /dev/null +++ b/be0/src/auth_mail.py @@ -0,0 +1,189 @@ +"""Outbound email for auth (password reset). Configure SMTP or AUTH_MAIL_LOG_ONLY for dev.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import smtplib +import ssl +from email.message import EmailMessage +from typing import Literal + +logger = logging.getLogger(__name__) + +OtpDeliveryChannel = Literal["smtp", "log_only", "none"] + + +def public_web_origin() -> str: + raw = (os.getenv("AUTH_PUBLIC_WEB_ORIGIN") or os.getenv("PUBLIC_WEB_ORIGIN") or "").strip() + if raw: + return raw.rstrip("/") + return "http://localhost:8081" + + +def reset_link(raw_token: str) -> str: + base = public_web_origin() + return f"{base}/reset-password?token={raw_token}" + + +def verify_email_link(raw_token: str) -> str: + base = public_web_origin() + return f"{base}/verify-email?token={raw_token}" + + +def _registration_otp_validity_phrase_vi() -> str: + """Mirror auth_api REGISTER_OTP_TTL_MINUTES default for email copy.""" + raw = os.getenv("REGISTER_OTP_TTL_MINUTES", "1").strip() + try: + minutes = int(raw or "1") + except ValueError: + minutes = 1 + minutes = max(1, min(minutes, 24 * 60)) + if minutes == 1: + return "1 phút" + return f"{minutes} phút" + + + + +def mail_delivery_configured() -> bool: + if os.getenv("AUTH_MAIL_LOG_ONLY", "").lower() in ("1", "true", "yes"): + return True + return bool(os.getenv("SMTP_HOST", "").strip()) + + +def _send_smtp_sync(to_email: str, subject: str, text_body: str, html_body: str) -> None: + host = os.getenv("SMTP_HOST", "").strip() + port = int(os.getenv("SMTP_PORT", "587")) + user = os.getenv("SMTP_USER", "").strip() + password = os.getenv("SMTP_PASSWORD", "").strip() + mail_from = os.getenv("AUTH_MAIL_FROM", user or "noreply@localhost").strip() + + if user and not password: + logger.warning( + "SMTP_USER is set but SMTP_PASSWORD is empty; login will fail (535). " + "With Docker Compose from the repo root, define SMTP_* in the root `.env` " + "so interpolation passes them into the be0 service (be0/.env is not loaded unless env_file is set)." + ) + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = mail_from + msg["To"] = to_email + msg.set_content(text_body) + msg.add_alternative(html_body, subtype="html") + + context = ssl.create_default_context() + with smtplib.SMTP(host, port, timeout=30) as server: + if os.getenv("SMTP_USE_TLS", "1").lower() in ("1", "true", "yes"): + server.starttls(context=context) + if user: + try: + server.login(user, password) + except smtplib.SMTPAuthenticationError as e: + logger.warning( + "SMTP login failed for user %s (code %s): %s. " + "If using Microsoft 365 / Outlook, use the mailbox full email as SMTP_USER, " + "set SMTP_PASSWORD to an app password when MFA is on, and ensure " + "Authenticated SMTP (SMTP AUTH) is enabled for that mailbox in the tenant.", + user, + e.smtp_code, + e.smtp_error.decode(errors="replace") if isinstance(e.smtp_error, bytes) else e.smtp_error, + ) + raise + server.send_message(msg) + + +async def deliver_password_reset_email(to_email: str, raw_token: str) -> None: + """Log link, send via SMTP, or no-op with warning if misconfigured.""" + link = reset_link(raw_token) + if os.getenv("AUTH_MAIL_LOG_ONLY", "").lower() in ("1", "true", "yes"): + logger.info("AUTH_MAIL_LOG_ONLY: password reset link for %s: %s", to_email, link) + return + + host = os.getenv("SMTP_HOST", "").strip() + if not host: + logger.warning( + "Password reset token created but mail is not configured " + "(set SMTP_HOST or AUTH_MAIL_LOG_ONLY=1). User: %s", + to_email, + ) + return + + subject = "Đặt lại mật khẩu — hệ thống sáng kiến" + text_body = ( + "Bạn (hoặc ai đó) đã yêu cầu đặt lại mật khẩu.\n\n" + f"Mở liên kết sau (hiệu lực giới hạn):\n{link}\n\n" + "Nếu bạn không yêu cầu, hãy bỏ qua email này." + ) + html_body = ( + "