From 6e3127e7d68f1ef56a4118a26645d51e7e2326fb Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 4 Apr 2026 23:20:12 -0700 Subject: [PATCH] feat: DataViz Pro full-stack data visualization platform - Frontend: Next.js + React + TypeScript + ECharts + Ant Design + Redux/Zustand (Clean Architecture) - Backend: FastAPI + PostgreSQL + SQLAlchemy (DDD + Clean Architecture + Microservices) - 4 microservices: data-service, chart-service, template-service, export-service - 15+ chart types, drag-drop layout, multi-format export - Docker Compose orchestration Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 58 ++ README.md | 3 + backend/docker-compose.yml | 77 ++ backend/init-databases.sql | 4 + backend/pyproject.toml | 19 + backend/services/chart-service/Dockerfile | 29 + .../chart-service/alembic/alembic.ini | 37 + backend/services/chart-service/alembic/env.py | 69 ++ .../alembic/versions/__init__.py | 1 + backend/services/chart-service/pyproject.toml | 15 + .../services/chart-service/src/__init__.py | 1 + .../chart-service/src/adapters/__init__.py | 1 + .../src/adapters/clients/__init__.py | 1 + .../clients/data_service_client_impl.py | 29 + .../src/adapters/option_builders/__init__.py | 1 + .../adapters/option_builders/bar_builder.py | 198 +++++ .../option_builders/builder_factory.py | 127 +++ .../adapters/option_builders/combo_builder.py | 102 +++ .../option_builders/heatmap_builder.py | 75 ++ .../adapters/option_builders/line_builder.py | 116 +++ .../adapters/option_builders/map_builder.py | 63 ++ .../adapters/option_builders/pie_builder.py | 80 ++ .../adapters/option_builders/radar_builder.py | 89 ++ .../option_builders/scatter_builder.py | 89 ++ .../option_builders/wordcloud_builder.py | 60 ++ .../src/adapters/persistence/__init__.py | 1 + .../persistence/chart_repository_impl.py | 85 ++ .../src/adapters/persistence/database.py | 38 + .../src/adapters/persistence/models.py | 36 + .../src/adapters/presenters/__init__.py | 1 + .../adapters/presenters/chart_presenter.py | 34 + .../chart-service/src/application/__init__.py | 1 + .../src/application/dto/__init__.py | 1 + .../src/application/dto/chart_response.py | 47 + .../src/application/dto/echarts_option.py | 13 + .../src/application/ports/__init__.py | 1 + .../src/application/ports/input/__init__.py | 1 + .../application/ports/input/create_chart.py | 10 + .../ports/input/get_chart_option.py | 10 + .../ports/input/recommend_charts.py | 9 + .../application/ports/input/update_chart.py | 13 + .../src/application/ports/output/__init__.py | 1 + .../ports/output/data_service_client.py | 14 + .../src/application/usecases/__init__.py | 1 + .../usecases/create_chart_usecase.py | 63 ++ .../usecases/get_chart_option_usecase.py | 89 ++ .../usecases/recommend_charts_usecase.py | 18 + .../usecases/update_chart_usecase.py | 76 ++ .../chart-service/src/domain/__init__.py | 1 + .../src/domain/entities/__init__.py | 7 + .../src/domain/entities/chart_instance.py | 48 ++ .../src/domain/entities/field_binding.py | 27 + .../src/domain/entities/style_config.py | 6 + .../src/domain/repositories/__init__.py | 1 + .../domain/repositories/chart_repository.py | 9 + .../src/domain/services/__init__.py | 1 + .../src/domain/services/binding_validation.py | 105 +++ .../domain/services/chart_recommendation.py | 96 +++ .../src/domain/services/option_builder.py | 17 + .../src/domain/value_objects/__init__.py | 5 + .../src/domain/value_objects/chart_type.py | 5 + .../src/infrastructure/__init__.py | 1 + .../src/infrastructure/api/__init__.py | 1 + .../src/infrastructure/api/app.py | 44 + .../src/infrastructure/api/dependencies.py | 73 ++ .../src/infrastructure/api/routes.py | 112 +++ .../src/infrastructure/config.py | 16 + .../chart-service/src/infrastructure/main.py | 16 + backend/services/data-service/Dockerfile | 29 + .../services/data-service/alembic/alembic.ini | 37 + backend/services/data-service/alembic/env.py | 69 ++ .../data-service/alembic/versions/__init__.py | 0 backend/services/data-service/pyproject.toml | 18 + backend/services/data-service/src/__init__.py | 0 .../data-service/src/adapters/__init__.py | 0 .../src/adapters/parsers/__init__.py | 0 .../src/adapters/parsers/csv_parser.py | 29 + .../src/adapters/parsers/json_parser.py | 36 + .../src/adapters/parsers/parser_factory.py | 40 + .../src/adapters/parsers/xlsx_parser.py | 74 ++ .../src/adapters/persistence/__init__.py | 0 .../src/adapters/persistence/database.py | 38 + .../persistence/dataset_repository_impl.py | 133 +++ .../src/adapters/persistence/models.py | 66 ++ .../src/adapters/presenters/__init__.py | 0 .../adapters/presenters/dataset_presenter.py | 31 + .../data-service/src/application/__init__.py | 0 .../src/application/dto/__init__.py | 6 + .../src/application/dto/dataset_response.py | 24 + .../src/application/dto/import_result.py | 21 + .../src/application/ports/__init__.py | 0 .../src/application/ports/input/__init__.py | 7 + .../application/ports/input/get_dataset.py | 11 + .../application/ports/input/import_data.py | 10 + .../application/ports/input/list_datasets.py | 10 + .../src/application/ports/output/__init__.py | 5 + .../application/ports/output/file_parser.py | 17 + .../src/application/usecases/__init__.py | 13 + .../usecases/delete_dataset_usecase.py | 18 + .../usecases/get_dataset_usecase.py | 39 + .../usecases/import_data_usecase.py | 101 +++ .../usecases/list_datasets_usecase.py | 36 + .../data-service/src/domain/__init__.py | 0 .../src/domain/entities/__init__.py | 7 + .../src/domain/entities/column.py | 20 + .../src/domain/entities/data_row.py | 5 + .../src/domain/entities/dataset.py | 60 ++ .../src/domain/repositories/__init__.py | 5 + .../domain/repositories/dataset_repository.py | 9 + .../src/domain/services/__init__.py | 6 + .../services/data_structure_inference.py | 69 ++ .../domain/services/field_type_inference.py | 123 +++ .../src/domain/value_objects/__init__.py | 7 + .../domain/value_objects/data_structure.py | 5 + .../src/domain/value_objects/field_type.py | 5 + .../src/domain/value_objects/file_format.py | 5 + .../src/infrastructure/__init__.py | 0 .../src/infrastructure/api/__init__.py | 0 .../src/infrastructure/api/app.py | 44 + .../src/infrastructure/api/dependencies.py | 57 ++ .../src/infrastructure/api/routes.py | 126 +++ .../data-service/src/infrastructure/config.py | 15 + .../data-service/src/infrastructure/main.py | 16 + backend/services/export-service/Dockerfile | 38 + backend/services/export-service/alembic.ini | 37 + .../services/export-service/alembic/env.py | 59 ++ .../alembic/versions/__init__.py | 0 .../services/export-service/pyproject.toml | 24 + .../services/export-service/src/__init__.py | 0 .../export-service/src/adapters/__init__.py | 0 .../src/adapters/clients/__init__.py | 0 .../clients/chart_service_client_impl.py | 25 + .../clients/data_service_client_impl.py | 25 + .../src/adapters/generators/__init__.py | 0 .../generators/jinja2_html_generator.py | 60 ++ .../generators/openpyxl_excel_generator.py | 44 + .../generators/playwright_image_renderer.py | 53 ++ .../src/adapters/generators/pptx_generator.py | 43 + .../generators/weasyprint_pdf_generator.py | 54 ++ .../src/adapters/persistence/__init__.py | 0 .../src/adapters/persistence/database.py | 30 + .../export_task_repository_impl.py | 96 +++ .../src/adapters/persistence/models.py | 40 + .../src/adapters/presenters/__init__.py | 0 .../adapters/presenters/export_presenter.py | 18 + .../src/application/__init__.py | 0 .../src/application/dto/__init__.py | 6 + .../src/application/dto/export_request.py | 14 + .../src/application/dto/export_response.py | 15 + .../src/application/ports/__init__.py | 0 .../src/application/ports/input/__init__.py | 6 + .../application/ports/input/create_export.py | 12 + .../ports/input/get_export_status.py | 12 + .../src/application/ports/output/__init__.py | 19 + .../ports/output/chart_service_client.py | 15 + .../ports/output/data_service_client.py | 15 + .../ports/output/excel_generator.py | 12 + .../ports/output/html_generator.py | 10 + .../ports/output/image_renderer.py | 10 + .../application/ports/output/pdf_generator.py | 13 + .../application/ports/output/ppt_generator.py | 13 + .../src/application/usecases/__init__.py | 6 + .../usecases/create_export_usecase.py | 144 ++++ .../usecases/get_export_status_usecase.py | 25 + .../export-service/src/domain/__init__.py | 0 .../src/domain/entities/__init__.py | 5 + .../src/domain/entities/export_task.py | 39 + .../src/domain/repositories/__init__.py | 5 + .../repositories/export_task_repository.py | 15 + .../src/domain/services/__init__.py | 5 + .../src/domain/services/export_strategy.py | 18 + .../src/domain/value_objects/__init__.py | 6 + .../src/domain/value_objects/export_format.py | 5 + .../src/domain/value_objects/export_status.py | 5 + .../src/infrastructure/__init__.py | 0 .../src/infrastructure/api/__init__.py | 0 .../src/infrastructure/api/app.py | 41 + .../src/infrastructure/api/dependencies.py | 100 +++ .../src/infrastructure/api/routes.py | 54 ++ .../src/infrastructure/config.py | 18 + .../export-service/src/infrastructure/main.py | 16 + backend/services/template-service/Dockerfile | 29 + backend/services/template-service/alembic.ini | 38 + .../services/template-service/alembic/env.py | 59 ++ .../alembic/versions/__init__.py | 0 .../services/template-service/pyproject.toml | 18 + .../services/template-service/src/__init__.py | 1 + .../template-service/src/adapters/__init__.py | 1 + .../src/adapters/persistence/__init__.py | 1 + .../src/adapters/persistence/database.py | 28 + .../src/adapters/persistence/models.py | 42 + .../persistence/template_repository_impl.py | 92 ++ .../src/adapters/presenters/__init__.py | 1 + .../adapters/presenters/template_presenter.py | 27 + .../src/application/__init__.py | 1 + .../src/application/dto/__init__.py | 5 + .../src/application/dto/template_response.py | 36 + .../src/application/ports/__init__.py | 1 + .../src/application/ports/input/__init__.py | 14 + .../ports/input/import_export_template.py | 17 + .../application/ports/input/list_templates.py | 14 + .../application/ports/input/load_template.py | 11 + .../application/ports/input/save_template.py | 10 + .../src/application/ports/output/__init__.py | 5 + .../ports/output/template_storage.py | 25 + .../src/application/usecases/__init__.py | 16 + .../usecases/delete_template_usecase.py | 18 + .../usecases/import_export_usecase.py | 52 ++ .../usecases/list_templates_usecase.py | 36 + .../usecases/load_template_usecase.py | 30 + .../usecases/save_template_usecase.py | 47 + .../template-service/src/domain/__init__.py | 1 + .../src/domain/entities/__init__.py | 5 + .../src/domain/entities/template.py | 65 ++ .../src/domain/repositories/__init__.py | 5 + .../repositories/template_repository.py | 12 + .../src/domain/services/__init__.py | 5 + .../domain/services/template_validation.py | 25 + .../src/domain/value_objects/__init__.py | 5 + .../src/domain/value_objects/template_type.py | 5 + .../src/infrastructure/__init__.py | 1 + .../src/infrastructure/api/__init__.py | 1 + .../src/infrastructure/api/app.py | 41 + .../src/infrastructure/api/dependencies.py | 57 ++ .../src/infrastructure/api/routes.py | 154 ++++ .../src/infrastructure/config.py | 15 + .../src/infrastructure/main.py | 16 + .../seed/builtin_templates.json | 188 ++++ backend/shared/__init__.py | 0 backend/shared/base_entity.py | 14 + backend/shared/base_repository.py | 19 + backend/shared/base_use_case.py | 10 + backend/shared/exceptions.py | 24 + backend/shared/http_client.py | 33 + backend/shared/types.py | 73 ++ backend/tests/chart-service/__init__.py | 0 .../chart-service/integration/__init__.py | 0 backend/tests/chart-service/unit/__init__.py | 0 .../unit/application/__init__.py | 0 .../chart-service/unit/domain/__init__.py | 0 .../unit/domain/test_binding_validation.py | 88 ++ .../unit/domain/test_chart_recommendation.py | 19 + backend/tests/data-service/__init__.py | 0 .../data-service/integration/__init__.py | 0 .../data-service/integration/test_api.py | 15 + backend/tests/data-service/unit/__init__.py | 0 .../data-service/unit/application/__init__.py | 0 .../application/test_import_data_usecase.py | 15 + .../data-service/unit/domain/__init__.py | 0 .../domain/test_data_structure_inference.py | 20 + .../unit/domain/test_field_type_inference.py | 87 ++ backend/tests/export-service/__init__.py | 0 .../export-service/integration/__init__.py | 0 backend/tests/export-service/unit/__init__.py | 0 .../unit/application/__init__.py | 0 .../export-service/unit/domain/__init__.py | 0 .../unit/domain/test_export_task.py | 109 +++ backend/tests/template-service/__init__.py | 0 .../template-service/integration/__init__.py | 0 .../tests/template-service/unit/__init__.py | 0 .../unit/application/__init__.py | 0 .../template-service/unit/domain/__init__.py | 0 .../unit/domain/test_template_validation.py | 53 ++ docs/backend-architecture-guide.md | 804 ++++++++++++++++++ docs/frontend-architecture-guide.md | 466 ++++++++++ 265 files changed, 8450 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/docker-compose.yml create mode 100644 backend/init-databases.sql create mode 100644 backend/pyproject.toml create mode 100644 backend/services/chart-service/Dockerfile create mode 100644 backend/services/chart-service/alembic/alembic.ini create mode 100644 backend/services/chart-service/alembic/env.py create mode 100644 backend/services/chart-service/alembic/versions/__init__.py create mode 100644 backend/services/chart-service/pyproject.toml create mode 100644 backend/services/chart-service/src/__init__.py create mode 100644 backend/services/chart-service/src/adapters/__init__.py create mode 100644 backend/services/chart-service/src/adapters/clients/__init__.py create mode 100644 backend/services/chart-service/src/adapters/clients/data_service_client_impl.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/__init__.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/bar_builder.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/builder_factory.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/combo_builder.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/heatmap_builder.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/line_builder.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/map_builder.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/pie_builder.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/radar_builder.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/scatter_builder.py create mode 100644 backend/services/chart-service/src/adapters/option_builders/wordcloud_builder.py create mode 100644 backend/services/chart-service/src/adapters/persistence/__init__.py create mode 100644 backend/services/chart-service/src/adapters/persistence/chart_repository_impl.py create mode 100644 backend/services/chart-service/src/adapters/persistence/database.py create mode 100644 backend/services/chart-service/src/adapters/persistence/models.py create mode 100644 backend/services/chart-service/src/adapters/presenters/__init__.py create mode 100644 backend/services/chart-service/src/adapters/presenters/chart_presenter.py create mode 100644 backend/services/chart-service/src/application/__init__.py create mode 100644 backend/services/chart-service/src/application/dto/__init__.py create mode 100644 backend/services/chart-service/src/application/dto/chart_response.py create mode 100644 backend/services/chart-service/src/application/dto/echarts_option.py create mode 100644 backend/services/chart-service/src/application/ports/__init__.py create mode 100644 backend/services/chart-service/src/application/ports/input/__init__.py create mode 100644 backend/services/chart-service/src/application/ports/input/create_chart.py create mode 100644 backend/services/chart-service/src/application/ports/input/get_chart_option.py create mode 100644 backend/services/chart-service/src/application/ports/input/recommend_charts.py create mode 100644 backend/services/chart-service/src/application/ports/input/update_chart.py create mode 100644 backend/services/chart-service/src/application/ports/output/__init__.py create mode 100644 backend/services/chart-service/src/application/ports/output/data_service_client.py create mode 100644 backend/services/chart-service/src/application/usecases/__init__.py create mode 100644 backend/services/chart-service/src/application/usecases/create_chart_usecase.py create mode 100644 backend/services/chart-service/src/application/usecases/get_chart_option_usecase.py create mode 100644 backend/services/chart-service/src/application/usecases/recommend_charts_usecase.py create mode 100644 backend/services/chart-service/src/application/usecases/update_chart_usecase.py create mode 100644 backend/services/chart-service/src/domain/__init__.py create mode 100644 backend/services/chart-service/src/domain/entities/__init__.py create mode 100644 backend/services/chart-service/src/domain/entities/chart_instance.py create mode 100644 backend/services/chart-service/src/domain/entities/field_binding.py create mode 100644 backend/services/chart-service/src/domain/entities/style_config.py create mode 100644 backend/services/chart-service/src/domain/repositories/__init__.py create mode 100644 backend/services/chart-service/src/domain/repositories/chart_repository.py create mode 100644 backend/services/chart-service/src/domain/services/__init__.py create mode 100644 backend/services/chart-service/src/domain/services/binding_validation.py create mode 100644 backend/services/chart-service/src/domain/services/chart_recommendation.py create mode 100644 backend/services/chart-service/src/domain/services/option_builder.py create mode 100644 backend/services/chart-service/src/domain/value_objects/__init__.py create mode 100644 backend/services/chart-service/src/domain/value_objects/chart_type.py create mode 100644 backend/services/chart-service/src/infrastructure/__init__.py create mode 100644 backend/services/chart-service/src/infrastructure/api/__init__.py create mode 100644 backend/services/chart-service/src/infrastructure/api/app.py create mode 100644 backend/services/chart-service/src/infrastructure/api/dependencies.py create mode 100644 backend/services/chart-service/src/infrastructure/api/routes.py create mode 100644 backend/services/chart-service/src/infrastructure/config.py create mode 100644 backend/services/chart-service/src/infrastructure/main.py create mode 100644 backend/services/data-service/Dockerfile create mode 100644 backend/services/data-service/alembic/alembic.ini create mode 100644 backend/services/data-service/alembic/env.py create mode 100644 backend/services/data-service/alembic/versions/__init__.py create mode 100644 backend/services/data-service/pyproject.toml create mode 100644 backend/services/data-service/src/__init__.py create mode 100644 backend/services/data-service/src/adapters/__init__.py create mode 100644 backend/services/data-service/src/adapters/parsers/__init__.py create mode 100644 backend/services/data-service/src/adapters/parsers/csv_parser.py create mode 100644 backend/services/data-service/src/adapters/parsers/json_parser.py create mode 100644 backend/services/data-service/src/adapters/parsers/parser_factory.py create mode 100644 backend/services/data-service/src/adapters/parsers/xlsx_parser.py create mode 100644 backend/services/data-service/src/adapters/persistence/__init__.py create mode 100644 backend/services/data-service/src/adapters/persistence/database.py create mode 100644 backend/services/data-service/src/adapters/persistence/dataset_repository_impl.py create mode 100644 backend/services/data-service/src/adapters/persistence/models.py create mode 100644 backend/services/data-service/src/adapters/presenters/__init__.py create mode 100644 backend/services/data-service/src/adapters/presenters/dataset_presenter.py create mode 100644 backend/services/data-service/src/application/__init__.py create mode 100644 backend/services/data-service/src/application/dto/__init__.py create mode 100644 backend/services/data-service/src/application/dto/dataset_response.py create mode 100644 backend/services/data-service/src/application/dto/import_result.py create mode 100644 backend/services/data-service/src/application/ports/__init__.py create mode 100644 backend/services/data-service/src/application/ports/input/__init__.py create mode 100644 backend/services/data-service/src/application/ports/input/get_dataset.py create mode 100644 backend/services/data-service/src/application/ports/input/import_data.py create mode 100644 backend/services/data-service/src/application/ports/input/list_datasets.py create mode 100644 backend/services/data-service/src/application/ports/output/__init__.py create mode 100644 backend/services/data-service/src/application/ports/output/file_parser.py create mode 100644 backend/services/data-service/src/application/usecases/__init__.py create mode 100644 backend/services/data-service/src/application/usecases/delete_dataset_usecase.py create mode 100644 backend/services/data-service/src/application/usecases/get_dataset_usecase.py create mode 100644 backend/services/data-service/src/application/usecases/import_data_usecase.py create mode 100644 backend/services/data-service/src/application/usecases/list_datasets_usecase.py create mode 100644 backend/services/data-service/src/domain/__init__.py create mode 100644 backend/services/data-service/src/domain/entities/__init__.py create mode 100644 backend/services/data-service/src/domain/entities/column.py create mode 100644 backend/services/data-service/src/domain/entities/data_row.py create mode 100644 backend/services/data-service/src/domain/entities/dataset.py create mode 100644 backend/services/data-service/src/domain/repositories/__init__.py create mode 100644 backend/services/data-service/src/domain/repositories/dataset_repository.py create mode 100644 backend/services/data-service/src/domain/services/__init__.py create mode 100644 backend/services/data-service/src/domain/services/data_structure_inference.py create mode 100644 backend/services/data-service/src/domain/services/field_type_inference.py create mode 100644 backend/services/data-service/src/domain/value_objects/__init__.py create mode 100644 backend/services/data-service/src/domain/value_objects/data_structure.py create mode 100644 backend/services/data-service/src/domain/value_objects/field_type.py create mode 100644 backend/services/data-service/src/domain/value_objects/file_format.py create mode 100644 backend/services/data-service/src/infrastructure/__init__.py create mode 100644 backend/services/data-service/src/infrastructure/api/__init__.py create mode 100644 backend/services/data-service/src/infrastructure/api/app.py create mode 100644 backend/services/data-service/src/infrastructure/api/dependencies.py create mode 100644 backend/services/data-service/src/infrastructure/api/routes.py create mode 100644 backend/services/data-service/src/infrastructure/config.py create mode 100644 backend/services/data-service/src/infrastructure/main.py create mode 100644 backend/services/export-service/Dockerfile create mode 100644 backend/services/export-service/alembic.ini create mode 100644 backend/services/export-service/alembic/env.py create mode 100644 backend/services/export-service/alembic/versions/__init__.py create mode 100644 backend/services/export-service/pyproject.toml create mode 100644 backend/services/export-service/src/__init__.py create mode 100644 backend/services/export-service/src/adapters/__init__.py create mode 100644 backend/services/export-service/src/adapters/clients/__init__.py create mode 100644 backend/services/export-service/src/adapters/clients/chart_service_client_impl.py create mode 100644 backend/services/export-service/src/adapters/clients/data_service_client_impl.py create mode 100644 backend/services/export-service/src/adapters/generators/__init__.py create mode 100644 backend/services/export-service/src/adapters/generators/jinja2_html_generator.py create mode 100644 backend/services/export-service/src/adapters/generators/openpyxl_excel_generator.py create mode 100644 backend/services/export-service/src/adapters/generators/playwright_image_renderer.py create mode 100644 backend/services/export-service/src/adapters/generators/pptx_generator.py create mode 100644 backend/services/export-service/src/adapters/generators/weasyprint_pdf_generator.py create mode 100644 backend/services/export-service/src/adapters/persistence/__init__.py create mode 100644 backend/services/export-service/src/adapters/persistence/database.py create mode 100644 backend/services/export-service/src/adapters/persistence/export_task_repository_impl.py create mode 100644 backend/services/export-service/src/adapters/persistence/models.py create mode 100644 backend/services/export-service/src/adapters/presenters/__init__.py create mode 100644 backend/services/export-service/src/adapters/presenters/export_presenter.py create mode 100644 backend/services/export-service/src/application/__init__.py create mode 100644 backend/services/export-service/src/application/dto/__init__.py create mode 100644 backend/services/export-service/src/application/dto/export_request.py create mode 100644 backend/services/export-service/src/application/dto/export_response.py create mode 100644 backend/services/export-service/src/application/ports/__init__.py create mode 100644 backend/services/export-service/src/application/ports/input/__init__.py create mode 100644 backend/services/export-service/src/application/ports/input/create_export.py create mode 100644 backend/services/export-service/src/application/ports/input/get_export_status.py create mode 100644 backend/services/export-service/src/application/ports/output/__init__.py create mode 100644 backend/services/export-service/src/application/ports/output/chart_service_client.py create mode 100644 backend/services/export-service/src/application/ports/output/data_service_client.py create mode 100644 backend/services/export-service/src/application/ports/output/excel_generator.py create mode 100644 backend/services/export-service/src/application/ports/output/html_generator.py create mode 100644 backend/services/export-service/src/application/ports/output/image_renderer.py create mode 100644 backend/services/export-service/src/application/ports/output/pdf_generator.py create mode 100644 backend/services/export-service/src/application/ports/output/ppt_generator.py create mode 100644 backend/services/export-service/src/application/usecases/__init__.py create mode 100644 backend/services/export-service/src/application/usecases/create_export_usecase.py create mode 100644 backend/services/export-service/src/application/usecases/get_export_status_usecase.py create mode 100644 backend/services/export-service/src/domain/__init__.py create mode 100644 backend/services/export-service/src/domain/entities/__init__.py create mode 100644 backend/services/export-service/src/domain/entities/export_task.py create mode 100644 backend/services/export-service/src/domain/repositories/__init__.py create mode 100644 backend/services/export-service/src/domain/repositories/export_task_repository.py create mode 100644 backend/services/export-service/src/domain/services/__init__.py create mode 100644 backend/services/export-service/src/domain/services/export_strategy.py create mode 100644 backend/services/export-service/src/domain/value_objects/__init__.py create mode 100644 backend/services/export-service/src/domain/value_objects/export_format.py create mode 100644 backend/services/export-service/src/domain/value_objects/export_status.py create mode 100644 backend/services/export-service/src/infrastructure/__init__.py create mode 100644 backend/services/export-service/src/infrastructure/api/__init__.py create mode 100644 backend/services/export-service/src/infrastructure/api/app.py create mode 100644 backend/services/export-service/src/infrastructure/api/dependencies.py create mode 100644 backend/services/export-service/src/infrastructure/api/routes.py create mode 100644 backend/services/export-service/src/infrastructure/config.py create mode 100644 backend/services/export-service/src/infrastructure/main.py create mode 100644 backend/services/template-service/Dockerfile create mode 100644 backend/services/template-service/alembic.ini create mode 100644 backend/services/template-service/alembic/env.py create mode 100644 backend/services/template-service/alembic/versions/__init__.py create mode 100644 backend/services/template-service/pyproject.toml create mode 100644 backend/services/template-service/src/__init__.py create mode 100644 backend/services/template-service/src/adapters/__init__.py create mode 100644 backend/services/template-service/src/adapters/persistence/__init__.py create mode 100644 backend/services/template-service/src/adapters/persistence/database.py create mode 100644 backend/services/template-service/src/adapters/persistence/models.py create mode 100644 backend/services/template-service/src/adapters/persistence/template_repository_impl.py create mode 100644 backend/services/template-service/src/adapters/presenters/__init__.py create mode 100644 backend/services/template-service/src/adapters/presenters/template_presenter.py create mode 100644 backend/services/template-service/src/application/__init__.py create mode 100644 backend/services/template-service/src/application/dto/__init__.py create mode 100644 backend/services/template-service/src/application/dto/template_response.py create mode 100644 backend/services/template-service/src/application/ports/__init__.py create mode 100644 backend/services/template-service/src/application/ports/input/__init__.py create mode 100644 backend/services/template-service/src/application/ports/input/import_export_template.py create mode 100644 backend/services/template-service/src/application/ports/input/list_templates.py create mode 100644 backend/services/template-service/src/application/ports/input/load_template.py create mode 100644 backend/services/template-service/src/application/ports/input/save_template.py create mode 100644 backend/services/template-service/src/application/ports/output/__init__.py create mode 100644 backend/services/template-service/src/application/ports/output/template_storage.py create mode 100644 backend/services/template-service/src/application/usecases/__init__.py create mode 100644 backend/services/template-service/src/application/usecases/delete_template_usecase.py create mode 100644 backend/services/template-service/src/application/usecases/import_export_usecase.py create mode 100644 backend/services/template-service/src/application/usecases/list_templates_usecase.py create mode 100644 backend/services/template-service/src/application/usecases/load_template_usecase.py create mode 100644 backend/services/template-service/src/application/usecases/save_template_usecase.py create mode 100644 backend/services/template-service/src/domain/__init__.py create mode 100644 backend/services/template-service/src/domain/entities/__init__.py create mode 100644 backend/services/template-service/src/domain/entities/template.py create mode 100644 backend/services/template-service/src/domain/repositories/__init__.py create mode 100644 backend/services/template-service/src/domain/repositories/template_repository.py create mode 100644 backend/services/template-service/src/domain/services/__init__.py create mode 100644 backend/services/template-service/src/domain/services/template_validation.py create mode 100644 backend/services/template-service/src/domain/value_objects/__init__.py create mode 100644 backend/services/template-service/src/domain/value_objects/template_type.py create mode 100644 backend/services/template-service/src/infrastructure/__init__.py create mode 100644 backend/services/template-service/src/infrastructure/api/__init__.py create mode 100644 backend/services/template-service/src/infrastructure/api/app.py create mode 100644 backend/services/template-service/src/infrastructure/api/dependencies.py create mode 100644 backend/services/template-service/src/infrastructure/api/routes.py create mode 100644 backend/services/template-service/src/infrastructure/config.py create mode 100644 backend/services/template-service/src/infrastructure/main.py create mode 100644 backend/services/template-service/src/infrastructure/seed/builtin_templates.json create mode 100644 backend/shared/__init__.py create mode 100644 backend/shared/base_entity.py create mode 100644 backend/shared/base_repository.py create mode 100644 backend/shared/base_use_case.py create mode 100644 backend/shared/exceptions.py create mode 100644 backend/shared/http_client.py create mode 100644 backend/shared/types.py create mode 100644 backend/tests/chart-service/__init__.py create mode 100644 backend/tests/chart-service/integration/__init__.py create mode 100644 backend/tests/chart-service/unit/__init__.py create mode 100644 backend/tests/chart-service/unit/application/__init__.py create mode 100644 backend/tests/chart-service/unit/domain/__init__.py create mode 100644 backend/tests/chart-service/unit/domain/test_binding_validation.py create mode 100644 backend/tests/chart-service/unit/domain/test_chart_recommendation.py create mode 100644 backend/tests/data-service/__init__.py create mode 100644 backend/tests/data-service/integration/__init__.py create mode 100644 backend/tests/data-service/integration/test_api.py create mode 100644 backend/tests/data-service/unit/__init__.py create mode 100644 backend/tests/data-service/unit/application/__init__.py create mode 100644 backend/tests/data-service/unit/application/test_import_data_usecase.py create mode 100644 backend/tests/data-service/unit/domain/__init__.py create mode 100644 backend/tests/data-service/unit/domain/test_data_structure_inference.py create mode 100644 backend/tests/data-service/unit/domain/test_field_type_inference.py create mode 100644 backend/tests/export-service/__init__.py create mode 100644 backend/tests/export-service/integration/__init__.py create mode 100644 backend/tests/export-service/unit/__init__.py create mode 100644 backend/tests/export-service/unit/application/__init__.py create mode 100644 backend/tests/export-service/unit/domain/__init__.py create mode 100644 backend/tests/export-service/unit/domain/test_export_task.py create mode 100644 backend/tests/template-service/__init__.py create mode 100644 backend/tests/template-service/integration/__init__.py create mode 100644 backend/tests/template-service/unit/__init__.py create mode 100644 backend/tests/template-service/unit/application/__init__.py create mode 100644 backend/tests/template-service/unit/domain/__init__.py create mode 100644 backend/tests/template-service/unit/domain/test_template_validation.py create mode 100644 docs/backend-architecture-guide.md create mode 100644 docs/frontend-architecture-guide.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d49528 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Dependencies +node_modules/ +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +env/ + +# Build outputs +frontend/out/ +frontend/.next/ +*.egg-info/ +dist/ +build/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Environment +.env +.env.local +.env.*.local +!.env.example + +# Logs +*.log +npm-debug.log* + +# Docker volumes +backend/pgdata/ + +# Exports +backend/services/export-service/exports/ + +# Python +*.egg +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +htmlcov/ +.coverage + +# Alembic +backend/services/*/alembic/versions/*.py +!backend/services/*/alembic/versions/__init__.py + +# Package lock (keep package-lock.json for frontend) +poetry.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..af200de --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# DataViz Pro + +Data visualization platform for statistical analysis and chart rendering. diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..c8213d4 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,77 @@ +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: dataviz + POSTGRES_PASSWORD: dataviz_local + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./init-databases.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dataviz"] + interval: 5s + retries: 5 + + data-service: + build: + context: . + dockerfile: services/data-service/Dockerfile + ports: + - "8001:8000" + environment: + DATABASE_URL: postgresql+asyncpg://dataviz:dataviz_local@postgres:5432/data_db + depends_on: + postgres: + condition: service_healthy + + chart-service: + build: + context: . + dockerfile: services/chart-service/Dockerfile + ports: + - "8002:8000" + environment: + DATABASE_URL: postgresql+asyncpg://dataviz:dataviz_local@postgres:5432/chart_db + DATA_SERVICE_URL: http://data-service:8000 + depends_on: + postgres: + condition: service_healthy + data-service: + condition: service_started + + template-service: + build: + context: . + dockerfile: services/template-service/Dockerfile + ports: + - "8003:8000" + environment: + DATABASE_URL: postgresql+asyncpg://dataviz:dataviz_local@postgres:5432/template_db + depends_on: + postgres: + condition: service_healthy + + export-service: + build: + context: . + dockerfile: services/export-service/Dockerfile + ports: + - "8004:8000" + environment: + DATABASE_URL: postgresql+asyncpg://dataviz:dataviz_local@postgres:5432/export_db + DATA_SERVICE_URL: http://data-service:8000 + CHART_SERVICE_URL: http://chart-service:8000 + depends_on: + postgres: + condition: service_healthy + data-service: + condition: service_started + chart-service: + condition: service_started + +volumes: + pgdata: diff --git a/backend/init-databases.sql b/backend/init-databases.sql new file mode 100644 index 0000000..8a9b816 --- /dev/null +++ b/backend/init-databases.sql @@ -0,0 +1,4 @@ +CREATE DATABASE data_db; +CREATE DATABASE chart_db; +CREATE DATABASE template_db; +CREATE DATABASE export_db; diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..3c40802 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,19 @@ +[tool.poetry] +name = "dataviz-pro-backend" +version = "0.1.0" +description = "DataViz Pro Backend Monorepo" +packages = [{include = "shared"}] + +[tool.poetry.dependencies] +python = "^3.12" + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.mypy] +python_version = "3.12" +strict = true diff --git a/backend/services/chart-service/Dockerfile b/backend/services/chart-service/Dockerfile new file mode 100644 index 0000000..b8f297a --- /dev/null +++ b/backend/services/chart-service/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev && \ + rm -rf /var/lib/apt/lists/* + +# Copy shared library +COPY shared/ /app/shared/ + +# Copy service code +COPY services/chart-service/pyproject.toml /app/ +COPY services/chart-service/src/ /app/src/ +COPY services/chart-service/alembic/ /app/alembic/ +COPY services/chart-service/alembic.ini /app/alembic.ini + +# Install dependencies +RUN pip install --no-cache-dir poetry && \ + poetry config virtualenvs.create false && \ + poetry install --no-interaction --no-ansi --no-root + +# Set PYTHONPATH to include shared +ENV PYTHONPATH=/app:/app/src + +EXPOSE 8000 + +CMD ["python", "-m", "src.infrastructure.main"] diff --git a/backend/services/chart-service/alembic/alembic.ini b/backend/services/chart-service/alembic/alembic.ini new file mode 100644 index 0000000..68b87e7 --- /dev/null +++ b/backend/services/chart-service/alembic/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = . +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/services/chart-service/alembic/env.py b/backend/services/chart-service/alembic/env.py new file mode 100644 index 0000000..58286ff --- /dev/null +++ b/backend/services/chart-service/alembic/env.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import asyncio +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import create_async_engine + +from src.adapters.persistence.models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_database_url() -> str: + return os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://postgres:postgres@localhost:5432/chart_db", + ) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): # noqa: ANN001 + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode using an async engine.""" + connectable = create_async_engine( + get_database_url(), + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/services/chart-service/alembic/versions/__init__.py b/backend/services/chart-service/alembic/versions/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/alembic/versions/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/pyproject.toml b/backend/services/chart-service/pyproject.toml new file mode 100644 index 0000000..388fc75 --- /dev/null +++ b/backend/services/chart-service/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "chart-service" +version = "0.1.0" +description = "DataViz Pro Chart Service" + +[tool.poetry.dependencies] +python = "^3.12" +fastapi = "^0.115" +uvicorn = {extras = ["standard"], version = "^0.34"} +sqlalchemy = {extras = ["asyncio"], version = "^2.0"} +asyncpg = "^0.30" +alembic = "^1.14" +pydantic = "^2.10" +pydantic-settings = "^2.7" +httpx = "^0.27" diff --git a/backend/services/chart-service/src/__init__.py b/backend/services/chart-service/src/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/adapters/__init__.py b/backend/services/chart-service/src/adapters/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/adapters/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/adapters/clients/__init__.py b/backend/services/chart-service/src/adapters/clients/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/adapters/clients/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/adapters/clients/data_service_client_impl.py b/backend/services/chart-service/src/adapters/clients/data_service_client_impl.py new file mode 100644 index 0000000..7145e0a --- /dev/null +++ b/backend/services/chart-service/src/adapters/clients/data_service_client_impl.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import uuid + +from shared.http_client import ServiceHttpClient + +from src.application.ports.output.data_service_client import IDataServiceClient + + +class DataServiceClientImpl(IDataServiceClient): + """Calls data-service over HTTP using the shared ServiceHttpClient.""" + + def __init__(self, http_client: ServiceHttpClient) -> None: + self._http = http_client + + async def get_dataset(self, dataset_id: uuid.UUID) -> dict: + """Fetch dataset metadata + structure from data-service.""" + structure = await self._http.get(f"/api/v1/datasets/{dataset_id}/structure") + return structure + + async def get_rows( + self, dataset_id: uuid.UUID, limit: int = 10000, offset: int = 0 + ) -> list[dict]: + """Fetch dataset rows from data-service.""" + result = await self._http.get( + f"/api/v1/datasets/{dataset_id}/rows", + params={"limit": limit, "offset": offset}, + ) + return result.get("rows", []) diff --git a/backend/services/chart-service/src/adapters/option_builders/__init__.py b/backend/services/chart-service/src/adapters/option_builders/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/adapters/option_builders/bar_builder.py b/backend/services/chart-service/src/adapters/option_builders/bar_builder.py new file mode 100644 index 0000000..37c8988 --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/bar_builder.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from typing import Any + +from collections import defaultdict + +from shared.types import ChartType + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding + + +def _get_binding(bindings: list[FieldBinding], axis: str) -> FieldBinding | None: + for b in bindings: + if b.axis == axis: + return b + return None + + +def _aggregate(values: list[Any], aggregation: str | None) -> Any: + nums = [v for v in values if isinstance(v, (int, float))] + if not nums: + return 0 + if aggregation == "sum" or aggregation is None: + return sum(nums) + if aggregation == "avg": + return sum(nums) / len(nums) + if aggregation == "count": + return len(values) + if aggregation == "max": + return max(nums) + if aggregation == "min": + return min(nums) + return sum(nums) + + +def build_bar_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Standard vertical bar chart.""" + x_bind = _get_binding(chart.bindings, "x") + y_bind = _get_binding(chart.bindings, "y") + + if not x_bind or not y_bind: + return {"series": []} + + # Group by x values + groups: dict[str, list[Any]] = defaultdict(list) + for row in data: + key = str(row.get(x_bind.column_name, "")) + groups[key].append(row.get(y_bind.column_name, 0)) + + categories = list(groups.keys()) + values = [_aggregate(groups[c], y_bind.aggregation) for c in categories] + + return { + "tooltip": {"trigger": "axis"}, + "xAxis": {"type": "category", "data": categories}, + "yAxis": {"type": "value"}, + "series": [ + { + "type": "bar", + "name": y_bind.column_name, + "data": values, + } + ], + **_style_overrides(chart.style), + } + + +def build_grouped_bar_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Grouped bar chart with a group binding.""" + x_bind = _get_binding(chart.bindings, "x") + y_bind = _get_binding(chart.bindings, "y") + g_bind = _get_binding(chart.bindings, "group") + + if not x_bind or not y_bind or not g_bind: + return {"series": []} + + groups: dict[str, dict[str, list[Any]]] = defaultdict(lambda: defaultdict(list)) + all_categories: list[str] = [] + all_groups: list[str] = [] + + for row in data: + cat = str(row.get(x_bind.column_name, "")) + grp = str(row.get(g_bind.column_name, "")) + if cat not in all_categories: + all_categories.append(cat) + if grp not in all_groups: + all_groups.append(grp) + groups[grp][cat].append(row.get(y_bind.column_name, 0)) + + series = [] + for grp in all_groups: + series.append( + { + "type": "bar", + "name": grp, + "data": [ + _aggregate(groups[grp].get(c, []), y_bind.aggregation) + for c in all_categories + ], + } + ) + + return { + "tooltip": {"trigger": "axis"}, + "legend": {"data": all_groups}, + "xAxis": {"type": "category", "data": all_categories}, + "yAxis": {"type": "value"}, + "series": series, + **_style_overrides(chart.style), + } + + +def build_stacked_bar_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Stacked bar chart.""" + x_bind = _get_binding(chart.bindings, "x") + y_bind = _get_binding(chart.bindings, "y") + s_bind = _get_binding(chart.bindings, "stack") + + if not x_bind or not y_bind or not s_bind: + return {"series": []} + + groups: dict[str, dict[str, list[Any]]] = defaultdict(lambda: defaultdict(list)) + all_categories: list[str] = [] + all_stacks: list[str] = [] + + for row in data: + cat = str(row.get(x_bind.column_name, "")) + stk = str(row.get(s_bind.column_name, "")) + if cat not in all_categories: + all_categories.append(cat) + if stk not in all_stacks: + all_stacks.append(stk) + groups[stk][cat].append(row.get(y_bind.column_name, 0)) + + series = [] + for stk in all_stacks: + series.append( + { + "type": "bar", + "name": stk, + "stack": "total", + "data": [ + _aggregate(groups[stk].get(c, []), y_bind.aggregation) + for c in all_categories + ], + } + ) + + return { + "tooltip": {"trigger": "axis"}, + "legend": {"data": all_stacks}, + "xAxis": {"type": "category", "data": all_categories}, + "yAxis": {"type": "value"}, + "series": series, + **_style_overrides(chart.style), + } + + +def build_horizontal_bar_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Horizontal bar chart (axes swapped).""" + x_bind = _get_binding(chart.bindings, "x") + y_bind = _get_binding(chart.bindings, "y") + + if not x_bind or not y_bind: + return {"series": []} + + groups: dict[str, list[Any]] = defaultdict(list) + for row in data: + key = str(row.get(x_bind.column_name, "")) + groups[key].append(row.get(y_bind.column_name, 0)) + + categories = list(groups.keys()) + values = [_aggregate(groups[c], y_bind.aggregation) for c in categories] + + return { + "tooltip": {"trigger": "axis"}, + "xAxis": {"type": "value"}, + "yAxis": {"type": "category", "data": categories}, + "series": [ + { + "type": "bar", + "name": y_bind.column_name, + "data": values, + } + ], + **_style_overrides(chart.style), + } + + +def _style_overrides(style: dict[str, Any]) -> dict[str, Any]: + """Extract top-level ECharts keys from style config.""" + out: dict[str, Any] = {} + if "title" in style: + out["title"] = style["title"] + if "backgroundColor" in style: + out["backgroundColor"] = style["backgroundColor"] + return out diff --git a/backend/services/chart-service/src/adapters/option_builders/builder_factory.py b/backend/services/chart-service/src/adapters/option_builders/builder_factory.py new file mode 100644 index 0000000..8459f50 --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/builder_factory.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from typing import Any, Callable + +from shared.types import ChartType + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding +from src.domain.services.option_builder import IOptionBuilder + +from src.adapters.option_builders.bar_builder import ( + build_bar_option, + build_grouped_bar_option, + build_horizontal_bar_option, + build_stacked_bar_option, +) +from src.adapters.option_builders.combo_builder import build_combo_option +from src.adapters.option_builders.heatmap_builder import build_heatmap_option +from src.adapters.option_builders.line_builder import build_area_option, build_line_option +from src.adapters.option_builders.map_builder import build_map_option +from src.adapters.option_builders.pie_builder import build_donut_option, build_pie_option +from src.adapters.option_builders.radar_builder import build_radar_option +from src.adapters.option_builders.scatter_builder import ( + build_boston_matrix_option, + build_scatter_option, +) +from src.adapters.option_builders.wordcloud_builder import build_wordcloud_option + +BuilderFn = Callable[[ChartInstance, list[dict]], dict[str, Any]] + + +def _build_kpi_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """KPI card: return a simple value dict, not a full ECharts option.""" + val_bind: FieldBinding | None = None + label_bind: FieldBinding | None = None + comparison_bind: FieldBinding | None = None + + for b in chart.bindings: + if b.axis == "value": + val_bind = b + elif b.axis == "label": + label_bind = b + elif b.axis == "comparison": + comparison_bind = b + + if not val_bind or not data: + return {"value": 0, "label": ""} + + # Aggregate all rows into a single value + nums = [ + row.get(val_bind.column_name, 0) + for row in data + if isinstance(row.get(val_bind.column_name), (int, float)) + ] + agg = val_bind.aggregation or "sum" + if agg == "sum": + result = sum(nums) if nums else 0 + elif agg == "avg": + result = sum(nums) / len(nums) if nums else 0 + elif agg == "count": + result = len(data) + elif agg == "max": + result = max(nums) if nums else 0 + elif agg == "min": + result = min(nums) if nums else 0 + else: + result = sum(nums) if nums else 0 + + label = "" + if label_bind and data: + label = str(data[0].get(label_bind.column_name, "")) + + comparison = None + if comparison_bind and data: + comp_nums = [ + row.get(comparison_bind.column_name, 0) + for row in data + if isinstance(row.get(comparison_bind.column_name), (int, float)) + ] + comparison = sum(comp_nums) if comp_nums else None + + out: dict[str, Any] = {"value": result, "label": label} + if comparison is not None: + out["comparison"] = comparison + return out + + +def _build_data_table_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Data table: return raw rows.""" + return {"rows": data} + + +_BUILDERS: dict[ChartType, BuilderFn] = { + ChartType.KPI: _build_kpi_option, + ChartType.BAR: build_bar_option, + ChartType.GROUPED_BAR: build_grouped_bar_option, + ChartType.STACKED_BAR: build_stacked_bar_option, + ChartType.HORIZONTAL_BAR: build_horizontal_bar_option, + ChartType.LINE: build_line_option, + ChartType.AREA: build_area_option, + ChartType.PIE: build_pie_option, + ChartType.DONUT: build_donut_option, + ChartType.SCATTER: build_scatter_option, + ChartType.BOSTON_MATRIX: build_boston_matrix_option, + ChartType.RADAR: build_radar_option, + ChartType.WORDCLOUD: build_wordcloud_option, + ChartType.HEATMAP: build_heatmap_option, + ChartType.MAP: build_map_option, + ChartType.COMBO: build_combo_option, + ChartType.DATA_TABLE: _build_data_table_option, +} + + +def get_builder(chart_type: ChartType) -> BuilderFn: + """Return the option builder function for the given chart type.""" + builder = _BUILDERS.get(chart_type) + if builder is None: + raise ValueError(f"No option builder registered for chart type: {chart_type}") + return builder + + +class EChartsOptionBuilderAdapter(IOptionBuilder): + """Adapter implementing IOptionBuilder port using the registered builders.""" + + def build(self, chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + builder = get_builder(chart.chart_type) + return builder(chart, data) diff --git a/backend/services/chart-service/src/adapters/option_builders/combo_builder.py b/backend/services/chart-service/src/adapters/option_builders/combo_builder.py new file mode 100644 index 0000000..1c5ebb2 --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/combo_builder.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding + + +def _get_binding(bindings: list[FieldBinding], axis: str) -> FieldBinding | None: + for b in bindings: + if b.axis == axis: + return b + return None + + +def _get_bindings(bindings: list[FieldBinding], axis: str) -> list[FieldBinding]: + return [b for b in bindings if b.axis == axis] + + +def _aggregate(values: list[Any], aggregation: str | None) -> Any: + nums = [v for v in values if isinstance(v, (int, float))] + if not nums: + return 0 + if aggregation == "sum" or aggregation is None: + return sum(nums) + if aggregation == "avg": + return sum(nums) / len(nums) + if aggregation == "count": + return len(values) + if aggregation == "max": + return max(nums) + if aggregation == "min": + return min(nums) + return sum(nums) + + +def build_combo_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Combo chart (bar + line on the same axes).""" + x_bind = _get_binding(chart.bindings, "x") + bar_bindings = _get_bindings(chart.bindings, "bar_y") + line_bindings = _get_bindings(chart.bindings, "line_y") + + if not x_bind: + return {"series": []} + + # Build categories + categories: list[str] = [] + for row in data: + cat = str(row.get(x_bind.column_name, "")) + if cat not in categories: + categories.append(cat) + + series: list[dict[str, Any]] = [] + legend_data: list[str] = [] + + # Bar series + for b_bind in bar_bindings: + groups: dict[str, list[Any]] = defaultdict(list) + for row in data: + cat = str(row.get(x_bind.column_name, "")) + groups[cat].append(row.get(b_bind.column_name, 0)) + legend_data.append(b_bind.column_name) + series.append( + { + "type": "bar", + "name": b_bind.column_name, + "data": [ + _aggregate(groups.get(c, []), b_bind.aggregation) for c in categories + ], + } + ) + + # Line series on secondary y-axis + for l_bind in line_bindings: + groups2: dict[str, list[Any]] = defaultdict(list) + for row in data: + cat = str(row.get(x_bind.column_name, "")) + groups2[cat].append(row.get(l_bind.column_name, 0)) + legend_data.append(l_bind.column_name) + series.append( + { + "type": "line", + "name": l_bind.column_name, + "yAxisIndex": 1, + "data": [ + _aggregate(groups2.get(c, []), l_bind.aggregation) for c in categories + ], + } + ) + + y_axes: list[dict[str, Any]] = [{"type": "value", "name": "Bar"}] + if line_bindings: + y_axes.append({"type": "value", "name": "Line"}) + + return { + "tooltip": {"trigger": "axis"}, + "legend": {"data": legend_data}, + "xAxis": {"type": "category", "data": categories}, + "yAxis": y_axes, + "series": series, + } diff --git a/backend/services/chart-service/src/adapters/option_builders/heatmap_builder.py b/backend/services/chart-service/src/adapters/option_builders/heatmap_builder.py new file mode 100644 index 0000000..b191e23 --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/heatmap_builder.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding + + +def _get_binding(bindings: list[FieldBinding], axis: str) -> FieldBinding | None: + for b in bindings: + if b.axis == axis: + return b + return None + + +def build_heatmap_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Heatmap chart builder.""" + x_bind = _get_binding(chart.bindings, "x") + y_bind = _get_binding(chart.bindings, "y") + val_bind = _get_binding(chart.bindings, "value") + + if not x_bind or not y_bind or not val_bind: + return {"series": []} + + x_categories: list[str] = [] + y_categories: list[str] = [] + cell_values: dict[tuple[str, str], list[float]] = defaultdict(list) + + for row in data: + x_val = str(row.get(x_bind.column_name, "")) + y_val = str(row.get(y_bind.column_name, "")) + v = row.get(val_bind.column_name, 0) + if x_val not in x_categories: + x_categories.append(x_val) + if y_val not in y_categories: + y_categories.append(y_val) + if isinstance(v, (int, float)): + cell_values[(x_val, y_val)].append(v) + + heatmap_data: list[list[Any]] = [] + all_values: list[float] = [] + for xi, x_cat in enumerate(x_categories): + for yi, y_cat in enumerate(y_categories): + vals = cell_values.get((x_cat, y_cat), [0]) + avg = sum(vals) / len(vals) if vals else 0 + heatmap_data.append([xi, yi, round(avg, 2)]) + all_values.append(avg) + + min_val = min(all_values) if all_values else 0 + max_val = max(all_values) if all_values else 1 + + return { + "tooltip": {"position": "top"}, + "xAxis": {"type": "category", "data": x_categories, "splitArea": {"show": True}}, + "yAxis": {"type": "category", "data": y_categories, "splitArea": {"show": True}}, + "visualMap": { + "min": min_val, + "max": max_val, + "calculable": True, + "orient": "horizontal", + "left": "center", + "bottom": "0%", + }, + "series": [ + { + "type": "heatmap", + "data": heatmap_data, + "label": {"show": True}, + "emphasis": { + "itemStyle": {"shadowBlur": 10, "shadowColor": "rgba(0,0,0,0.5)"} + }, + } + ], + } diff --git a/backend/services/chart-service/src/adapters/option_builders/line_builder.py b/backend/services/chart-service/src/adapters/option_builders/line_builder.py new file mode 100644 index 0000000..f04dbd9 --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/line_builder.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding + + +def _get_binding(bindings: list[FieldBinding], axis: str) -> FieldBinding | None: + for b in bindings: + if b.axis == axis: + return b + return None + + +def _aggregate(values: list[Any], aggregation: str | None) -> Any: + nums = [v for v in values if isinstance(v, (int, float))] + if not nums: + return 0 + if aggregation == "sum" or aggregation is None: + return sum(nums) + if aggregation == "avg": + return sum(nums) / len(nums) + if aggregation == "count": + return len(values) + if aggregation == "max": + return max(nums) + if aggregation == "min": + return min(nums) + return sum(nums) + + +def build_line_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Line chart builder.""" + x_bind = _get_binding(chart.bindings, "x") + y_bind = _get_binding(chart.bindings, "y") + g_bind = _get_binding(chart.bindings, "group") + + if not x_bind or not y_bind: + return {"series": []} + + if g_bind: + return _build_multi_line(chart, data, x_bind, y_bind, g_bind) + + groups: dict[str, list[Any]] = defaultdict(list) + categories: list[str] = [] + for row in data: + key = str(row.get(x_bind.column_name, "")) + if key not in categories: + categories.append(key) + groups[key].append(row.get(y_bind.column_name, 0)) + + values = [_aggregate(groups[c], y_bind.aggregation) for c in categories] + + return { + "tooltip": {"trigger": "axis"}, + "xAxis": {"type": "category", "data": categories}, + "yAxis": {"type": "value"}, + "series": [ + { + "type": "line", + "name": y_bind.column_name, + "data": values, + } + ], + } + + +def _build_multi_line( + chart: ChartInstance, + data: list[dict], + x_bind: FieldBinding, + y_bind: FieldBinding, + g_bind: FieldBinding, +) -> dict[str, Any]: + groups: dict[str, dict[str, list[Any]]] = defaultdict(lambda: defaultdict(list)) + all_categories: list[str] = [] + all_groups: list[str] = [] + + for row in data: + cat = str(row.get(x_bind.column_name, "")) + grp = str(row.get(g_bind.column_name, "")) + if cat not in all_categories: + all_categories.append(cat) + if grp not in all_groups: + all_groups.append(grp) + groups[grp][cat].append(row.get(y_bind.column_name, 0)) + + series = [ + { + "type": "line", + "name": grp, + "data": [ + _aggregate(groups[grp].get(c, []), y_bind.aggregation) + for c in all_categories + ], + } + for grp in all_groups + ] + + return { + "tooltip": {"trigger": "axis"}, + "legend": {"data": all_groups}, + "xAxis": {"type": "category", "data": all_categories}, + "yAxis": {"type": "value"}, + "series": series, + } + + +def build_area_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Area chart (line with areaStyle).""" + option = build_line_option(chart, data) + for s in option.get("series", []): + s["areaStyle"] = {} + return option diff --git a/backend/services/chart-service/src/adapters/option_builders/map_builder.py b/backend/services/chart-service/src/adapters/option_builders/map_builder.py new file mode 100644 index 0000000..ab4b95d --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/map_builder.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding + + +def _get_binding(bindings: list[FieldBinding], axis: str) -> FieldBinding | None: + for b in bindings: + if b.axis == axis: + return b + return None + + +def build_map_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Geo map chart builder.""" + region_bind = _get_binding(chart.bindings, "region") + val_bind = _get_binding(chart.bindings, "value") + + if not region_bind or not val_bind: + return {"series": []} + + groups: dict[str, list[float]] = defaultdict(list) + ordered: list[str] = [] + for row in data: + region = str(row.get(region_bind.column_name, "")) + val = row.get(val_bind.column_name, 0) + if region not in ordered: + ordered.append(region) + if isinstance(val, (int, float)): + groups[region].append(val) + + map_data = [ + {"name": r, "value": sum(groups[r]) if groups[r] else 0} + for r in ordered + ] + + all_values = [d["value"] for d in map_data] + min_val = min(all_values) if all_values else 0 + max_val = max(all_values) if all_values else 1 + + return { + "tooltip": {"trigger": "item"}, + "visualMap": { + "min": min_val, + "max": max_val, + "left": "left", + "top": "bottom", + "text": ["High", "Low"], + "calculable": True, + }, + "series": [ + { + "type": "map", + "map": "china", + "roam": True, + "data": map_data, + "emphasis": {"label": {"show": True}}, + } + ], + } diff --git a/backend/services/chart-service/src/adapters/option_builders/pie_builder.py b/backend/services/chart-service/src/adapters/option_builders/pie_builder.py new file mode 100644 index 0000000..66a28a8 --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/pie_builder.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding + + +def _get_binding(bindings: list[FieldBinding], axis: str) -> FieldBinding | None: + for b in bindings: + if b.axis == axis: + return b + return None + + +def _aggregate(values: list[Any], aggregation: str | None) -> Any: + nums = [v for v in values if isinstance(v, (int, float))] + if not nums: + return 0 + if aggregation == "sum" or aggregation is None: + return sum(nums) + if aggregation == "avg": + return sum(nums) / len(nums) + if aggregation == "count": + return len(values) + if aggregation == "max": + return max(nums) + if aggregation == "min": + return min(nums) + return sum(nums) + + +def build_pie_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Standard pie chart.""" + name_bind = _get_binding(chart.bindings, "name") + val_bind = _get_binding(chart.bindings, "value") + + if not name_bind or not val_bind: + return {"series": []} + + groups: dict[str, list[Any]] = defaultdict(list) + ordered: list[str] = [] + for row in data: + key = str(row.get(name_bind.column_name, "")) + if key not in ordered: + ordered.append(key) + groups[key].append(row.get(val_bind.column_name, 0)) + + pie_data = [ + {"name": k, "value": _aggregate(groups[k], val_bind.aggregation)} + for k in ordered + ] + + return { + "tooltip": {"trigger": "item"}, + "legend": {"orient": "vertical", "left": "left"}, + "series": [ + { + "type": "pie", + "radius": "55%", + "data": pie_data, + "emphasis": { + "itemStyle": { + "shadowBlur": 10, + "shadowOffsetX": 0, + "shadowColor": "rgba(0, 0, 0, 0.5)", + } + }, + } + ], + } + + +def build_donut_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Donut chart (pie with inner radius).""" + option = build_pie_option(chart, data) + for s in option.get("series", []): + s["radius"] = ["40%", "70%"] + return option diff --git a/backend/services/chart-service/src/adapters/option_builders/radar_builder.py b/backend/services/chart-service/src/adapters/option_builders/radar_builder.py new file mode 100644 index 0000000..72224a1 --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/radar_builder.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding + + +def _get_binding(bindings: list[FieldBinding], axis: str) -> FieldBinding | None: + for b in bindings: + if b.axis == axis: + return b + return None + + +def build_radar_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Radar chart builder.""" + ind_bind = _get_binding(chart.bindings, "indicator") + val_bind = _get_binding(chart.bindings, "value") + grp_bind = _get_binding(chart.bindings, "group") + + if not ind_bind or not val_bind: + return {"series": []} + + # Collect indicator names and their max values + indicator_values: dict[str, list[float]] = defaultdict(list) + ordered_indicators: list[str] = [] + + for row in data: + ind = str(row.get(ind_bind.column_name, "")) + val = row.get(val_bind.column_name, 0) + if ind not in ordered_indicators: + ordered_indicators.append(ind) + if isinstance(val, (int, float)): + indicator_values[ind].append(val) + + indicators = [ + { + "name": ind, + "max": max(indicator_values[ind]) * 1.2 if indicator_values[ind] else 100, + } + for ind in ordered_indicators + ] + + if grp_bind: + # Multi-group radar + groups: dict[str, dict[str, float]] = defaultdict(dict) + group_order: list[str] = [] + for row in data: + ind = str(row.get(ind_bind.column_name, "")) + grp = str(row.get(grp_bind.column_name, "")) + val = row.get(val_bind.column_name, 0) + if grp not in group_order: + group_order.append(grp) + groups[grp][ind] = val if isinstance(val, (int, float)) else 0 + + series_data = [ + { + "name": grp, + "value": [groups[grp].get(ind, 0) for ind in ordered_indicators], + } + for grp in group_order + ] + legend = {"data": group_order} + else: + # Single radar + series_data = [ + { + "name": val_bind.column_name, + "value": [ + indicator_values[ind][0] if indicator_values[ind] else 0 + for ind in ordered_indicators + ], + } + ] + legend = {} + + return { + "tooltip": {"trigger": "item"}, + **( {"legend": legend} if legend else {}), + "radar": {"indicator": indicators}, + "series": [ + { + "type": "radar", + "data": series_data, + } + ], + } diff --git a/backend/services/chart-service/src/adapters/option_builders/scatter_builder.py b/backend/services/chart-service/src/adapters/option_builders/scatter_builder.py new file mode 100644 index 0000000..1468137 --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/scatter_builder.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding + + +def _get_binding(bindings: list[FieldBinding], axis: str) -> FieldBinding | None: + for b in bindings: + if b.axis == axis: + return b + return None + + +def build_scatter_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Scatter plot builder.""" + x_bind = _get_binding(chart.bindings, "x") + y_bind = _get_binding(chart.bindings, "y") + size_bind = _get_binding(chart.bindings, "size") + + if not x_bind or not y_bind: + return {"series": []} + + scatter_data: list[list[Any]] = [] + for row in data: + x_val = row.get(x_bind.column_name, 0) + y_val = row.get(y_bind.column_name, 0) + if size_bind: + s_val = row.get(size_bind.column_name, 10) + scatter_data.append([x_val, y_val, s_val]) + else: + scatter_data.append([x_val, y_val]) + + series_item: dict[str, Any] = { + "type": "scatter", + "data": scatter_data, + "symbolSize": 10, + } + if size_bind: + series_item["symbolSize"] = None # will use encode + series_item["encode"] = {"x": 0, "y": 1, "size": 2} + + return { + "tooltip": {"trigger": "item"}, + "xAxis": {"type": "value", "name": x_bind.column_name}, + "yAxis": {"type": "value", "name": y_bind.column_name}, + "series": [series_item], + } + + +def build_boston_matrix_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Boston matrix (scatter with quadrant lines).""" + option = build_scatter_option(chart, data) + + label_bind = _get_binding(chart.bindings, "label") + + # Add labels if binding exists + if label_bind and option.get("series"): + for i, row in enumerate(data): + label_text = str(row.get(label_bind.column_name, "")) + if i < len(option["series"][0]["data"]): + point = option["series"][0]["data"][i] + if isinstance(point, list): + point.append(label_text) + + # Add quadrant markLines (median-based) + all_x = [] + all_y = [] + for point in option.get("series", [{}])[0].get("data", []): + if isinstance(point, list) and len(point) >= 2: + if isinstance(point[0], (int, float)): + all_x.append(point[0]) + if isinstance(point[1], (int, float)): + all_y.append(point[1]) + + if all_x and all_y: + mid_x = sum(all_x) / len(all_x) + mid_y = sum(all_y) / len(all_y) + option["series"][0]["markLine"] = { + "silent": True, + "lineStyle": {"type": "dashed", "color": "#999"}, + "data": [ + {"xAxis": mid_x}, + {"yAxis": mid_y}, + ], + } + + return option diff --git a/backend/services/chart-service/src/adapters/option_builders/wordcloud_builder.py b/backend/services/chart-service/src/adapters/option_builders/wordcloud_builder.py new file mode 100644 index 0000000..19d796d --- /dev/null +++ b/backend/services/chart-service/src/adapters/option_builders/wordcloud_builder.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding + + +def _get_binding(bindings: list[FieldBinding], axis: str) -> FieldBinding | None: + for b in bindings: + if b.axis == axis: + return b + return None + + +def build_wordcloud_option(chart: ChartInstance, data: list[dict]) -> dict[str, Any]: + """Word cloud chart builder. + + Note: echarts-wordcloud is a separate extension. We produce the + standard option shape that the extension expects. + """ + name_bind = _get_binding(chart.bindings, "name") + val_bind = _get_binding(chart.bindings, "value") + + if not name_bind or not val_bind: + return {"series": []} + + groups: dict[str, list[float]] = defaultdict(list) + ordered: list[str] = [] + for row in data: + name = str(row.get(name_bind.column_name, "")) + val = row.get(val_bind.column_name, 0) + if name not in ordered: + ordered.append(name) + if isinstance(val, (int, float)): + groups[name].append(val) + + cloud_data = [ + {"name": n, "value": sum(groups[n]) if groups[n] else 0} + for n in ordered + ] + + return { + "tooltip": {"show": True}, + "series": [ + { + "type": "wordCloud", + "shape": "circle", + "sizeRange": [14, 60], + "rotationRange": [-45, 45], + "gridSize": 8, + "data": cloud_data, + "textStyle": { + "fontFamily": "sans-serif", + "fontWeight": "bold", + }, + } + ], + } diff --git a/backend/services/chart-service/src/adapters/persistence/__init__.py b/backend/services/chart-service/src/adapters/persistence/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/adapters/persistence/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/adapters/persistence/chart_repository_impl.py b/backend/services/chart-service/src/adapters/persistence/chart_repository_impl.py new file mode 100644 index 0000000..e2a0348 --- /dev/null +++ b/backend/services/chart-service/src/adapters/persistence/chart_repository_impl.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import uuid +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.types import ChartType + +from src.adapters.persistence.models import ChartInstanceModel +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding +from src.domain.repositories.chart_repository import ChartRepository + + +class ChartRepositoryImpl(ChartRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def find_by_id(self, entity_id: uuid.UUID) -> Optional[ChartInstance]: + model = await self._session.get(ChartInstanceModel, entity_id) + if model is None: + return None + return _to_entity(model) + + async def find_all(self) -> list[ChartInstance]: + result = await self._session.execute( + select(ChartInstanceModel).order_by(ChartInstanceModel.created_at.desc()) + ) + return [_to_entity(m) for m in result.scalars().all()] + + async def save(self, entity: ChartInstance) -> ChartInstance: + existing = await self._session.get(ChartInstanceModel, entity.id) + if existing is None: + model = _to_model(entity) + self._session.add(model) + else: + existing.dataset_id = entity.dataset_id + existing.chart_type = entity.chart_type.value + existing.bindings = [b.to_dict() for b in entity.bindings] + existing.style = entity.style + existing.filters = entity.filters + existing.sort_config = entity.sort_config + existing.top_n = entity.top_n + existing.updated_at = entity.updated_at + + await self._session.flush() + return entity + + async def delete(self, entity_id: uuid.UUID) -> None: + model = await self._session.get(ChartInstanceModel, entity_id) + if model is not None: + await self._session.delete(model) + await self._session.flush() + + +def _to_entity(model: ChartInstanceModel) -> ChartInstance: + return ChartInstance( + id=model.id, + created_at=model.created_at, + updated_at=model.updated_at, + dataset_id=model.dataset_id, + chart_type=ChartType(model.chart_type), + bindings=[FieldBinding.from_dict(b) for b in (model.bindings or [])], + style=model.style or {}, + filters=model.filters or [], + sort_config=model.sort_config, + top_n=model.top_n, + ) + + +def _to_model(entity: ChartInstance) -> ChartInstanceModel: + return ChartInstanceModel( + id=entity.id, + dataset_id=entity.dataset_id, + chart_type=entity.chart_type.value, + bindings=[b.to_dict() for b in entity.bindings], + style=entity.style, + filters=entity.filters, + sort_config=entity.sort_config, + top_n=entity.top_n, + created_at=entity.created_at, + updated_at=entity.updated_at, + ) diff --git a/backend/services/chart-service/src/adapters/persistence/database.py b/backend/services/chart-service/src/adapters/persistence/database.py new file mode 100644 index 0000000..8ae6b75 --- /dev/null +++ b/backend/services/chart-service/src/adapters/persistence/database.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import os +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://postgres:postgres@localhost:5432/chart_db", +) + +engine = create_async_engine( + DATABASE_URL, + echo=False, + pool_size=10, + max_overflow=20, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/backend/services/chart-service/src/adapters/persistence/models.py b/backend/services/chart-service/src/adapters/persistence/models.py new file mode 100644 index 0000000..391098b --- /dev/null +++ b/backend/services/chart-service/src/adapters/persistence/models.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, Integer, String, Uuid +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class ChartInstanceModel(Base): + __tablename__ = "chart_instances" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + dataset_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True) + chart_type: Mapped[str] = mapped_column(String(64), nullable=False) + bindings: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + style: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + filters: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + sort_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + top_n: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/services/chart-service/src/adapters/presenters/__init__.py b/backend/services/chart-service/src/adapters/presenters/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/adapters/presenters/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/adapters/presenters/chart_presenter.py b/backend/services/chart-service/src/adapters/presenters/chart_presenter.py new file mode 100644 index 0000000..b57b757 --- /dev/null +++ b/backend/services/chart-service/src/adapters/presenters/chart_presenter.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any + +from src.application.dto.chart_response import ChartResponse, FieldBindingDTO +from src.domain.entities.chart_instance import ChartInstance + + +def present_chart(chart: ChartInstance) -> ChartResponse: + """Convert domain entity to API response DTO.""" + return ChartResponse( + id=chart.id, + dataset_id=chart.dataset_id, + chart_type=chart.chart_type.value, + bindings=[ + FieldBindingDTO( + axis=b.axis, + column_name=b.column_name, + aggregation=b.aggregation, + ) + for b in chart.bindings + ], + style=chart.style, + filters=chart.filters, + sort_config=chart.sort_config, + top_n=chart.top_n, + created_at=chart.created_at, + updated_at=chart.updated_at, + ) + + +def present_option(option: dict[str, Any]) -> dict[str, Any]: + """Wrap an ECharts option for the API response.""" + return {"option": option} diff --git a/backend/services/chart-service/src/application/__init__.py b/backend/services/chart-service/src/application/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/application/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/application/dto/__init__.py b/backend/services/chart-service/src/application/dto/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/application/dto/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/application/dto/chart_response.py b/backend/services/chart-service/src/application/dto/chart_response.py new file mode 100644 index 0000000..c714daa --- /dev/null +++ b/backend/services/chart-service/src/application/dto/chart_response.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class FieldBindingDTO(BaseModel): + axis: str + column_name: str + aggregation: str | None = None + + +class CreateChartRequest(BaseModel): + dataset_id: uuid.UUID + chart_type: str + bindings: list[FieldBindingDTO] = Field(default_factory=list) + style: dict[str, Any] = Field(default_factory=dict) + filters: list[dict[str, Any]] = Field(default_factory=list) + sort_config: dict[str, Any] | None = None + top_n: int | None = None + + +class UpdateChartRequest(BaseModel): + chart_type: str | None = None + bindings: list[FieldBindingDTO] | None = None + style: dict[str, Any] | None = None + filters: list[dict[str, Any]] | None = None + sort_config: dict[str, Any] | None = None + top_n: int | None = None + + +class ChartResponse(BaseModel): + id: uuid.UUID + dataset_id: uuid.UUID + chart_type: str + bindings: list[FieldBindingDTO] + style: dict[str, Any] + filters: list[dict[str, Any]] + sort_config: dict[str, Any] | None + top_n: int | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/services/chart-service/src/application/dto/echarts_option.py b/backend/services/chart-service/src/application/dto/echarts_option.py new file mode 100644 index 0000000..5bddb69 --- /dev/null +++ b/backend/services/chart-service/src/application/dto/echarts_option.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class EChartsOptionResponse(BaseModel): + """Wraps the generated ECharts option dict.""" + + option: dict[str, Any] = Field( + ..., description="Complete ECharts option object ready for setOption()" + ) diff --git a/backend/services/chart-service/src/application/ports/__init__.py b/backend/services/chart-service/src/application/ports/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/application/ports/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/application/ports/input/__init__.py b/backend/services/chart-service/src/application/ports/input/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/application/ports/input/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/application/ports/input/create_chart.py b/backend/services/chart-service/src/application/ports/input/create_chart.py new file mode 100644 index 0000000..8929cb0 --- /dev/null +++ b/backend/services/chart-service/src/application/ports/input/create_chart.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from src.application.dto.chart_response import ChartResponse, CreateChartRequest + + +class ICreateChartUseCase(ABC): + @abstractmethod + async def execute(self, request: CreateChartRequest) -> ChartResponse: ... diff --git a/backend/services/chart-service/src/application/ports/input/get_chart_option.py b/backend/services/chart-service/src/application/ports/input/get_chart_option.py new file mode 100644 index 0000000..8226a44 --- /dev/null +++ b/backend/services/chart-service/src/application/ports/input/get_chart_option.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod +from typing import Any + + +class IGetChartOptionUseCase(ABC): + @abstractmethod + async def execute(self, chart_id: uuid.UUID) -> dict[str, Any]: ... diff --git a/backend/services/chart-service/src/application/ports/input/recommend_charts.py b/backend/services/chart-service/src/application/ports/input/recommend_charts.py new file mode 100644 index 0000000..2fab7f8 --- /dev/null +++ b/backend/services/chart-service/src/application/ports/input/recommend_charts.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod + + +class IRecommendChartsUseCase(ABC): + @abstractmethod + async def execute(self, dataset_id: uuid.UUID) -> list[dict]: ... diff --git a/backend/services/chart-service/src/application/ports/input/update_chart.py b/backend/services/chart-service/src/application/ports/input/update_chart.py new file mode 100644 index 0000000..cf80163 --- /dev/null +++ b/backend/services/chart-service/src/application/ports/input/update_chart.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod + +from src.application.dto.chart_response import ChartResponse, UpdateChartRequest + + +class IUpdateChartUseCase(ABC): + @abstractmethod + async def execute( + self, chart_id: uuid.UUID, request: UpdateChartRequest + ) -> ChartResponse: ... diff --git a/backend/services/chart-service/src/application/ports/output/__init__.py b/backend/services/chart-service/src/application/ports/output/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/application/ports/output/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/application/ports/output/data_service_client.py b/backend/services/chart-service/src/application/ports/output/data_service_client.py new file mode 100644 index 0000000..e4c82c2 --- /dev/null +++ b/backend/services/chart-service/src/application/ports/output/data_service_client.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod + + +class IDataServiceClient(ABC): + @abstractmethod + async def get_dataset(self, dataset_id: uuid.UUID) -> dict: ... + + @abstractmethod + async def get_rows( + self, dataset_id: uuid.UUID, limit: int = 10000, offset: int = 0 + ) -> list[dict]: ... diff --git a/backend/services/chart-service/src/application/usecases/__init__.py b/backend/services/chart-service/src/application/usecases/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/application/usecases/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/application/usecases/create_chart_usecase.py b/backend/services/chart-service/src/application/usecases/create_chart_usecase.py new file mode 100644 index 0000000..3ff95b2 --- /dev/null +++ b/backend/services/chart-service/src/application/usecases/create_chart_usecase.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from shared.types import ChartType + +from src.application.dto.chart_response import ( + ChartResponse, + CreateChartRequest, + FieldBindingDTO, +) +from src.application.ports.input.create_chart import ICreateChartUseCase +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding +from src.domain.repositories.chart_repository import ChartRepository + + +_DEFAULT_STYLE: dict = { + "backgroundColor": "#ffffff", + "textStyle": {"fontFamily": "sans-serif"}, +} + + +class CreateChartUseCase(ICreateChartUseCase): + def __init__(self, repository: ChartRepository) -> None: + self._repository = repository + + async def execute(self, request: CreateChartRequest) -> ChartResponse: + bindings = [ + FieldBinding(axis=b.axis, column_name=b.column_name, aggregation=b.aggregation) + for b in request.bindings + ] + + style = {**_DEFAULT_STYLE, **request.style} + + chart = ChartInstance( + dataset_id=request.dataset_id, + chart_type=ChartType(request.chart_type), + bindings=bindings, + style=style, + filters=request.filters, + sort_config=request.sort_config, + top_n=request.top_n, + ) + + saved = await self._repository.save(chart) + return _to_response(saved) + + +def _to_response(chart: ChartInstance) -> ChartResponse: + return ChartResponse( + id=chart.id, + dataset_id=chart.dataset_id, + chart_type=chart.chart_type.value, + bindings=[ + FieldBindingDTO(axis=b.axis, column_name=b.column_name, aggregation=b.aggregation) + for b in chart.bindings + ], + style=chart.style, + filters=chart.filters, + sort_config=chart.sort_config, + top_n=chart.top_n, + created_at=chart.created_at, + updated_at=chart.updated_at, + ) diff --git a/backend/services/chart-service/src/application/usecases/get_chart_option_usecase.py b/backend/services/chart-service/src/application/usecases/get_chart_option_usecase.py new file mode 100644 index 0000000..071d70b --- /dev/null +++ b/backend/services/chart-service/src/application/usecases/get_chart_option_usecase.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from shared.exceptions import EntityNotFoundError + +from src.application.ports.input.get_chart_option import IGetChartOptionUseCase +from src.application.ports.output.data_service_client import IDataServiceClient +from src.domain.repositories.chart_repository import ChartRepository +from src.domain.services.option_builder import IOptionBuilder + + +class GetChartOptionUseCase(IGetChartOptionUseCase): + def __init__( + self, + repository: ChartRepository, + data_client: IDataServiceClient, + option_builder: IOptionBuilder, + ) -> None: + self._repository = repository + self._data_client = data_client + self._option_builder = option_builder + + async def execute(self, chart_id: uuid.UUID) -> dict[str, Any]: + chart = await self._repository.find_by_id(chart_id) + if chart is None: + raise EntityNotFoundError("ChartInstance", str(chart_id)) + + # Fetch data from data-service + limit = chart.top_n if chart.top_n else 10000 + rows = await self._data_client.get_rows(chart.dataset_id, limit=limit) + + # Apply client-side filters + rows = _apply_filters(rows, chart.filters) + + # Apply sort + if chart.sort_config: + field = chart.sort_config.get("field") + ascending = chart.sort_config.get("ascending", True) + if field: + rows = sorted( + rows, + key=lambda r: r.get(field, ""), + reverse=not ascending, + ) + + # Apply top_n after sort + if chart.top_n: + rows = rows[: chart.top_n] + + option = self._option_builder.build(chart, rows) + return option + + +def _apply_filters(rows: list[dict], filters: list[dict]) -> list[dict]: + """Apply simple filters to rows.""" + if not filters: + return rows + + result = rows + for f in filters: + field = f.get("field") + op = f.get("operator", "eq") + value = f.get("value") + if not field: + continue + result = [r for r in result if _matches(r.get(field), op, value)] + return result + + +def _matches(cell_value: Any, op: str, filter_value: Any) -> bool: + if op == "eq": + return cell_value == filter_value + if op == "ne": + return cell_value != filter_value + if op == "gt": + return cell_value is not None and cell_value > filter_value + if op == "gte": + return cell_value is not None and cell_value >= filter_value + if op == "lt": + return cell_value is not None and cell_value < filter_value + if op == "lte": + return cell_value is not None and cell_value <= filter_value + if op == "in": + return cell_value in (filter_value or []) + if op == "contains": + return filter_value is not None and str(filter_value) in str(cell_value or "") + return True diff --git a/backend/services/chart-service/src/application/usecases/recommend_charts_usecase.py b/backend/services/chart-service/src/application/usecases/recommend_charts_usecase.py new file mode 100644 index 0000000..818f561 --- /dev/null +++ b/backend/services/chart-service/src/application/usecases/recommend_charts_usecase.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import uuid + +from src.application.ports.input.recommend_charts import IRecommendChartsUseCase +from src.application.ports.output.data_service_client import IDataServiceClient +from src.domain.services.chart_recommendation import recommend_charts + + +class RecommendChartsUseCase(IRecommendChartsUseCase): + def __init__(self, data_client: IDataServiceClient) -> None: + self._data_client = data_client + + async def execute(self, dataset_id: uuid.UUID) -> list[dict]: + dataset = await self._data_client.get_dataset(dataset_id) + columns = dataset.get("columns", []) + data_structure = dataset.get("data_structure", "") + return recommend_charts(columns, data_structure) diff --git a/backend/services/chart-service/src/application/usecases/update_chart_usecase.py b/backend/services/chart-service/src/application/usecases/update_chart_usecase.py new file mode 100644 index 0000000..59f8a49 --- /dev/null +++ b/backend/services/chart-service/src/application/usecases/update_chart_usecase.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import uuid + +from shared.exceptions import EntityNotFoundError +from shared.types import ChartType + +from src.application.dto.chart_response import ( + ChartResponse, + FieldBindingDTO, + UpdateChartRequest, +) +from src.application.ports.input.update_chart import IUpdateChartUseCase +from src.domain.entities.field_binding import FieldBinding +from src.domain.repositories.chart_repository import ChartRepository + + +class UpdateChartUseCase(IUpdateChartUseCase): + def __init__(self, repository: ChartRepository) -> None: + self._repository = repository + + async def execute( + self, chart_id: uuid.UUID, request: UpdateChartRequest + ) -> ChartResponse: + chart = await self._repository.find_by_id(chart_id) + if chart is None: + raise EntityNotFoundError("ChartInstance", str(chart_id)) + + if request.chart_type is not None: + chart.chart_type = ChartType(request.chart_type) + chart.touch() + + if request.bindings is not None: + new_bindings = [ + FieldBinding( + axis=b.axis, + column_name=b.column_name, + aggregation=b.aggregation, + ) + for b in request.bindings + ] + chart.update_bindings(new_bindings) + + if request.style is not None: + chart.update_style(request.style) + + if request.filters is not None: + chart.filters = request.filters + chart.touch() + + if request.sort_config is not None: + chart.sort_config = request.sort_config + chart.touch() + + if request.top_n is not None: + chart.top_n = request.top_n + chart.touch() + + saved = await self._repository.save(chart) + return ChartResponse( + id=saved.id, + dataset_id=saved.dataset_id, + chart_type=saved.chart_type.value, + bindings=[ + FieldBindingDTO( + axis=b.axis, column_name=b.column_name, aggregation=b.aggregation + ) + for b in saved.bindings + ], + style=saved.style, + filters=saved.filters, + sort_config=saved.sort_config, + top_n=saved.top_n, + created_at=saved.created_at, + updated_at=saved.updated_at, + ) diff --git a/backend/services/chart-service/src/domain/__init__.py b/backend/services/chart-service/src/domain/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/domain/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/domain/entities/__init__.py b/backend/services/chart-service/src/domain/entities/__init__.py new file mode 100644 index 0000000..e53e05b --- /dev/null +++ b/backend/services/chart-service/src/domain/entities/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from src.domain.entities.chart_instance import ChartInstance +from src.domain.entities.field_binding import FieldBinding +from src.domain.entities.style_config import StyleConfig + +__all__ = ["ChartInstance", "FieldBinding", "StyleConfig"] diff --git a/backend/services/chart-service/src/domain/entities/chart_instance.py b/backend/services/chart-service/src/domain/entities/chart_instance.py new file mode 100644 index 0000000..6fe82c6 --- /dev/null +++ b/backend/services/chart-service/src/domain/entities/chart_instance.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from typing import Any + +from shared.base_entity import BaseEntity +from shared.types import ChartType + +from src.domain.entities.field_binding import FieldBinding +from src.domain.entities.style_config import StyleConfig + + +@dataclass +class ChartInstance(BaseEntity): + """Aggregate root representing a configured chart.""" + + dataset_id: uuid.UUID = field(default_factory=uuid.uuid4) + chart_type: ChartType = ChartType.BAR + bindings: list[FieldBinding] = field(default_factory=list) + style: StyleConfig = field(default_factory=dict) + filters: list[dict[str, Any]] = field(default_factory=list) + sort_config: dict[str, Any] | None = None + top_n: int | None = None + + # ------------------------------------------------------------------ + # Domain behaviour + # ------------------------------------------------------------------ + + def update_bindings(self, bindings: list[FieldBinding]) -> None: + """Replace current bindings after validation.""" + errors = self.validate_bindings(bindings) + if errors: + raise ValueError("; ".join(errors)) + self.bindings = bindings + self.touch() + + def update_style(self, style: StyleConfig) -> None: + """Merge new style properties into current style.""" + self.style.update(style) + self.touch() + + def validate_bindings(self, bindings: list[FieldBinding] | None = None) -> list[str]: + """Return a list of validation error messages (empty means valid).""" + from src.domain.services.binding_validation import validate_bindings as _validate + + target = bindings if bindings is not None else self.bindings + return _validate(self.chart_type, target) diff --git a/backend/services/chart-service/src/domain/entities/field_binding.py b/backend/services/chart-service/src/domain/entities/field_binding.py new file mode 100644 index 0000000..5da83b4 --- /dev/null +++ b/backend/services/chart-service/src/domain/entities/field_binding.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class FieldBinding: + """Maps a chart axis to a dataset column with optional aggregation.""" + + axis: str + column_name: str + aggregation: str | None = None + + def to_dict(self) -> dict[str, str | None]: + return { + "axis": self.axis, + "column_name": self.column_name, + "aggregation": self.aggregation, + } + + @classmethod + def from_dict(cls, data: dict[str, str | None]) -> FieldBinding: + return cls( + axis=str(data["axis"]), + column_name=str(data["column_name"]), + aggregation=data.get("aggregation"), + ) diff --git a/backend/services/chart-service/src/domain/entities/style_config.py b/backend/services/chart-service/src/domain/entities/style_config.py new file mode 100644 index 0000000..dc3eef8 --- /dev/null +++ b/backend/services/chart-service/src/domain/entities/style_config.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from typing import Any + +StyleConfig = dict[str, Any] +"""Style configuration stored as JSONB. Keys are ECharts style properties.""" diff --git a/backend/services/chart-service/src/domain/repositories/__init__.py b/backend/services/chart-service/src/domain/repositories/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/domain/repositories/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/domain/repositories/chart_repository.py b/backend/services/chart-service/src/domain/repositories/chart_repository.py new file mode 100644 index 0000000..62ecc46 --- /dev/null +++ b/backend/services/chart-service/src/domain/repositories/chart_repository.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from shared.base_repository import BaseRepository + +from src.domain.entities.chart_instance import ChartInstance + + +class ChartRepository(BaseRepository[ChartInstance]): + """Abstract repository for ChartInstance aggregate.""" diff --git a/backend/services/chart-service/src/domain/services/__init__.py b/backend/services/chart-service/src/domain/services/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/domain/services/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/domain/services/binding_validation.py b/backend/services/chart-service/src/domain/services/binding_validation.py new file mode 100644 index 0000000..b08dc47 --- /dev/null +++ b/backend/services/chart-service/src/domain/services/binding_validation.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from shared.types import ChartType + +from src.domain.entities.field_binding import FieldBinding + +# Maps chart type -> { "required": [axis_names], "optional": [axis_names] } +CHART_BINDING_RULES: dict[ChartType, dict[str, list[str]]] = { + ChartType.KPI: { + "required": ["value"], + "optional": ["label", "comparison"], + }, + ChartType.BAR: { + "required": ["x", "y"], + "optional": ["color"], + }, + ChartType.GROUPED_BAR: { + "required": ["x", "y", "group"], + "optional": [], + }, + ChartType.STACKED_BAR: { + "required": ["x", "y", "stack"], + "optional": [], + }, + ChartType.HORIZONTAL_BAR: { + "required": ["x", "y"], + "optional": ["color"], + }, + ChartType.LINE: { + "required": ["x", "y"], + "optional": ["group", "color"], + }, + ChartType.AREA: { + "required": ["x", "y"], + "optional": ["group"], + }, + ChartType.PIE: { + "required": ["name", "value"], + "optional": [], + }, + ChartType.DONUT: { + "required": ["name", "value"], + "optional": [], + }, + ChartType.SCATTER: { + "required": ["x", "y"], + "optional": ["size", "color"], + }, + ChartType.BOSTON_MATRIX: { + "required": ["x", "y"], + "optional": ["size", "label"], + }, + ChartType.RADAR: { + "required": ["indicator", "value"], + "optional": ["group"], + }, + ChartType.WORDCLOUD: { + "required": ["name", "value"], + "optional": [], + }, + ChartType.HEATMAP: { + "required": ["x", "y", "value"], + "optional": [], + }, + ChartType.MAP: { + "required": ["region", "value"], + "optional": ["label"], + }, + ChartType.COMBO: { + "required": ["x"], + "optional": ["bar_y", "line_y"], + }, + ChartType.DATA_TABLE: { + "required": [], + "optional": [], + }, +} + + +def validate_bindings( + chart_type: ChartType, bindings: list[FieldBinding] +) -> list[str]: + """Return list of validation error messages (empty if valid).""" + rules = CHART_BINDING_RULES.get(chart_type) + if rules is None: + return [f"Unknown chart type: {chart_type}"] + + errors: list[str] = [] + bound_axes = {b.axis for b in bindings} + required = set(rules["required"]) + allowed = required | set(rules["optional"]) + + missing = required - bound_axes + if missing: + errors.append( + f"Missing required binding(s) for {chart_type}: {', '.join(sorted(missing))}" + ) + + unexpected = bound_axes - allowed + if unexpected: + errors.append( + f"Unexpected binding axis(es) for {chart_type}: {', '.join(sorted(unexpected))}" + ) + + return errors diff --git a/backend/services/chart-service/src/domain/services/chart_recommendation.py b/backend/services/chart-service/src/domain/services/chart_recommendation.py new file mode 100644 index 0000000..eea4aaf --- /dev/null +++ b/backend/services/chart-service/src/domain/services/chart_recommendation.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from shared.types import ChartType, DataStructureType + +# Mapping from data structure -> ordered list of (chart_type, label, primary?) +_RECOMMENDATION_MAP: dict[str, list[tuple[ChartType, str, bool]]] = { + DataStructureType.TOTAL: [ + (ChartType.KPI, "KPI Card", True), + ], + DataStructureType.YOY_MOM: [ + (ChartType.KPI, "KPI Card", True), + (ChartType.BAR, "Bar Chart", False), + (ChartType.LINE, "Line Chart", False), + ], + DataStructureType.SINGLE_DIM_SINGLE_METRIC: [ + (ChartType.BAR, "Bar Chart", True), + (ChartType.HORIZONTAL_BAR, "Horizontal Bar", False), + (ChartType.PIE, "Pie Chart", False), + (ChartType.DONUT, "Donut Chart", False), + (ChartType.LINE, "Line Chart", False), + ], + DataStructureType.SINGLE_DIM_MULTI_METRIC: [ + (ChartType.GROUPED_BAR, "Grouped Bar", True), + (ChartType.STACKED_BAR, "Stacked Bar", False), + (ChartType.RADAR, "Radar Chart", False), + (ChartType.LINE, "Line Chart", False), + (ChartType.COMBO, "Combo Chart", False), + ], + DataStructureType.DUAL_DIM_SINGLE_METRIC: [ + (ChartType.GROUPED_BAR, "Grouped Bar", True), + (ChartType.STACKED_BAR, "Stacked Bar", False), + (ChartType.HEATMAP, "Heatmap", False), + (ChartType.LINE, "Line Chart", False), + ], + DataStructureType.DUAL_DIM_MULTI_METRIC: [ + (ChartType.GROUPED_BAR, "Grouped Bar", True), + (ChartType.STACKED_BAR, "Stacked Bar", False), + (ChartType.COMBO, "Combo Chart", False), + ], + DataStructureType.TIME_SERIES: [ + (ChartType.LINE, "Line Chart", True), + (ChartType.AREA, "Area Chart", False), + (ChartType.BAR, "Bar Chart", False), + ], + DataStructureType.GEO: [ + (ChartType.MAP, "Map", True), + (ChartType.BAR, "Bar Chart", False), + ], + DataStructureType.TEXT_FREQUENCY: [ + (ChartType.WORDCLOUD, "Word Cloud", True), + (ChartType.BAR, "Bar Chart", False), + (ChartType.PIE, "Pie Chart", False), + ], + DataStructureType.TWO_DIM_EVALUATION: [ + (ChartType.SCATTER, "Scatter Plot", True), + (ChartType.BOSTON_MATRIX, "Boston Matrix", False), + ], +} + + +def recommend_charts( + columns: list[dict], data_structure: str +) -> list[dict]: + """Return recommended chart types with labels and primary flag. + + Parameters + ---------- + columns: + Column metadata dicts (name, field_type, ...). + data_structure: + The detected DataStructureType value string. + + Returns + ------- + list of dicts with keys: chart_type, label, primary. + """ + recommendations = _RECOMMENDATION_MAP.get(data_structure, []) + + # Fallback: if unknown structure, offer a generic set + if not recommendations: + recommendations = [ + (ChartType.BAR, "Bar Chart", True), + (ChartType.LINE, "Line Chart", False), + (ChartType.PIE, "Pie Chart", False), + (ChartType.DATA_TABLE, "Data Table", False), + ] + + # Always append data-table as last option if not already there + chart_types_present = {r[0] for r in recommendations} + if ChartType.DATA_TABLE not in chart_types_present: + recommendations = [*recommendations, (ChartType.DATA_TABLE, "Data Table", False)] + + return [ + {"chart_type": ct.value, "label": label, "primary": primary} + for ct, label, primary in recommendations + ] diff --git a/backend/services/chart-service/src/domain/services/option_builder.py b/backend/services/chart-service/src/domain/services/option_builder.py new file mode 100644 index 0000000..a517c9a --- /dev/null +++ b/backend/services/chart-service/src/domain/services/option_builder.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from src.domain.entities.chart_instance import ChartInstance + + +class IOptionBuilder(ABC): + """Port interface for building ECharts option JSON. + + Defined in domain layer as an abstract interface. + Concrete implementation lives in adapters layer (builder_factory). + """ + + @abstractmethod + def build(self, chart: ChartInstance, data: list[dict]) -> dict[str, Any]: ... diff --git a/backend/services/chart-service/src/domain/value_objects/__init__.py b/backend/services/chart-service/src/domain/value_objects/__init__.py new file mode 100644 index 0000000..592c7d9 --- /dev/null +++ b/backend/services/chart-service/src/domain/value_objects/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from src.domain.value_objects.chart_type import ChartType + +__all__ = ["ChartType"] diff --git a/backend/services/chart-service/src/domain/value_objects/chart_type.py b/backend/services/chart-service/src/domain/value_objects/chart_type.py new file mode 100644 index 0000000..765f133 --- /dev/null +++ b/backend/services/chart-service/src/domain/value_objects/chart_type.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from shared.types import ChartType + +__all__ = ["ChartType"] diff --git a/backend/services/chart-service/src/infrastructure/__init__.py b/backend/services/chart-service/src/infrastructure/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/infrastructure/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/infrastructure/api/__init__.py b/backend/services/chart-service/src/infrastructure/api/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/chart-service/src/infrastructure/api/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/chart-service/src/infrastructure/api/app.py b/backend/services/chart-service/src/infrastructure/api/app.py new file mode 100644 index 0000000..cffa257 --- /dev/null +++ b/backend/services/chart-service/src/infrastructure/api/app.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.infrastructure.api.routes import router +from src.infrastructure.config import settings + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + # Startup + yield + # Shutdown + from src.adapters.persistence.database import engine + + await engine.dispose() + + +def create_app() -> FastAPI: + app = FastAPI( + title="DataViz Pro - Chart Service", + version="0.1.0", + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(router) + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok", "service": "chart-service"} + + return app diff --git a/backend/services/chart-service/src/infrastructure/api/dependencies.py b/backend/services/chart-service/src/infrastructure/api/dependencies.py new file mode 100644 index 0000000..6c4172b --- /dev/null +++ b/backend/services/chart-service/src/infrastructure/api/dependencies.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.http_client import ServiceHttpClient + +from src.adapters.clients.data_service_client_impl import DataServiceClientImpl +from src.adapters.option_builders.builder_factory import EChartsOptionBuilderAdapter +from src.adapters.persistence.chart_repository_impl import ChartRepositoryImpl +from src.adapters.persistence.database import get_session +from src.application.ports.output.data_service_client import IDataServiceClient +from src.application.usecases.create_chart_usecase import CreateChartUseCase +from src.application.usecases.get_chart_option_usecase import GetChartOptionUseCase +from src.application.usecases.recommend_charts_usecase import RecommendChartsUseCase +from src.application.usecases.update_chart_usecase import UpdateChartUseCase +from src.domain.repositories.chart_repository import ChartRepository +from src.domain.services.option_builder import IOptionBuilder +from src.infrastructure.config import settings + + +# ---- output ports ---- + + +def get_data_service_client() -> IDataServiceClient: + http_client = ServiceHttpClient(base_url=settings.DATA_SERVICE_URL) + return DataServiceClientImpl(http_client) + + +def get_option_builder() -> IOptionBuilder: + return EChartsOptionBuilderAdapter() + + +# ---- repositories ---- + + +async def get_chart_repository( + session: AsyncSession = Depends(get_session), +) -> ChartRepository: + return ChartRepositoryImpl(session) + + +# ---- use cases ---- + + +async def get_create_chart_usecase( + repository: ChartRepository = Depends(get_chart_repository), +) -> CreateChartUseCase: + return CreateChartUseCase(repository=repository) + + +async def get_update_chart_usecase( + repository: ChartRepository = Depends(get_chart_repository), +) -> UpdateChartUseCase: + return UpdateChartUseCase(repository=repository) + + +async def get_chart_option_usecase( + repository: ChartRepository = Depends(get_chart_repository), + data_client: IDataServiceClient = Depends(get_data_service_client), + option_builder: IOptionBuilder = Depends(get_option_builder), +) -> GetChartOptionUseCase: + return GetChartOptionUseCase( + repository=repository, + data_client=data_client, + option_builder=option_builder, + ) + + +async def get_recommend_charts_usecase( + data_client: IDataServiceClient = Depends(get_data_service_client), +) -> RecommendChartsUseCase: + return RecommendChartsUseCase(data_client=data_client) diff --git a/backend/services/chart-service/src/infrastructure/api/routes.py b/backend/services/chart-service/src/infrastructure/api/routes.py new file mode 100644 index 0000000..ddece36 --- /dev/null +++ b/backend/services/chart-service/src/infrastructure/api/routes.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException + +from shared.exceptions import EntityNotFoundError + +from src.application.dto.chart_response import ( + ChartResponse, + CreateChartRequest, + UpdateChartRequest, +) +from src.application.dto.echarts_option import EChartsOptionResponse +from src.application.usecases.create_chart_usecase import CreateChartUseCase +from src.application.usecases.get_chart_option_usecase import GetChartOptionUseCase +from src.application.usecases.recommend_charts_usecase import RecommendChartsUseCase +from src.application.usecases.update_chart_usecase import UpdateChartUseCase +from src.adapters.presenters.chart_presenter import present_chart +from src.domain.repositories.chart_repository import ChartRepository +from src.infrastructure.api.dependencies import ( + get_chart_option_usecase, + get_chart_repository, + get_create_chart_usecase, + get_recommend_charts_usecase, + get_update_chart_usecase, +) + +router = APIRouter(prefix="/api/v1/charts", tags=["charts"]) + + +@router.post("", response_model=ChartResponse, status_code=201) +async def create_chart( + request: CreateChartRequest, + use_case: CreateChartUseCase = Depends(get_create_chart_usecase), +) -> ChartResponse: + try: + return await use_case.execute(request) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + +@router.get("", response_model=list[ChartResponse]) +async def list_charts( + repository: ChartRepository = Depends(get_chart_repository), +) -> list[ChartResponse]: + charts = await repository.find_all() + return [present_chart(c) for c in charts] + + +@router.get("/{chart_id}", response_model=ChartResponse) +async def get_chart( + chart_id: uuid.UUID, + repository: ChartRepository = Depends(get_chart_repository), +) -> ChartResponse: + chart = await repository.find_by_id(chart_id) + if chart is None: + raise HTTPException(status_code=404, detail="Chart not found") + return present_chart(chart) + + +@router.put("/{chart_id}", response_model=ChartResponse) +async def update_chart( + chart_id: uuid.UUID, + request: UpdateChartRequest, + use_case: UpdateChartUseCase = Depends(get_update_chart_usecase), +) -> ChartResponse: + try: + return await use_case.execute(chart_id, request) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail="Chart not found") + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + +@router.delete("/{chart_id}", status_code=204) +async def delete_chart( + chart_id: uuid.UUID, + repository: ChartRepository = Depends(get_chart_repository), +) -> None: + chart = await repository.find_by_id(chart_id) + if chart is None: + raise HTTPException(status_code=404, detail="Chart not found") + await repository.delete(chart_id) + + +@router.get("/{chart_id}/option", response_model=EChartsOptionResponse) +async def get_chart_option( + chart_id: uuid.UUID, + use_case: GetChartOptionUseCase = Depends(get_chart_option_usecase), +) -> EChartsOptionResponse: + try: + option = await use_case.execute(chart_id) + return EChartsOptionResponse(option=option) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail="Chart not found") + + +from pydantic import BaseModel + + +class RecommendChartsRequest(BaseModel): + dataset_id: uuid.UUID + + +@router.post("/recommend") +async def recommend_charts( + request: RecommendChartsRequest, + use_case: RecommendChartsUseCase = Depends(get_recommend_charts_usecase), +) -> list[dict[str, Any]]: + return await use_case.execute(request.dataset_id) diff --git a/backend/services/chart-service/src/infrastructure/config.py b/backend/services/chart-service/src/infrastructure/config.py new file mode 100644 index 0000000..c7d74f6 --- /dev/null +++ b/backend/services/chart-service/src/infrastructure/config.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/chart_db" + DATA_SERVICE_URL: str = "http://localhost:8000" + APP_HOST: str = "0.0.0.0" + APP_PORT: int = 8000 + CORS_ORIGINS: list[str] = ["*"] + + model_config = {"env_prefix": "", "case_sensitive": True} + + +settings = Settings() diff --git a/backend/services/chart-service/src/infrastructure/main.py b/backend/services/chart-service/src/infrastructure/main.py new file mode 100644 index 0000000..fef316b --- /dev/null +++ b/backend/services/chart-service/src/infrastructure/main.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import uvicorn + +from src.infrastructure.api.app import create_app +from src.infrastructure.config import settings + +app = create_app() + +if __name__ == "__main__": + uvicorn.run( + "src.infrastructure.main:app", + host=settings.APP_HOST, + port=settings.APP_PORT, + reload=True, + ) diff --git a/backend/services/data-service/Dockerfile b/backend/services/data-service/Dockerfile new file mode 100644 index 0000000..b9158b2 --- /dev/null +++ b/backend/services/data-service/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev && \ + rm -rf /var/lib/apt/lists/* + +# Copy shared library +COPY shared/ /app/shared/ + +# Copy service code +COPY services/data-service/pyproject.toml /app/ +COPY services/data-service/src/ /app/src/ +COPY services/data-service/alembic/ /app/alembic/ +COPY services/data-service/alembic.ini /app/alembic.ini + +# Install dependencies +RUN pip install --no-cache-dir poetry && \ + poetry config virtualenvs.create false && \ + poetry install --no-interaction --no-ansi --no-root + +# Set PYTHONPATH to include shared +ENV PYTHONPATH=/app:/app/src + +EXPOSE 8000 + +CMD ["python", "-m", "src.infrastructure.main"] diff --git a/backend/services/data-service/alembic/alembic.ini b/backend/services/data-service/alembic/alembic.ini new file mode 100644 index 0000000..68b87e7 --- /dev/null +++ b/backend/services/data-service/alembic/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = . +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/services/data-service/alembic/env.py b/backend/services/data-service/alembic/env.py new file mode 100644 index 0000000..e9b30f4 --- /dev/null +++ b/backend/services/data-service/alembic/env.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import asyncio +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import create_async_engine + +from src.adapters.persistence.models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_database_url() -> str: + return os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://postgres:postgres@localhost:5432/data_db", + ) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): # noqa: ANN001 + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode using an async engine.""" + connectable = create_async_engine( + get_database_url(), + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/services/data-service/alembic/versions/__init__.py b/backend/services/data-service/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/pyproject.toml b/backend/services/data-service/pyproject.toml new file mode 100644 index 0000000..f3a401d --- /dev/null +++ b/backend/services/data-service/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "data-service" +version = "0.1.0" +description = "DataViz Pro Data Service" + +[tool.poetry.dependencies] +python = "^3.12" +fastapi = "^0.115" +uvicorn = {extras = ["standard"], version = "^0.34"} +sqlalchemy = {extras = ["asyncio"], version = "^2.0"} +asyncpg = "^0.30" +alembic = "^1.14" +openpyxl = "^3.1" +xlrd = "^2.0" +pandas = "^2.2" +python-multipart = "^0.0.18" +pydantic = "^2.10" +pydantic-settings = "^2.7" diff --git a/backend/services/data-service/src/__init__.py b/backend/services/data-service/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/src/adapters/__init__.py b/backend/services/data-service/src/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/src/adapters/parsers/__init__.py b/backend/services/data-service/src/adapters/parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/src/adapters/parsers/csv_parser.py b/backend/services/data-service/src/adapters/parsers/csv_parser.py new file mode 100644 index 0000000..a155b46 --- /dev/null +++ b/backend/services/data-service/src/adapters/parsers/csv_parser.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import io +from typing import Any + +import pandas as pd + +from src.application.ports.output.file_parser import ParsedSheet + + +class CsvParser: + """Parse .csv files using pandas.""" + + async def parse(self, file_content: bytes) -> list[ParsedSheet]: + # Try common encodings + for encoding in ("utf-8", "utf-8-sig", "gbk", "gb2312", "latin-1"): + try: + text = file_content.decode(encoding) + break + except (UnicodeDecodeError, LookupError): + continue + else: + text = file_content.decode("utf-8", errors="replace") + + df = pd.read_csv(io.StringIO(text), dtype=str, keep_default_na=False) + columns = [str(c).strip() for c in df.columns.tolist()] + rows: list[dict[str, Any]] = df.to_dict(orient="records") + + return [ParsedSheet(sheet_name="Sheet1", columns=columns, rows=rows)] diff --git a/backend/services/data-service/src/adapters/parsers/json_parser.py b/backend/services/data-service/src/adapters/parsers/json_parser.py new file mode 100644 index 0000000..1dbc15f --- /dev/null +++ b/backend/services/data-service/src/adapters/parsers/json_parser.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import json +from typing import Any + +from src.application.ports.output.file_parser import ParsedSheet + + +class JsonParser: + """Parse .json files (expects an array of objects or a single object).""" + + async def parse(self, file_content: bytes) -> list[ParsedSheet]: + data = json.loads(file_content.decode("utf-8")) + + if isinstance(data, dict): + data = [data] + + if not isinstance(data, list) or not data: + raise ValueError("JSON must be an array of objects or a single object") + + # Collect all unique keys in order of appearance + columns: list[str] = [] + seen: set[str] = set() + for record in data: + if isinstance(record, dict): + for key in record: + if key not in seen: + columns.append(str(key)) + seen.add(key) + + rows: list[dict[str, Any]] = [] + for record in data: + if isinstance(record, dict): + rows.append({col: record.get(col) for col in columns}) + + return [ParsedSheet(sheet_name="Sheet1", columns=columns, rows=rows)] diff --git a/backend/services/data-service/src/adapters/parsers/parser_factory.py b/backend/services/data-service/src/adapters/parsers/parser_factory.py new file mode 100644 index 0000000..5ac5758 --- /dev/null +++ b/backend/services/data-service/src/adapters/parsers/parser_factory.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from shared.exceptions import FileParsingError + +from src.application.ports.output.file_parser import IFileParser, ParsedSheet + +from .csv_parser import CsvParser +from .json_parser import JsonParser +from .xlsx_parser import XlsParser, XlsxParser + + +class ParserFactory(IFileParser): + """Dispatch file parsing to the correct adapter based on file extension.""" + + def __init__(self) -> None: + self._xlsx_parser = XlsxParser() + self._xls_parser = XlsParser() + self._csv_parser = CsvParser() + self._json_parser = JsonParser() + + async def parse(self, file_name: str, file_content: bytes) -> list[ParsedSheet]: + ext = file_name.rsplit(".", maxsplit=1)[-1].lower() if "." in file_name else "" + + try: + if ext == "xlsx": + return await self._xlsx_parser.parse(file_content) + if ext == "xls": + return await self._xls_parser.parse(file_content) + if ext == "csv": + return await self._csv_parser.parse(file_content) + if ext == "json": + return await self._json_parser.parse(file_content) + except FileParsingError: + raise + except Exception as exc: + raise FileParsingError(f"Failed to parse file '{file_name}': {exc}") from exc + + raise FileParsingError( + f"Unsupported file format: '.{ext}'. Supported: xlsx, xls, csv, json" + ) diff --git a/backend/services/data-service/src/adapters/parsers/xlsx_parser.py b/backend/services/data-service/src/adapters/parsers/xlsx_parser.py new file mode 100644 index 0000000..7ea8f04 --- /dev/null +++ b/backend/services/data-service/src/adapters/parsers/xlsx_parser.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import io +from typing import Any + +from openpyxl import load_workbook + +from src.application.ports.output.file_parser import ParsedSheet + + +class XlsxParser: + """Parse .xlsx files using openpyxl.""" + + async def parse(self, file_content: bytes) -> list[ParsedSheet]: + wb = load_workbook(filename=io.BytesIO(file_content), read_only=True, data_only=True) + sheets: list[ParsedSheet] = [] + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + rows_iter = ws.iter_rows(values_only=True) + + # First row = headers + header_row = next(rows_iter, None) + if header_row is None: + continue + + columns = [str(h).strip() if h is not None else f"column_{i}" for i, h in enumerate(header_row)] + + rows: list[dict[str, Any]] = [] + for row in rows_iter: + if all(cell is None for cell in row): + continue + row_dict: dict[str, Any] = {} + for idx, cell_value in enumerate(row): + if idx < len(columns): + row_dict[columns[idx]] = cell_value + rows.append(row_dict) + + sheets.append(ParsedSheet(sheet_name=sheet_name, columns=columns, rows=rows)) + + wb.close() + return sheets + + +class XlsParser: + """Parse .xls files using xlrd.""" + + async def parse(self, file_content: bytes) -> list[ParsedSheet]: + import xlrd + + wb = xlrd.open_workbook(file_contents=file_content) + sheets: list[ParsedSheet] = [] + + for sheet_idx in range(wb.nsheets): + ws = wb.sheet_by_index(sheet_idx) + if ws.nrows == 0: + continue + + columns = [ + str(ws.cell_value(0, col)).strip() or f"column_{col}" + for col in range(ws.ncols) + ] + + rows: list[dict[str, Any]] = [] + for row_idx in range(1, ws.nrows): + row_dict: dict[str, Any] = {} + for col_idx in range(ws.ncols): + if col_idx < len(columns): + row_dict[columns[col_idx]] = ws.cell_value(row_idx, col_idx) + rows.append(row_dict) + + sheets.append(ParsedSheet(sheet_name=ws.name, columns=columns, rows=rows)) + + return sheets diff --git a/backend/services/data-service/src/adapters/persistence/__init__.py b/backend/services/data-service/src/adapters/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/src/adapters/persistence/database.py b/backend/services/data-service/src/adapters/persistence/database.py new file mode 100644 index 0000000..3acc3c6 --- /dev/null +++ b/backend/services/data-service/src/adapters/persistence/database.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import os +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://postgres:postgres@localhost:5432/data_db", +) + +engine = create_async_engine( + DATABASE_URL, + echo=False, + pool_size=10, + max_overflow=20, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/backend/services/data-service/src/adapters/persistence/dataset_repository_impl.py b/backend/services/data-service/src/adapters/persistence/dataset_repository_impl.py new file mode 100644 index 0000000..71b86ec --- /dev/null +++ b/backend/services/data-service/src/adapters/persistence/dataset_repository_impl.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.types import DataStructureType, FieldType + +from src.domain.entities.column import Column +from src.domain.entities.dataset import DataSet +from src.domain.repositories.dataset_repository import DataSetRepository + +from .models import ColumnModel, DataSetModel + + +class DataSetRepositoryImpl(DataSetRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + # ---- mapping helpers ---- + + @staticmethod + def _to_domain(model: DataSetModel) -> DataSet: + columns = [ + Column( + name=cm.name, + field_type=FieldType(cm.field_type), + sample_values=cm.sample_values or [], + ordinal=cm.ordinal, + ) + for cm in model.columns_rel + ] + return DataSet( + id=model.id, + created_at=model.created_at, + updated_at=model.updated_at, + file_name=model.file_name, + sheet_name=model.sheet_name, + columns=columns, + row_count=model.row_count, + data_structure=( + DataStructureType(model.data_structure) + if model.data_structure + else None + ), + raw_data=model.raw_data or [], + ) + + @staticmethod + def _to_model(entity: DataSet) -> DataSetModel: + model = DataSetModel( + id=entity.id, + file_name=entity.file_name, + sheet_name=entity.sheet_name, + row_count=entity.row_count, + data_structure=( + entity.data_structure.value if entity.data_structure else None + ), + raw_data=entity.raw_data, + created_at=entity.created_at, + updated_at=entity.updated_at, + ) + model.columns_rel = [ + ColumnModel( + id=uuid.uuid4(), + dataset_id=entity.id, + name=col.name, + field_type=col.field_type.value, + sample_values=col.sample_values, + ordinal=col.ordinal, + ) + for col in entity.columns + ] + return model + + # ---- repository interface ---- + + async def find_by_id(self, entity_id: uuid.UUID) -> Optional[DataSet]: + stmt = select(DataSetModel).where(DataSetModel.id == entity_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + if model is None: + return None + return self._to_domain(model) + + async def find_all(self) -> list[DataSet]: + stmt = select(DataSetModel).order_by(DataSetModel.created_at.desc()) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [self._to_domain(m) for m in models] + + async def save(self, entity: DataSet) -> DataSet: + entity.touch() + existing = await self._session.get(DataSetModel, entity.id) + if existing is not None: + existing.file_name = entity.file_name + existing.sheet_name = entity.sheet_name + existing.row_count = entity.row_count + existing.data_structure = ( + entity.data_structure.value if entity.data_structure else None + ) + existing.raw_data = entity.raw_data + existing.updated_at = entity.updated_at + + # Replace columns + existing.columns_rel.clear() + for col in entity.columns: + existing.columns_rel.append( + ColumnModel( + id=uuid.uuid4(), + dataset_id=entity.id, + name=col.name, + field_type=col.field_type.value, + sample_values=col.sample_values, + ordinal=col.ordinal, + ) + ) + await self._session.flush() + return entity + + model = self._to_model(entity) + self._session.add(model) + await self._session.flush() + return entity + + async def delete(self, entity_id: uuid.UUID) -> None: + model = await self._session.get(DataSetModel, entity_id) + if model is not None: + await self._session.delete(model) + await self._session.flush() diff --git a/backend/services/data-service/src/adapters/persistence/models.py b/backend/services/data-service/src/adapters/persistence/models.py new file mode 100644 index 0000000..02f9088 --- /dev/null +++ b/backend/services/data-service/src/adapters/persistence/models.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import ( + DateTime, + ForeignKey, + Integer, + String, + Text, + Uuid, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class DataSetModel(Base): + __tablename__ = "datasets" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + file_name: Mapped[str] = mapped_column(String(255), nullable=False) + sheet_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + data_structure: Mapped[str | None] = mapped_column(String(64), nullable=True) + raw_data: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + columns_rel: Mapped[list[ColumnModel]] = relationship( + "ColumnModel", + back_populates="dataset", + cascade="all, delete-orphan", + order_by="ColumnModel.ordinal", + lazy="selectin", + ) + + +class ColumnModel(Base): + __tablename__ = "columns" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + dataset_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + field_type: Mapped[str] = mapped_column(String(32), nullable=False, default="text") + sample_values: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + ordinal: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + dataset: Mapped[DataSetModel] = relationship( + "DataSetModel", back_populates="columns_rel" + ) diff --git a/backend/services/data-service/src/adapters/presenters/__init__.py b/backend/services/data-service/src/adapters/presenters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/src/adapters/presenters/dataset_presenter.py b/backend/services/data-service/src/adapters/presenters/dataset_presenter.py new file mode 100644 index 0000000..745cc65 --- /dev/null +++ b/backend/services/data-service/src/adapters/presenters/dataset_presenter.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from src.application.dto.dataset_response import ColumnInfo, DataSetResponse +from src.domain.entities.dataset import DataSet + + +class DataSetPresenter: + """Maps DataSet domain entities to API response DTOs.""" + + @staticmethod + def to_response(entity: DataSet) -> DataSetResponse: + return DataSetResponse( + id=str(entity.id), + file_name=entity.file_name, + sheet_name=entity.sheet_name, + columns=[ + ColumnInfo( + name=col.name, + field_type=col.field_type.value, + sample_values=col.sample_values, + ordinal=col.ordinal, + ) + for col in entity.columns + ], + row_count=entity.row_count, + data_structure=( + entity.data_structure.value if entity.data_structure else None + ), + created_at=entity.created_at, + updated_at=entity.updated_at, + ) diff --git a/backend/services/data-service/src/application/__init__.py b/backend/services/data-service/src/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/src/application/dto/__init__.py b/backend/services/data-service/src/application/dto/__init__.py new file mode 100644 index 0000000..270c892 --- /dev/null +++ b/backend/services/data-service/src/application/dto/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .dataset_response import ColumnInfo, DataSetResponse +from .import_result import ImportResult + +__all__ = ["ColumnInfo", "DataSetResponse", "ImportResult"] diff --git a/backend/services/data-service/src/application/dto/dataset_response.py b/backend/services/data-service/src/application/dto/dataset_response.py new file mode 100644 index 0000000..ac4ee0a --- /dev/null +++ b/backend/services/data-service/src/application/dto/dataset_response.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + + +class ColumnInfo(BaseModel): + name: str + field_type: str + sample_values: list[Any] = [] + ordinal: int + + +class DataSetResponse(BaseModel): + id: str + file_name: str + sheet_name: str | None = None + columns: list[ColumnInfo] + row_count: int + data_structure: str | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/services/data-service/src/application/dto/import_result.py b/backend/services/data-service/src/application/dto/import_result.py new file mode 100644 index 0000000..fc2f331 --- /dev/null +++ b/backend/services/data-service/src/application/dto/import_result.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +class ImportColumnInfo(BaseModel): + name: str + field_type: str + ordinal: int + + +class ImportResult(BaseModel): + dataset_id: str + file_name: str + sheet_name: str | None = None + row_count: int + columns: list[ImportColumnInfo] + data_structure: str | None = None + suggestions: list[str] = [] diff --git a/backend/services/data-service/src/application/ports/__init__.py b/backend/services/data-service/src/application/ports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/src/application/ports/input/__init__.py b/backend/services/data-service/src/application/ports/input/__init__.py new file mode 100644 index 0000000..c689cb2 --- /dev/null +++ b/backend/services/data-service/src/application/ports/input/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .get_dataset import IGetDataSetUseCase +from .import_data import IImportDataUseCase +from .list_datasets import IListDataSetsUseCase + +__all__ = ["IGetDataSetUseCase", "IImportDataUseCase", "IListDataSetsUseCase"] diff --git a/backend/services/data-service/src/application/ports/input/get_dataset.py b/backend/services/data-service/src/application/ports/input/get_dataset.py new file mode 100644 index 0000000..e454b99 --- /dev/null +++ b/backend/services/data-service/src/application/ports/input/get_dataset.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod + +from src.application.dto.dataset_response import DataSetResponse + + +class IGetDataSetUseCase(ABC): + @abstractmethod + async def execute(self, dataset_id: uuid.UUID) -> DataSetResponse: ... diff --git a/backend/services/data-service/src/application/ports/input/import_data.py b/backend/services/data-service/src/application/ports/input/import_data.py new file mode 100644 index 0000000..14321cc --- /dev/null +++ b/backend/services/data-service/src/application/ports/input/import_data.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from src.application.dto.import_result import ImportResult + + +class IImportDataUseCase(ABC): + @abstractmethod + async def execute(self, file_name: str, file_content: bytes) -> list[ImportResult]: ... diff --git a/backend/services/data-service/src/application/ports/input/list_datasets.py b/backend/services/data-service/src/application/ports/input/list_datasets.py new file mode 100644 index 0000000..f326fe7 --- /dev/null +++ b/backend/services/data-service/src/application/ports/input/list_datasets.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from src.application.dto.dataset_response import DataSetResponse + + +class IListDataSetsUseCase(ABC): + @abstractmethod + async def execute(self) -> list[DataSetResponse]: ... diff --git a/backend/services/data-service/src/application/ports/output/__init__.py b/backend/services/data-service/src/application/ports/output/__init__.py new file mode 100644 index 0000000..450ad4f --- /dev/null +++ b/backend/services/data-service/src/application/ports/output/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .file_parser import IFileParser, ParsedSheet + +__all__ = ["IFileParser", "ParsedSheet"] diff --git a/backend/services/data-service/src/application/ports/output/file_parser.py b/backend/services/data-service/src/application/ports/output/file_parser.py new file mode 100644 index 0000000..71c9f52 --- /dev/null +++ b/backend/services/data-service/src/application/ports/output/file_parser.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + + +@dataclass +class ParsedSheet: + sheet_name: str + columns: list[str] + rows: list[dict[str, Any]] + + +class IFileParser(ABC): + @abstractmethod + async def parse(self, file_name: str, file_content: bytes) -> list[ParsedSheet]: ... diff --git a/backend/services/data-service/src/application/usecases/__init__.py b/backend/services/data-service/src/application/usecases/__init__.py new file mode 100644 index 0000000..0c917c5 --- /dev/null +++ b/backend/services/data-service/src/application/usecases/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .delete_dataset_usecase import DeleteDataSetUseCase +from .get_dataset_usecase import GetDataSetUseCase +from .import_data_usecase import ImportDataUseCase +from .list_datasets_usecase import ListDataSetsUseCase + +__all__ = [ + "DeleteDataSetUseCase", + "GetDataSetUseCase", + "ImportDataUseCase", + "ListDataSetsUseCase", +] diff --git a/backend/services/data-service/src/application/usecases/delete_dataset_usecase.py b/backend/services/data-service/src/application/usecases/delete_dataset_usecase.py new file mode 100644 index 0000000..fe3abfb --- /dev/null +++ b/backend/services/data-service/src/application/usecases/delete_dataset_usecase.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import uuid + +from shared.exceptions import EntityNotFoundError + +from src.domain.repositories.dataset_repository import DataSetRepository + + +class DeleteDataSetUseCase: + def __init__(self, repository: DataSetRepository) -> None: + self._repository = repository + + async def execute(self, dataset_id: uuid.UUID) -> None: + existing = await self._repository.find_by_id(dataset_id) + if existing is None: + raise EntityNotFoundError("DataSet", str(dataset_id)) + await self._repository.delete(dataset_id) diff --git a/backend/services/data-service/src/application/usecases/get_dataset_usecase.py b/backend/services/data-service/src/application/usecases/get_dataset_usecase.py new file mode 100644 index 0000000..db2e236 --- /dev/null +++ b/backend/services/data-service/src/application/usecases/get_dataset_usecase.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import uuid + +from shared.exceptions import EntityNotFoundError + +from src.application.dto.dataset_response import ColumnInfo, DataSetResponse +from src.application.ports.input.get_dataset import IGetDataSetUseCase +from src.domain.repositories.dataset_repository import DataSetRepository + + +class GetDataSetUseCase(IGetDataSetUseCase): + def __init__(self, repository: DataSetRepository) -> None: + self._repository = repository + + async def execute(self, dataset_id: uuid.UUID) -> DataSetResponse: + dataset = await self._repository.find_by_id(dataset_id) + if dataset is None: + raise EntityNotFoundError("DataSet", str(dataset_id)) + return DataSetResponse( + id=str(dataset.id), + file_name=dataset.file_name, + sheet_name=dataset.sheet_name, + columns=[ + ColumnInfo( + name=col.name, + field_type=col.field_type.value, + sample_values=col.sample_values, + ordinal=col.ordinal, + ) + for col in dataset.columns + ], + row_count=dataset.row_count, + data_structure=( + dataset.data_structure.value if dataset.data_structure else None + ), + created_at=dataset.created_at, + updated_at=dataset.updated_at, + ) diff --git a/backend/services/data-service/src/application/usecases/import_data_usecase.py b/backend/services/data-service/src/application/usecases/import_data_usecase.py new file mode 100644 index 0000000..b9b30a7 --- /dev/null +++ b/backend/services/data-service/src/application/usecases/import_data_usecase.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from src.application.dto.import_result import ImportColumnInfo, ImportResult +from src.application.ports.input.import_data import IImportDataUseCase +from src.application.ports.output.file_parser import IFileParser +from src.domain.entities.column import Column +from src.domain.entities.dataset import DataSet +from src.domain.repositories.dataset_repository import DataSetRepository +from src.domain.services.data_structure_inference import infer_data_structure +from src.domain.services.field_type_inference import infer_field_type + + +class ImportDataUseCase(IImportDataUseCase): + def __init__( + self, + parser: IFileParser, + repository: DataSetRepository, + ) -> None: + self._parser = parser + self._repository = repository + + async def execute(self, file_name: str, file_content: bytes) -> list[ImportResult]: + sheets = await self._parser.parse(file_name, file_content) + results: list[ImportResult] = [] + + for sheet in sheets: + # Build Column entities with inferred types + columns: list[Column] = [] + for idx, col_name in enumerate(sheet.columns): + values = [row.get(col_name) for row in sheet.rows] + ft = infer_field_type(values) + sample = values[:5] + columns.append( + Column( + name=col_name, + field_type=ft, + sample_values=sample, + ordinal=idx, + ) + ) + + # Infer data structure + ds_type = infer_data_structure(columns) + + # Create aggregate + dataset = DataSet( + file_name=file_name, + sheet_name=sheet.sheet_name, + columns=columns, + row_count=len(sheet.rows), + data_structure=ds_type, + raw_data=sheet.rows, + ) + + errors = dataset.validate() + if errors: + raise ValueError(f"Validation failed: {'; '.join(errors)}") + + saved = await self._repository.save(dataset) + + # Build suggestions + suggestions = self._build_suggestions(ds_type, columns) + + results.append( + ImportResult( + dataset_id=str(saved.id), + file_name=file_name, + sheet_name=sheet.sheet_name, + row_count=len(sheet.rows), + columns=[ + ImportColumnInfo( + name=c.name, + field_type=c.field_type.value, + ordinal=c.ordinal, + ) + for c in columns + ], + data_structure=ds_type.value if ds_type else None, + suggestions=suggestions, + ) + ) + + return results + + @staticmethod + def _build_suggestions( + ds_type: object | None, + columns: list[Column], + ) -> list[str]: + from shared.types import DataStructureType + + suggestions: list[str] = [] + if ds_type == DataStructureType.TIME_SERIES: + suggestions.append("Recommended chart: Line chart for time-series data") + elif ds_type == DataStructureType.GEO: + suggestions.append("Recommended chart: Map chart for geographic data") + elif ds_type == DataStructureType.SINGLE_DIM_SINGLE_METRIC: + suggestions.append("Recommended chart: Bar chart or Pie chart") + elif ds_type == DataStructureType.SINGLE_DIM_MULTI_METRIC: + suggestions.append("Recommended chart: Grouped Bar chart") + return suggestions diff --git a/backend/services/data-service/src/application/usecases/list_datasets_usecase.py b/backend/services/data-service/src/application/usecases/list_datasets_usecase.py new file mode 100644 index 0000000..9a13c79 --- /dev/null +++ b/backend/services/data-service/src/application/usecases/list_datasets_usecase.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from src.application.dto.dataset_response import ColumnInfo, DataSetResponse +from src.application.ports.input.list_datasets import IListDataSetsUseCase +from src.domain.repositories.dataset_repository import DataSetRepository + + +class ListDataSetsUseCase(IListDataSetsUseCase): + def __init__(self, repository: DataSetRepository) -> None: + self._repository = repository + + async def execute(self) -> list[DataSetResponse]: + datasets = await self._repository.find_all() + return [ + DataSetResponse( + id=str(ds.id), + file_name=ds.file_name, + sheet_name=ds.sheet_name, + columns=[ + ColumnInfo( + name=col.name, + field_type=col.field_type.value, + sample_values=col.sample_values, + ordinal=col.ordinal, + ) + for col in ds.columns + ], + row_count=ds.row_count, + data_structure=( + ds.data_structure.value if ds.data_structure else None + ), + created_at=ds.created_at, + updated_at=ds.updated_at, + ) + for ds in datasets + ] diff --git a/backend/services/data-service/src/domain/__init__.py b/backend/services/data-service/src/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/src/domain/entities/__init__.py b/backend/services/data-service/src/domain/entities/__init__.py new file mode 100644 index 0000000..8949bb9 --- /dev/null +++ b/backend/services/data-service/src/domain/entities/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .column import Column +from .dataset import DataSet +from .data_row import DataRow + +__all__ = ["Column", "DataSet", "DataRow"] diff --git a/backend/services/data-service/src/domain/entities/column.py b/backend/services/data-service/src/domain/entities/column.py new file mode 100644 index 0000000..9b423f8 --- /dev/null +++ b/backend/services/data-service/src/domain/entities/column.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from shared.types import FieldType + + +@dataclass +class Column: + """Column entity representing a single column in a dataset.""" + + name: str + field_type: FieldType = FieldType.TEXT + sample_values: list[Any] = field(default_factory=list) + ordinal: int = 0 + + def __post_init__(self) -> None: + if not self.name or not self.name.strip(): + raise ValueError("Column name must not be empty") diff --git a/backend/services/data-service/src/domain/entities/data_row.py b/backend/services/data-service/src/domain/entities/data_row.py new file mode 100644 index 0000000..b294659 --- /dev/null +++ b/backend/services/data-service/src/domain/entities/data_row.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +DataRow = dict[str, Any] diff --git a/backend/services/data-service/src/domain/entities/dataset.py b/backend/services/data-service/src/domain/entities/dataset.py new file mode 100644 index 0000000..309925d --- /dev/null +++ b/backend/services/data-service/src/domain/entities/dataset.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from shared.base_entity import BaseEntity +from shared.types import DataStructureType + +from .column import Column +from .data_row import DataRow + + +@dataclass +class DataSet(BaseEntity): + """DataSet aggregate root representing an imported file/sheet.""" + + file_name: str = "" + sheet_name: str | None = None + columns: list[Column] = field(default_factory=list) + row_count: int = 0 + data_structure: DataStructureType | None = None + raw_data: list[DataRow] = field(default_factory=list) + + # ---- behaviour ---- + + def infer_field_types(self) -> None: + """Delegate field-type inference to the domain service and update columns.""" + from src.domain.services.field_type_inference import infer_field_type + + for col in self.columns: + values = [row.get(col.name) for row in self.raw_data] + col.field_type = infer_field_type(values) + + def infer_data_structure(self) -> None: + """Delegate data-structure inference to the domain service.""" + from src.domain.services.data_structure_inference import ( + infer_data_structure, + ) + + self.data_structure = infer_data_structure(self.columns) + + def get_column_by_name(self, name: str) -> Column | None: + for col in self.columns: + if col.name == name: + return col + return None + + def validate(self) -> list[str]: + """Return a list of validation error messages (empty means valid).""" + errors: list[str] = [] + if not self.file_name: + errors.append("file_name is required") + if not self.columns: + errors.append("DataSet must contain at least one column") + if self.row_count < 0: + errors.append("row_count must be non-negative") + col_names = [c.name for c in self.columns] + if len(col_names) != len(set(col_names)): + errors.append("Duplicate column names detected") + return errors diff --git a/backend/services/data-service/src/domain/repositories/__init__.py b/backend/services/data-service/src/domain/repositories/__init__.py new file mode 100644 index 0000000..1393153 --- /dev/null +++ b/backend/services/data-service/src/domain/repositories/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .dataset_repository import DataSetRepository + +__all__ = ["DataSetRepository"] diff --git a/backend/services/data-service/src/domain/repositories/dataset_repository.py b/backend/services/data-service/src/domain/repositories/dataset_repository.py new file mode 100644 index 0000000..fdaff8f --- /dev/null +++ b/backend/services/data-service/src/domain/repositories/dataset_repository.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from shared.base_repository import BaseRepository + +from src.domain.entities.dataset import DataSet + + +class DataSetRepository(BaseRepository[DataSet]): + """Abstract repository for DataSet aggregate persistence.""" diff --git a/backend/services/data-service/src/domain/services/__init__.py b/backend/services/data-service/src/domain/services/__init__.py new file mode 100644 index 0000000..d6ba96e --- /dev/null +++ b/backend/services/data-service/src/domain/services/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .data_structure_inference import infer_data_structure +from .field_type_inference import infer_all_field_types, infer_field_type + +__all__ = ["infer_data_structure", "infer_all_field_types", "infer_field_type"] diff --git a/backend/services/data-service/src/domain/services/data_structure_inference.py b/backend/services/data-service/src/domain/services/data_structure_inference.py new file mode 100644 index 0000000..dc231f9 --- /dev/null +++ b/backend/services/data-service/src/domain/services/data_structure_inference.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from shared.types import DataStructureType, FieldType + +from src.domain.entities.column import Column + + +def infer_data_structure(columns: list[Column]) -> DataStructureType: + """Infer the high-level data structure from the column list. + + This mirrors the frontend inference logic, analysing the mix of field + types to classify the dataset. + """ + if not columns: + return DataStructureType.SINGLE_DIM_SINGLE_METRIC + + type_counts: dict[FieldType, int] = {} + for col in columns: + type_counts[col.field_type] = type_counts.get(col.field_type, 0) + 1 + + num_numbers = type_counts.get(FieldType.NUMBER, 0) + num_dates = type_counts.get(FieldType.DATE, 0) + num_text = type_counts.get(FieldType.TEXT, 0) + num_geo = type_counts.get(FieldType.GEO, 0) + num_pct = type_counts.get(FieldType.PERCENTAGE, 0) + num_metrics = num_numbers + num_pct + num_dims = num_text + num_geo + num_dates + total = len(columns) + + # Total / KPI: single numeric value (1 column, numeric) + if total == 1 and num_metrics == 1: + return DataStructureType.TOTAL + + # Geo data + if num_geo >= 1: + return DataStructureType.GEO + + # Time series: at least one date column + at least one metric + if num_dates >= 1 and num_metrics >= 1: + return DataStructureType.TIME_SERIES + + # YoY / MoM: exactly 1 text dimension + multiple metrics (common pattern) + # Heuristic: if column names contain year/month-like patterns this applies + # For now we rely on structure: 1 text dim + >=2 metrics + if num_text == 1 and num_metrics >= 2 and num_dates == 0: + return DataStructureType.SINGLE_DIM_MULTI_METRIC + + # Text frequency: only text columns (e.g. word lists) + if num_metrics == 0 and num_text >= 1: + return DataStructureType.TEXT_FREQUENCY + + # Two-dimensional evaluation: 2 metrics + 1 text dim + if num_text == 1 and num_metrics == 2 and num_dates == 0: + return DataStructureType.TWO_DIM_EVALUATION + + # Dual dimension + single metric + if num_text >= 2 and num_metrics == 1: + return DataStructureType.DUAL_DIM_SINGLE_METRIC + + # Dual dimension + multi metric + if num_text >= 2 and num_metrics >= 2: + return DataStructureType.DUAL_DIM_MULTI_METRIC + + # Single dimension + single metric + if num_dims == 1 and num_metrics == 1: + return DataStructureType.SINGLE_DIM_SINGLE_METRIC + + # Fallback + return DataStructureType.SINGLE_DIM_SINGLE_METRIC diff --git a/backend/services/data-service/src/domain/services/field_type_inference.py b/backend/services/data-service/src/domain/services/field_type_inference.py new file mode 100644 index 0000000..6315cc8 --- /dev/null +++ b/backend/services/data-service/src/domain/services/field_type_inference.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import re +from typing import Any + +from shared.types import FieldType + +# --------------------------------------------------------------------------- +# Patterns +# --------------------------------------------------------------------------- + +_DATE_PATTERNS: list[re.Pattern[str]] = [ + re.compile(r"^\d{4}[-/]\d{1,2}[-/]\d{1,2}$"), + re.compile(r"^\d{1,2}[-/]\d{1,2}[-/]\d{4}$"), + re.compile(r"^\d{4}年\d{1,2}月(\d{1,2}日)?$"), + re.compile(r"^\d{4}-\d{2}$"), # yyyy-MM + re.compile(r"^\d{4}年\d{1,2}月$"), # yyyy年M月 + re.compile(r"^Q[1-4]\s*\d{4}$", re.IGNORECASE), +] + +_PERCENTAGE_PATTERN = re.compile(r"^-?\d+(\.\d+)?%$") + +_CHINESE_GEO_SUFFIXES = ( + "省", "市", "区", "县", "镇", "乡", "村", + "自治区", "自治州", "自治县", "特别行政区", +) + +_KNOWN_GEO_NAMES = { + "北京", "上海", "天津", "重庆", + "河北", "山西", "辽宁", "吉林", "黑龙江", + "江苏", "浙江", "安徽", "福建", "江西", "山东", + "河南", "湖北", "湖南", "广东", "海南", + "四川", "贵州", "云南", "陕西", "甘肃", + "青海", "台湾", "内蒙古", "广西", "西藏", + "宁夏", "新疆", "香港", "澳门", +} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _is_number(value: Any) -> bool: + if isinstance(value, (int, float)): + return True + if isinstance(value, str): + cleaned = value.replace(",", "").strip() + try: + float(cleaned) + return True + except ValueError: + return False + return False + + +def _is_date(value: Any) -> bool: + if not isinstance(value, str): + return False + s = value.strip() + return any(p.match(s) for p in _DATE_PATTERNS) + + +def _is_percentage(value: Any) -> bool: + if not isinstance(value, str): + return False + return _PERCENTAGE_PATTERN.match(value.strip()) is not None + + +def _is_geo(value: Any) -> bool: + if not isinstance(value, str): + return False + s = value.strip() + if s in _KNOWN_GEO_NAMES: + return True + return any(s.endswith(suffix) for suffix in _CHINESE_GEO_SUFFIXES) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def infer_field_type(values: list[Any]) -> FieldType: + """Infer the *FieldType* of a column from its sample values. + + Strategy: take the non-null values and see which type the majority match. + """ + non_null = [v for v in values if v is not None and str(v).strip() != ""] + if not non_null: + return FieldType.TEXT + + sample = non_null[:200] + total = len(sample) + threshold = 0.6 # 60% of non-null values must match + + # Check percentage first (before number, since "10%" is also numeric-ish) + if sum(1 for v in sample if _is_percentage(v)) / total >= threshold: + return FieldType.PERCENTAGE + + # Check date + if sum(1 for v in sample if _is_date(v)) / total >= threshold: + return FieldType.DATE + + # Check geo + if sum(1 for v in sample if _is_geo(v)) / total >= threshold: + return FieldType.GEO + + # Check number + if sum(1 for v in sample if _is_number(v)) / total >= threshold: + return FieldType.NUMBER + + return FieldType.TEXT + + +def infer_all_field_types( + columns: list[str], + rows: list[dict[str, Any]], +) -> list[tuple[str, FieldType]]: + """Infer field types for every column given a list of column names and row dicts.""" + result: list[tuple[str, FieldType]] = [] + for col_name in columns: + values = [row.get(col_name) for row in rows] + result.append((col_name, infer_field_type(values))) + return result diff --git a/backend/services/data-service/src/domain/value_objects/__init__.py b/backend/services/data-service/src/domain/value_objects/__init__.py new file mode 100644 index 0000000..28067e8 --- /dev/null +++ b/backend/services/data-service/src/domain/value_objects/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .data_structure import DataStructureType +from .field_type import FieldType +from .file_format import FileFormat + +__all__ = ["DataStructureType", "FieldType", "FileFormat"] diff --git a/backend/services/data-service/src/domain/value_objects/data_structure.py b/backend/services/data-service/src/domain/value_objects/data_structure.py new file mode 100644 index 0000000..eed921e --- /dev/null +++ b/backend/services/data-service/src/domain/value_objects/data_structure.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from shared.types import DataStructureType + +__all__ = ["DataStructureType"] diff --git a/backend/services/data-service/src/domain/value_objects/field_type.py b/backend/services/data-service/src/domain/value_objects/field_type.py new file mode 100644 index 0000000..a35eb31 --- /dev/null +++ b/backend/services/data-service/src/domain/value_objects/field_type.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from shared.types import FieldType + +__all__ = ["FieldType"] diff --git a/backend/services/data-service/src/domain/value_objects/file_format.py b/backend/services/data-service/src/domain/value_objects/file_format.py new file mode 100644 index 0000000..eac4cb6 --- /dev/null +++ b/backend/services/data-service/src/domain/value_objects/file_format.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from shared.types import FileFormat + +__all__ = ["FileFormat"] diff --git a/backend/services/data-service/src/infrastructure/__init__.py b/backend/services/data-service/src/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/src/infrastructure/api/__init__.py b/backend/services/data-service/src/infrastructure/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/data-service/src/infrastructure/api/app.py b/backend/services/data-service/src/infrastructure/api/app.py new file mode 100644 index 0000000..a4f484c --- /dev/null +++ b/backend/services/data-service/src/infrastructure/api/app.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.infrastructure.config import settings +from src.infrastructure.api.routes import router + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + # Startup + yield + # Shutdown + from src.adapters.persistence.database import engine + + await engine.dispose() + + +def create_app() -> FastAPI: + app = FastAPI( + title="DataViz Pro - Data Service", + version="0.1.0", + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(router) + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok", "service": "data-service"} + + return app diff --git a/backend/services/data-service/src/infrastructure/api/dependencies.py b/backend/services/data-service/src/infrastructure/api/dependencies.py new file mode 100644 index 0000000..4c8c933 --- /dev/null +++ b/backend/services/data-service/src/infrastructure/api/dependencies.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.parsers.parser_factory import ParserFactory +from src.adapters.persistence.database import get_session +from src.adapters.persistence.dataset_repository_impl import DataSetRepositoryImpl +from src.application.ports.output.file_parser import IFileParser +from src.application.usecases.delete_dataset_usecase import DeleteDataSetUseCase +from src.application.usecases.get_dataset_usecase import GetDataSetUseCase +from src.application.usecases.import_data_usecase import ImportDataUseCase +from src.application.usecases.list_datasets_usecase import ListDataSetsUseCase +from src.domain.repositories.dataset_repository import DataSetRepository + + +# ---- output ports ---- + +def get_file_parser() -> IFileParser: + return ParserFactory() + + +# ---- repositories ---- + +async def get_dataset_repository( + session: AsyncSession = Depends(get_session), +) -> DataSetRepository: + return DataSetRepositoryImpl(session) + + +# ---- use cases ---- + +async def get_import_data_usecase( + parser: IFileParser = Depends(get_file_parser), + repository: DataSetRepository = Depends(get_dataset_repository), +) -> ImportDataUseCase: + return ImportDataUseCase(parser=parser, repository=repository) + + +async def get_get_dataset_usecase( + repository: DataSetRepository = Depends(get_dataset_repository), +) -> GetDataSetUseCase: + return GetDataSetUseCase(repository=repository) + + +async def get_list_datasets_usecase( + repository: DataSetRepository = Depends(get_dataset_repository), +) -> ListDataSetsUseCase: + return ListDataSetsUseCase(repository=repository) + + +async def get_delete_dataset_usecase( + repository: DataSetRepository = Depends(get_dataset_repository), +) -> DeleteDataSetUseCase: + return DeleteDataSetUseCase(repository=repository) diff --git a/backend/services/data-service/src/infrastructure/api/routes.py b/backend/services/data-service/src/infrastructure/api/routes.py new file mode 100644 index 0000000..2bea1cd --- /dev/null +++ b/backend/services/data-service/src/infrastructure/api/routes.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query + +from shared.exceptions import EntityNotFoundError, FileParsingError + +from src.application.dto.dataset_response import DataSetResponse +from src.application.dto.import_result import ImportResult +from src.application.usecases.delete_dataset_usecase import DeleteDataSetUseCase +from src.application.usecases.get_dataset_usecase import GetDataSetUseCase +from src.application.usecases.import_data_usecase import ImportDataUseCase +from src.application.usecases.list_datasets_usecase import ListDataSetsUseCase +from src.infrastructure.api.dependencies import ( + get_delete_dataset_usecase, + get_get_dataset_usecase, + get_import_data_usecase, + get_list_datasets_usecase, + get_dataset_repository, +) +from src.domain.repositories.dataset_repository import DataSetRepository + +router = APIRouter(prefix="/api/v1/datasets", tags=["datasets"]) + + +@router.post("/import", response_model=list[ImportResult], status_code=201) +async def import_data( + file: UploadFile = File(...), + use_case: ImportDataUseCase = Depends(get_import_data_usecase), +) -> list[ImportResult]: + if not file.filename: + raise HTTPException(status_code=400, detail="File name is required") + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="Uploaded file is empty") + try: + return await use_case.execute(file.filename, content) + except FileParsingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + +@router.get("", response_model=list[DataSetResponse]) +async def list_datasets( + use_case: ListDataSetsUseCase = Depends(get_list_datasets_usecase), +) -> list[DataSetResponse]: + return await use_case.execute() + + +@router.get("/{dataset_id}", response_model=DataSetResponse) +async def get_dataset( + dataset_id: uuid.UUID, + use_case: GetDataSetUseCase = Depends(get_get_dataset_usecase), +) -> DataSetResponse: + try: + return await use_case.execute(dataset_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail="DataSet not found") + + +@router.get("/{dataset_id}/rows") +async def get_dataset_rows( + dataset_id: uuid.UUID, + limit: int = Query(default=100, ge=1, le=10000), + offset: int = Query(default=0, ge=0), + repository: DataSetRepository = Depends(get_dataset_repository), +) -> dict[str, Any]: + dataset = await repository.find_by_id(dataset_id) + if dataset is None: + raise HTTPException(status_code=404, detail="DataSet not found") + total = len(dataset.raw_data) + rows = dataset.raw_data[offset : offset + limit] + return {"total": total, "limit": limit, "offset": offset, "rows": rows} + + +@router.patch("/{dataset_id}/rows/{row_index}") +async def update_row( + dataset_id: uuid.UUID, + row_index: int, + payload: dict[str, Any], + repository: DataSetRepository = Depends(get_dataset_repository), +) -> dict[str, Any]: + dataset = await repository.find_by_id(dataset_id) + if dataset is None: + raise HTTPException(status_code=404, detail="DataSet not found") + if row_index < 0 or row_index >= len(dataset.raw_data): + raise HTTPException(status_code=404, detail="Row index out of range") + dataset.raw_data[row_index].update(payload) + await repository.save(dataset) + return {"row_index": row_index, "row": dataset.raw_data[row_index]} + + +@router.delete("/{dataset_id}", status_code=204) +async def delete_dataset( + dataset_id: uuid.UUID, + use_case: DeleteDataSetUseCase = Depends(get_delete_dataset_usecase), +) -> None: + try: + await use_case.execute(dataset_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail="DataSet not found") + + +@router.get("/{dataset_id}/structure") +async def get_dataset_structure( + dataset_id: uuid.UUID, + repository: DataSetRepository = Depends(get_dataset_repository), +) -> dict[str, Any]: + dataset = await repository.find_by_id(dataset_id) + if dataset is None: + raise HTTPException(status_code=404, detail="DataSet not found") + return { + "dataset_id": str(dataset.id), + "data_structure": dataset.data_structure.value if dataset.data_structure else None, + "columns": [ + { + "name": col.name, + "field_type": col.field_type.value, + "ordinal": col.ordinal, + } + for col in dataset.columns + ], + } diff --git a/backend/services/data-service/src/infrastructure/config.py b/backend/services/data-service/src/infrastructure/config.py new file mode 100644 index 0000000..084d1a1 --- /dev/null +++ b/backend/services/data-service/src/infrastructure/config.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/data_db" + APP_HOST: str = "0.0.0.0" + APP_PORT: int = 8000 + CORS_ORIGINS: list[str] = ["*"] + + model_config = {"env_prefix": "", "case_sensitive": True} + + +settings = Settings() diff --git a/backend/services/data-service/src/infrastructure/main.py b/backend/services/data-service/src/infrastructure/main.py new file mode 100644 index 0000000..fef316b --- /dev/null +++ b/backend/services/data-service/src/infrastructure/main.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import uvicorn + +from src.infrastructure.api.app import create_app +from src.infrastructure.config import settings + +app = create_app() + +if __name__ == "__main__": + uvicorn.run( + "src.infrastructure.main:app", + host=settings.APP_HOST, + port=settings.APP_PORT, + reload=True, + ) diff --git a/backend/services/export-service/Dockerfile b/backend/services/export-service/Dockerfile new file mode 100644 index 0000000..2605f72 --- /dev/null +++ b/backend/services/export-service/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev && \ + rm -rf /var/lib/apt/lists/* + +# Uncomment below if export-service needs headless browser / PDF rendering: +# --- Playwright (Chromium) --- +# RUN pip install playwright && playwright install --with-deps chromium +# --- WeasyPrint --- +# RUN apt-get update && apt-get install -y --no-install-recommends \ +# libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfribidi0 \ +# libgdk-pixbuf2.0-0 libcairo2 && \ +# rm -rf /var/lib/apt/lists/* + +# Copy shared library +COPY shared/ /app/shared/ + +# Copy service code +COPY services/export-service/pyproject.toml /app/ +COPY services/export-service/src/ /app/src/ +COPY services/export-service/alembic/ /app/alembic/ +COPY services/export-service/alembic.ini /app/alembic.ini + +# Install dependencies +RUN pip install --no-cache-dir poetry && \ + poetry config virtualenvs.create false && \ + poetry install --no-interaction --no-ansi --no-root + +# Set PYTHONPATH to include shared +ENV PYTHONPATH=/app:/app/src + +EXPOSE 8000 + +CMD ["python", "-m", "src.infrastructure.main"] diff --git a/backend/services/export-service/alembic.ini b/backend/services/export-service/alembic.ini new file mode 100644 index 0000000..b58097e --- /dev/null +++ b/backend/services/export-service/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/export_db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/services/export-service/alembic/env.py b/backend/services/export-service/alembic/env.py new file mode 100644 index 0000000..c1c7792 --- /dev/null +++ b/backend/services/export-service/alembic/env.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from src.adapters.persistence.models import Base +from src.infrastructure.config import get_settings + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/services/export-service/alembic/versions/__init__.py b/backend/services/export-service/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/pyproject.toml b/backend/services/export-service/pyproject.toml new file mode 100644 index 0000000..c9aae49 --- /dev/null +++ b/backend/services/export-service/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "export-service" +version = "0.1.0" +description = "Export microservice - generates PNG, PDF, Excel, PPT, HTML exports" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110.0", + "uvicorn[standard]>=0.27.0", + "sqlalchemy[asyncio]>=2.0.25", + "asyncpg>=0.29.0", + "alembic>=1.13.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", + "httpx>=0.26.0", + "openpyxl>=3.1.2", + "python-pptx>=0.6.23", + "jinja2>=3.1.3", + # "weasyprint>=61.0", # complex native deps – enable if needed + "reportlab>=4.1.0", +] + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.backends._legacy:_Backend" diff --git a/backend/services/export-service/src/__init__.py b/backend/services/export-service/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/adapters/__init__.py b/backend/services/export-service/src/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/adapters/clients/__init__.py b/backend/services/export-service/src/adapters/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/adapters/clients/chart_service_client_impl.py b/backend/services/export-service/src/adapters/clients/chart_service_client_impl.py new file mode 100644 index 0000000..d96e0bb --- /dev/null +++ b/backend/services/export-service/src/adapters/clients/chart_service_client_impl.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from shared.exceptions import ServiceCommunicationError +from shared.http_client import ServiceHttpClient +from src.application.ports.output.chart_service_client import IChartServiceClient + + +class ChartServiceClientImpl(IChartServiceClient): + def __init__(self, http_client: ServiceHttpClient) -> None: + self._http = http_client + + async def get_chart(self, chart_id: uuid.UUID) -> dict[str, Any]: + try: + return await self._http.get(f"/api/v1/charts/{chart_id}") + except Exception as exc: + raise ServiceCommunicationError("chart-service", str(exc)) from exc + + async def get_chart_option(self, chart_id: uuid.UUID) -> dict[str, Any]: + try: + return await self._http.get(f"/api/v1/charts/{chart_id}/option") + except Exception as exc: + raise ServiceCommunicationError("chart-service", str(exc)) from exc diff --git a/backend/services/export-service/src/adapters/clients/data_service_client_impl.py b/backend/services/export-service/src/adapters/clients/data_service_client_impl.py new file mode 100644 index 0000000..681d668 --- /dev/null +++ b/backend/services/export-service/src/adapters/clients/data_service_client_impl.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from shared.exceptions import ServiceCommunicationError +from shared.http_client import ServiceHttpClient +from src.application.ports.output.data_service_client import IDataServiceClient + + +class DataServiceClientImpl(IDataServiceClient): + def __init__(self, http_client: ServiceHttpClient) -> None: + self._http = http_client + + async def get_dataset(self, dataset_id: uuid.UUID) -> dict[str, Any]: + try: + return await self._http.get(f"/api/v1/datasets/{dataset_id}") + except Exception as exc: + raise ServiceCommunicationError("data-service", str(exc)) from exc + + async def get_rows(self, dataset_id: uuid.UUID) -> list[dict[str, Any]]: + try: + return await self._http.get(f"/api/v1/datasets/{dataset_id}/rows") + except Exception as exc: + raise ServiceCommunicationError("data-service", str(exc)) from exc diff --git a/backend/services/export-service/src/adapters/generators/__init__.py b/backend/services/export-service/src/adapters/generators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/adapters/generators/jinja2_html_generator.py b/backend/services/export-service/src/adapters/generators/jinja2_html_generator.py new file mode 100644 index 0000000..e2bb6f6 --- /dev/null +++ b/backend/services/export-service/src/adapters/generators/jinja2_html_generator.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json + +from jinja2 import Template + +from src.application.ports.output.html_generator import IHtmlGenerator + +_HTML_TEMPLATE = Template("""\ + + + + + + {{ title }} + + + + +

{{ title }}

+ {% for opt in charts %} +
+ {% endfor %} + + + +""") + + +class Jinja2HtmlGenerator(IHtmlGenerator): + """Generate a self-contained HTML page with ECharts rendered via CDN.""" + + async def generate(self, charts_option: list[dict], title: str) -> bytes: + html = _HTML_TEMPLATE.render( + title=title, + charts=charts_option, + charts_json=json.dumps(charts_option, ensure_ascii=False), + ) + return html.encode("utf-8") diff --git a/backend/services/export-service/src/adapters/generators/openpyxl_excel_generator.py b/backend/services/export-service/src/adapters/generators/openpyxl_excel_generator.py new file mode 100644 index 0000000..864c6d1 --- /dev/null +++ b/backend/services/export-service/src/adapters/generators/openpyxl_excel_generator.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import io + +from openpyxl import Workbook +from openpyxl.styles import Font + +from src.application.ports.output.excel_generator import IExcelGenerator + + +class OpenpyxlExcelGenerator(IExcelGenerator): + """Generate an XLSX workbook from tabular data.""" + + async def generate( + self, data: list[dict], columns: list[str], sheet_name: str + ) -> bytes: + wb = Workbook() + ws = wb.active + ws.title = sheet_name[:31] # Excel sheet name limit + + # Header row + header_font = Font(bold=True) + for col_idx, col_name in enumerate(columns, start=1): + cell = ws.cell(row=1, column=col_idx, value=col_name) + cell.font = header_font + + # Data rows + for row_idx, row_data in enumerate(data, start=2): + for col_idx, col_name in enumerate(columns, start=1): + ws.cell(row=row_idx, column=col_idx, value=row_data.get(col_name)) + + # Auto-adjust column widths + for col_idx, col_name in enumerate(columns, start=1): + max_len = len(str(col_name)) + for row_data in data[:100]: # sample first 100 rows + val = row_data.get(col_name, "") + max_len = max(max_len, len(str(val))) + ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = ( + min(max_len + 2, 50) + ) + + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() diff --git a/backend/services/export-service/src/adapters/generators/playwright_image_renderer.py b/backend/services/export-service/src/adapters/generators/playwright_image_renderer.py new file mode 100644 index 0000000..e702308 --- /dev/null +++ b/backend/services/export-service/src/adapters/generators/playwright_image_renderer.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import io +import json +import struct +import zlib + +from src.application.ports.output.image_renderer import IImageRenderer + + +class PlaceholderImageRenderer(IImageRenderer): + """Stub renderer that produces a minimal valid PNG placeholder. + + In production this would launch a headless browser (Playwright / Puppeteer) + to render an ECharts option into a real chart image. The placeholder + generates a 400x300 light-grey PNG so downstream generators always receive + valid image bytes. + """ + + async def render(self, echarts_option: dict) -> bytes: + return _create_placeholder_png( + width=400, + height=300, + label=echarts_option.get("title", {}).get("text", "chart"), + ) + + +def _create_placeholder_png(width: int, height: int, label: str = "") -> bytes: + """Create a minimal valid PNG (single-colour, no text rendering).""" + + def _chunk(chunk_type: bytes, data: bytes) -> bytes: + c = chunk_type + data + return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF) + + # IHDR + ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + ihdr = _chunk(b"IHDR", ihdr_data) + + # IDAT – uncompressed rows: filter byte 0 + RGB(220,220,220) per pixel + raw_row = b"\x00" + b"\xdc\xdc\xdc" * width + raw = raw_row * height + compressed = zlib.compress(raw) + idat = _chunk(b"IDAT", compressed) + + # IEND + iend = _chunk(b"IEND", b"") + + buf = io.BytesIO() + buf.write(b"\x89PNG\r\n\x1a\n") # PNG signature + buf.write(ihdr) + buf.write(idat) + buf.write(iend) + return buf.getvalue() diff --git a/backend/services/export-service/src/adapters/generators/pptx_generator.py b/backend/services/export-service/src/adapters/generators/pptx_generator.py new file mode 100644 index 0000000..9afba27 --- /dev/null +++ b/backend/services/export-service/src/adapters/generators/pptx_generator.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import io + +from pptx import Presentation +from pptx.util import Inches, Pt + +from src.application.ports.output.ppt_generator import IPptGenerator + + +class PptxGenerator(IPptGenerator): + """Generate a PPTX presentation with one slide per chart.""" + + async def generate(self, charts: list[tuple[str, bytes]]) -> bytes: + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + + blank_layout = prs.slide_layouts[6] # blank + + for title, png_bytes in charts: + slide = prs.slides.add_slide(blank_layout) + + # Title text box + txBox = slide.shapes.add_textbox( + Inches(0.5), Inches(0.3), Inches(12), Inches(0.8) + ) + tf = txBox.text_frame + p = tf.paragraphs[0] + p.text = title + p.font.size = Pt(28) + p.font.bold = True + + # Chart image + if png_bytes: + img_stream = io.BytesIO(png_bytes) + slide.shapes.add_picture( + img_stream, Inches(1.5), Inches(1.5), Inches(10), Inches(5.5) + ) + + buf = io.BytesIO() + prs.save(buf) + return buf.getvalue() diff --git a/backend/services/export-service/src/adapters/generators/weasyprint_pdf_generator.py b/backend/services/export-service/src/adapters/generators/weasyprint_pdf_generator.py new file mode 100644 index 0000000..e56d10f --- /dev/null +++ b/backend/services/export-service/src/adapters/generators/weasyprint_pdf_generator.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import io + +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import inch +from reportlab.lib.utils import ImageReader +from reportlab.platypus import ( + Image, + PageBreak, + Paragraph, + SimpleDocTemplate, + Spacer, +) +from reportlab.lib.styles import getSampleStyleSheet + +from src.application.ports.output.pdf_generator import IPdfGenerator + + +class ReportLabPdfGenerator(IPdfGenerator): + """Generate a multi-page PDF with chart images using ReportLab.""" + + async def generate(self, charts: list[dict], title: str) -> bytes: + buf = io.BytesIO() + doc = SimpleDocTemplate(buf, pagesize=A4) + styles = getSampleStyleSheet() + story: list = [] + + # Title page + story.append(Paragraph(title, styles["Title"])) + story.append(Spacer(1, 0.5 * inch)) + + for idx, chart in enumerate(charts): + chart_title = chart.get("title", f"Chart {idx + 1}") + image_bytes = chart.get("image", b"") + + story.append(Paragraph(chart_title, styles["Heading2"])) + story.append(Spacer(1, 0.2 * inch)) + + if image_bytes: + img_reader = ImageReader(io.BytesIO(image_bytes)) + img_width, img_height = img_reader.getSize() + aspect = img_height / img_width if img_width else 1 + display_width = 5.5 * inch + display_height = display_width * aspect + story.append( + Image(io.BytesIO(image_bytes), width=display_width, height=display_height) + ) + + if idx < len(charts) - 1: + story.append(PageBreak()) + + doc.build(story) + return buf.getvalue() diff --git a/backend/services/export-service/src/adapters/persistence/__init__.py b/backend/services/export-service/src/adapters/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/adapters/persistence/database.py b/backend/services/export-service/src/adapters/persistence/database.py new file mode 100644 index 0000000..ae99102 --- /dev/null +++ b/backend/services/export-service/src/adapters/persistence/database.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +_engine: AsyncEngine | None = None +_session_factory: async_sessionmaker[AsyncSession] | None = None + + +def init_engine(database_url: str) -> AsyncEngine: + global _engine, _session_factory + _engine = create_async_engine(database_url, echo=False, future=True) + _session_factory = async_sessionmaker(_engine, expire_on_commit=False) + return _engine + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + if _session_factory is None: + raise RuntimeError("Database engine not initialised. Call init_engine first.") + return _session_factory + + +async def get_session() -> AsyncSession: # type: ignore[misc] + factory = get_session_factory() + async with factory() as session: + yield session # type: ignore[misc] diff --git a/backend/services/export-service/src/adapters/persistence/export_task_repository_impl.py b/backend/services/export-service/src/adapters/persistence/export_task_repository_impl.py new file mode 100644 index 0000000..eaf597b --- /dev/null +++ b/backend/services/export-service/src/adapters/persistence/export_task_repository_impl.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import uuid +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.types import ExportFormat, ExportStatus +from src.adapters.persistence.models import ExportTaskModel +from src.domain.entities.export_task import ExportTask +from src.domain.repositories.export_task_repository import ExportTaskRepository + + +class ExportTaskRepositoryImpl(ExportTaskRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + # ── mapping helpers ──────────────────────────────────────────── + + @staticmethod + def _to_entity(model: ExportTaskModel) -> ExportTask: + return ExportTask( + id=model.id, + format=ExportFormat(model.format), + chart_ids=model.chart_ids or [], + dataset_id=model.dataset_id, + status=ExportStatus(model.status), + file_path=model.file_path, + error_message=model.error_message, + completed_at=model.completed_at, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + @staticmethod + def _to_model(entity: ExportTask) -> ExportTaskModel: + return ExportTaskModel( + id=entity.id, + format=entity.format.value, + chart_ids=entity.chart_ids, + dataset_id=entity.dataset_id, + status=entity.status.value, + file_path=entity.file_path, + error_message=entity.error_message, + completed_at=entity.completed_at, + created_at=entity.created_at, + updated_at=entity.updated_at, + ) + + # ── BaseRepository implementation ────────────────────────────── + + async def find_by_id(self, entity_id: uuid.UUID) -> Optional[ExportTask]: + result = await self._session.get(ExportTaskModel, entity_id) + return self._to_entity(result) if result else None + + async def find_all(self) -> list[ExportTask]: + stmt = select(ExportTaskModel).order_by(ExportTaskModel.created_at.desc()) + result = await self._session.execute(stmt) + return [self._to_entity(row) for row in result.scalars().all()] + + async def save(self, entity: ExportTask) -> ExportTask: + model = await self._session.get(ExportTaskModel, entity.id) + if model is None: + model = self._to_model(entity) + self._session.add(model) + else: + model.format = entity.format.value + model.chart_ids = entity.chart_ids + model.dataset_id = entity.dataset_id + model.status = entity.status.value + model.file_path = entity.file_path + model.error_message = entity.error_message + model.completed_at = entity.completed_at + model.updated_at = entity.updated_at + await self._session.flush() + await self._session.commit() + await self._session.refresh(model) + return self._to_entity(model) + + async def delete(self, entity_id: uuid.UUID) -> None: + model = await self._session.get(ExportTaskModel, entity_id) + if model: + await self._session.delete(model) + await self._session.commit() + + # ── extra query ──────────────────────────────────────────────── + + async def find_by_status(self, status: ExportStatus) -> list[ExportTask]: + stmt = ( + select(ExportTaskModel) + .where(ExportTaskModel.status == status.value) + .order_by(ExportTaskModel.created_at.desc()) + ) + result = await self._session.execute(stmt) + return [self._to_entity(row) for row in result.scalars().all()] diff --git a/backend/services/export-service/src/adapters/persistence/models.py b/backend/services/export-service/src/adapters/persistence/models.py new file mode 100644 index 0000000..dd94804 --- /dev/null +++ b/backend/services/export-service/src/adapters/persistence/models.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, String, Text, func +from sqlalchemy.dialects.postgresql import ARRAY, UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class ExportTaskModel(Base): + __tablename__ = "export_tasks" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + format: Mapped[str] = mapped_column(String(20), nullable=False) + chart_ids: Mapped[list[uuid.UUID]] = mapped_column( + ARRAY(UUID(as_uuid=True)), nullable=False, default=list + ) + dataset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") + file_path: Mapped[str | None] = mapped_column(Text, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/services/export-service/src/adapters/presenters/__init__.py b/backend/services/export-service/src/adapters/presenters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/adapters/presenters/export_presenter.py b/backend/services/export-service/src/adapters/presenters/export_presenter.py new file mode 100644 index 0000000..c78c622 --- /dev/null +++ b/backend/services/export-service/src/adapters/presenters/export_presenter.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from src.application.dto.export_response import ExportResponse +from src.domain.entities.export_task import ExportTask + + +class ExportPresenter: + """Maps domain entities to API response DTOs.""" + + @staticmethod + def to_response(task: ExportTask) -> ExportResponse: + return ExportResponse( + id=task.id, + status=task.status, + format=task.format, + file_path=task.file_path, + error=task.error_message, + ) diff --git a/backend/services/export-service/src/application/__init__.py b/backend/services/export-service/src/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/application/dto/__init__.py b/backend/services/export-service/src/application/dto/__init__.py new file mode 100644 index 0000000..32f9987 --- /dev/null +++ b/backend/services/export-service/src/application/dto/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from src.application.dto.export_request import ExportRequest +from src.application.dto.export_response import ExportResponse + +__all__ = ["ExportRequest", "ExportResponse"] diff --git a/backend/services/export-service/src/application/dto/export_request.py b/backend/services/export-service/src/application/dto/export_request.py new file mode 100644 index 0000000..599f2dd --- /dev/null +++ b/backend/services/export-service/src/application/dto/export_request.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import uuid + +from pydantic import BaseModel + +from shared.types import ExportFormat + + +class ExportRequest(BaseModel): + format: ExportFormat + chart_ids: list[uuid.UUID] + dataset_id: uuid.UUID + file_name: str | None = None diff --git a/backend/services/export-service/src/application/dto/export_response.py b/backend/services/export-service/src/application/dto/export_response.py new file mode 100644 index 0000000..b553119 --- /dev/null +++ b/backend/services/export-service/src/application/dto/export_response.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import uuid + +from pydantic import BaseModel + +from shared.types import ExportFormat, ExportStatus + + +class ExportResponse(BaseModel): + id: uuid.UUID + status: ExportStatus + format: ExportFormat + file_path: str | None = None + error: str | None = None diff --git a/backend/services/export-service/src/application/ports/__init__.py b/backend/services/export-service/src/application/ports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/application/ports/input/__init__.py b/backend/services/export-service/src/application/ports/input/__init__.py new file mode 100644 index 0000000..d4a9947 --- /dev/null +++ b/backend/services/export-service/src/application/ports/input/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from src.application.ports.input.create_export import ICreateExportUseCase +from src.application.ports.input.get_export_status import IGetExportStatusUseCase + +__all__ = ["ICreateExportUseCase", "IGetExportStatusUseCase"] diff --git a/backend/services/export-service/src/application/ports/input/create_export.py b/backend/services/export-service/src/application/ports/input/create_export.py new file mode 100644 index 0000000..cdc20c8 --- /dev/null +++ b/backend/services/export-service/src/application/ports/input/create_export.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from src.application.dto.export_request import ExportRequest +from src.application.dto.export_response import ExportResponse + + +class ICreateExportUseCase(ABC): + @abstractmethod + async def execute(self, request: ExportRequest) -> ExportResponse: + ... diff --git a/backend/services/export-service/src/application/ports/input/get_export_status.py b/backend/services/export-service/src/application/ports/input/get_export_status.py new file mode 100644 index 0000000..86c94c6 --- /dev/null +++ b/backend/services/export-service/src/application/ports/input/get_export_status.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod + +from src.application.dto.export_response import ExportResponse + + +class IGetExportStatusUseCase(ABC): + @abstractmethod + async def execute(self, task_id: uuid.UUID) -> ExportResponse: + ... diff --git a/backend/services/export-service/src/application/ports/output/__init__.py b/backend/services/export-service/src/application/ports/output/__init__.py new file mode 100644 index 0000000..37c4da2 --- /dev/null +++ b/backend/services/export-service/src/application/ports/output/__init__.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from src.application.ports.output.chart_service_client import IChartServiceClient +from src.application.ports.output.data_service_client import IDataServiceClient +from src.application.ports.output.excel_generator import IExcelGenerator +from src.application.ports.output.html_generator import IHtmlGenerator +from src.application.ports.output.image_renderer import IImageRenderer +from src.application.ports.output.pdf_generator import IPdfGenerator +from src.application.ports.output.ppt_generator import IPptGenerator + +__all__ = [ + "IChartServiceClient", + "IDataServiceClient", + "IExcelGenerator", + "IHtmlGenerator", + "IImageRenderer", + "IPdfGenerator", + "IPptGenerator", +] diff --git a/backend/services/export-service/src/application/ports/output/chart_service_client.py b/backend/services/export-service/src/application/ports/output/chart_service_client.py new file mode 100644 index 0000000..c5e247d --- /dev/null +++ b/backend/services/export-service/src/application/ports/output/chart_service_client.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod +from typing import Any + + +class IChartServiceClient(ABC): + @abstractmethod + async def get_chart(self, chart_id: uuid.UUID) -> dict[str, Any]: + ... + + @abstractmethod + async def get_chart_option(self, chart_id: uuid.UUID) -> dict[str, Any]: + ... diff --git a/backend/services/export-service/src/application/ports/output/data_service_client.py b/backend/services/export-service/src/application/ports/output/data_service_client.py new file mode 100644 index 0000000..147928a --- /dev/null +++ b/backend/services/export-service/src/application/ports/output/data_service_client.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod +from typing import Any + + +class IDataServiceClient(ABC): + @abstractmethod + async def get_dataset(self, dataset_id: uuid.UUID) -> dict[str, Any]: + ... + + @abstractmethod + async def get_rows(self, dataset_id: uuid.UUID) -> list[dict[str, Any]]: + ... diff --git a/backend/services/export-service/src/application/ports/output/excel_generator.py b/backend/services/export-service/src/application/ports/output/excel_generator.py new file mode 100644 index 0000000..f6d7e78 --- /dev/null +++ b/backend/services/export-service/src/application/ports/output/excel_generator.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class IExcelGenerator(ABC): + @abstractmethod + async def generate( + self, data: list[dict], columns: list[str], sheet_name: str + ) -> bytes: + """Generate an XLSX workbook and return its raw bytes.""" + ... diff --git a/backend/services/export-service/src/application/ports/output/html_generator.py b/backend/services/export-service/src/application/ports/output/html_generator.py new file mode 100644 index 0000000..76892e0 --- /dev/null +++ b/backend/services/export-service/src/application/ports/output/html_generator.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class IHtmlGenerator(ABC): + @abstractmethod + async def generate(self, charts_option: list[dict], title: str) -> bytes: + """Generate a standalone HTML page with inline ECharts rendering.""" + ... diff --git a/backend/services/export-service/src/application/ports/output/image_renderer.py b/backend/services/export-service/src/application/ports/output/image_renderer.py new file mode 100644 index 0000000..79dcf9d --- /dev/null +++ b/backend/services/export-service/src/application/ports/output/image_renderer.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class IImageRenderer(ABC): + @abstractmethod + async def render(self, echarts_option: dict) -> bytes: + """Render an ECharts option dict to a PNG image and return raw bytes.""" + ... diff --git a/backend/services/export-service/src/application/ports/output/pdf_generator.py b/backend/services/export-service/src/application/ports/output/pdf_generator.py new file mode 100644 index 0000000..43bdd1a --- /dev/null +++ b/backend/services/export-service/src/application/ports/output/pdf_generator.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class IPdfGenerator(ABC): + @abstractmethod + async def generate(self, charts: list[dict], title: str) -> bytes: + """Generate a PDF containing chart images. + + Each dict in *charts* has keys ``title`` (str) and ``image`` (bytes). + """ + ... diff --git a/backend/services/export-service/src/application/ports/output/ppt_generator.py b/backend/services/export-service/src/application/ports/output/ppt_generator.py new file mode 100644 index 0000000..a4121f6 --- /dev/null +++ b/backend/services/export-service/src/application/ports/output/ppt_generator.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class IPptGenerator(ABC): + @abstractmethod + async def generate(self, charts: list[tuple[str, bytes]]) -> bytes: + """Generate a PPTX file. + + *charts* is a list of (title, png_bytes) pairs, one slide per entry. + """ + ... diff --git a/backend/services/export-service/src/application/usecases/__init__.py b/backend/services/export-service/src/application/usecases/__init__.py new file mode 100644 index 0000000..627986e --- /dev/null +++ b/backend/services/export-service/src/application/usecases/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from src.application.usecases.create_export_usecase import CreateExportUseCase +from src.application.usecases.get_export_status_usecase import GetExportStatusUseCase + +__all__ = ["CreateExportUseCase", "GetExportStatusUseCase"] diff --git a/backend/services/export-service/src/application/usecases/create_export_usecase.py b/backend/services/export-service/src/application/usecases/create_export_usecase.py new file mode 100644 index 0000000..b7179ca --- /dev/null +++ b/backend/services/export-service/src/application/usecases/create_export_usecase.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import os +import uuid +from typing import Any + +from shared.exceptions import ExportError +from shared.types import ExportFormat +from src.application.dto.export_request import ExportRequest +from src.application.dto.export_response import ExportResponse +from src.application.ports.input.create_export import ICreateExportUseCase +from src.application.ports.output.chart_service_client import IChartServiceClient +from src.application.ports.output.data_service_client import IDataServiceClient +from src.application.ports.output.excel_generator import IExcelGenerator +from src.application.ports.output.html_generator import IHtmlGenerator +from src.application.ports.output.image_renderer import IImageRenderer +from src.application.ports.output.pdf_generator import IPdfGenerator +from src.application.ports.output.ppt_generator import IPptGenerator +from src.domain.entities.export_task import ExportTask +from src.domain.repositories.export_task_repository import ExportTaskRepository +from src.domain.services.export_strategy import get_export_strategy + + +class CreateExportUseCase(ICreateExportUseCase): + def __init__( + self, + repo: ExportTaskRepository, + chart_client: IChartServiceClient, + data_client: IDataServiceClient, + image_renderer: IImageRenderer, + pdf_generator: IPdfGenerator, + ppt_generator: IPptGenerator, + excel_generator: IExcelGenerator, + html_generator: IHtmlGenerator, + export_dir: str, + ) -> None: + self._repo = repo + self._chart_client = chart_client + self._data_client = data_client + self._image_renderer = image_renderer + self._pdf_generator = pdf_generator + self._ppt_generator = ppt_generator + self._excel_generator = excel_generator + self._html_generator = html_generator + self._export_dir = export_dir + + async def execute(self, request: ExportRequest) -> ExportResponse: + # 1. Create pending task + task = ExportTask( + format=request.format, + chart_ids=request.chart_ids, + dataset_id=request.dataset_id, + ) + task = await self._repo.save(task) + + # 2. Process + try: + task.start_processing() + task = await self._repo.save(task) + + # Fetch chart configs and options + charts_meta: list[dict[str, Any]] = [] + charts_options: list[dict[str, Any]] = [] + for chart_id in request.chart_ids: + chart = await self._chart_client.get_chart(chart_id) + option = await self._chart_client.get_chart_option(chart_id) + charts_meta.append(chart) + charts_options.append(option) + + # Determine strategy + strategy = get_export_strategy(request.format) + + file_bytes: bytes + ext: str + + if strategy == "image_renderer": + # Render first chart (or combine) + images: list[bytes] = [] + for option in charts_options: + img = await self._image_renderer.render(option) + images.append(img) + file_bytes = images[0] if len(images) == 1 else images[0] + ext = request.format.value + + elif strategy == "pdf_generator": + chart_images: list[dict[str, Any]] = [] + for meta, option in zip(charts_meta, charts_options): + img = await self._image_renderer.render(option) + chart_images.append( + {"title": meta.get("name", "Chart"), "image": img} + ) + title = request.file_name or "Export" + file_bytes = await self._pdf_generator.generate(chart_images, title) + ext = "pdf" + + elif strategy == "ppt_generator": + slides: list[tuple[str, bytes]] = [] + for meta, option in zip(charts_meta, charts_options): + img = await self._image_renderer.render(option) + slides.append((meta.get("name", "Chart"), img)) + file_bytes = await self._ppt_generator.generate(slides) + ext = "pptx" + + elif strategy == "excel_generator": + dataset = await self._data_client.get_dataset(request.dataset_id) + rows = await self._data_client.get_rows(request.dataset_id) + columns = [f["name"] for f in dataset.get("fields", [])] + sheet = dataset.get("name", "Sheet1") + file_bytes = await self._excel_generator.generate( + rows, columns, sheet + ) + ext = "xlsx" + + elif strategy == "html_generator": + title = request.file_name or "Export" + file_bytes = await self._html_generator.generate( + charts_options, title + ) + ext = "html" + + else: + raise ExportError(f"Unknown strategy: {strategy}") + + # 3. Persist file + os.makedirs(self._export_dir, exist_ok=True) + file_name = request.file_name or str(task.id) + file_path = os.path.join(self._export_dir, f"{file_name}.{ext}") + with open(file_path, "wb") as f: + f.write(file_bytes) + + task.complete(file_path) + task = await self._repo.save(task) + + except Exception as exc: + task.fail(str(exc)) + await self._repo.save(task) + + return ExportResponse( + id=task.id, + status=task.status, + format=task.format, + file_path=task.file_path, + error=task.error_message, + ) diff --git a/backend/services/export-service/src/application/usecases/get_export_status_usecase.py b/backend/services/export-service/src/application/usecases/get_export_status_usecase.py new file mode 100644 index 0000000..bd87e28 --- /dev/null +++ b/backend/services/export-service/src/application/usecases/get_export_status_usecase.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import uuid + +from shared.exceptions import EntityNotFoundError +from src.application.dto.export_response import ExportResponse +from src.application.ports.input.get_export_status import IGetExportStatusUseCase +from src.domain.repositories.export_task_repository import ExportTaskRepository + + +class GetExportStatusUseCase(IGetExportStatusUseCase): + def __init__(self, repo: ExportTaskRepository) -> None: + self._repo = repo + + async def execute(self, task_id: uuid.UUID) -> ExportResponse: + task = await self._repo.find_by_id(task_id) + if task is None: + raise EntityNotFoundError("ExportTask", str(task_id)) + return ExportResponse( + id=task.id, + status=task.status, + format=task.format, + file_path=task.file_path, + error=task.error_message, + ) diff --git a/backend/services/export-service/src/domain/__init__.py b/backend/services/export-service/src/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/domain/entities/__init__.py b/backend/services/export-service/src/domain/entities/__init__.py new file mode 100644 index 0000000..83084f0 --- /dev/null +++ b/backend/services/export-service/src/domain/entities/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from src.domain.entities.export_task import ExportTask + +__all__ = ["ExportTask"] diff --git a/backend/services/export-service/src/domain/entities/export_task.py b/backend/services/export-service/src/domain/entities/export_task.py new file mode 100644 index 0000000..6e22302 --- /dev/null +++ b/backend/services/export-service/src/domain/entities/export_task.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from shared.base_entity import BaseEntity +from shared.types import ExportFormat, ExportStatus + + +@dataclass +class ExportTask(BaseEntity): + """Aggregate root representing an export job.""" + + format: ExportFormat = ExportFormat.PNG + chart_ids: list[uuid.UUID] = field(default_factory=list) + dataset_id: uuid.UUID = field(default_factory=uuid.uuid4) + status: ExportStatus = ExportStatus.PENDING + file_path: str | None = None + error_message: str | None = None + completed_at: datetime | None = None + + # ── state transitions ────────────────────────────────────────── + + def start_processing(self) -> None: + self.status = ExportStatus.PROCESSING + self.touch() + + def complete(self, file_path: str) -> None: + self.status = ExportStatus.DONE + self.file_path = file_path + self.completed_at = datetime.now(timezone.utc) + self.touch() + + def fail(self, error: str) -> None: + self.status = ExportStatus.FAILED + self.error_message = error + self.completed_at = datetime.now(timezone.utc) + self.touch() diff --git a/backend/services/export-service/src/domain/repositories/__init__.py b/backend/services/export-service/src/domain/repositories/__init__.py new file mode 100644 index 0000000..bcfffa2 --- /dev/null +++ b/backend/services/export-service/src/domain/repositories/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from src.domain.repositories.export_task_repository import ExportTaskRepository + +__all__ = ["ExportTaskRepository"] diff --git a/backend/services/export-service/src/domain/repositories/export_task_repository.py b/backend/services/export-service/src/domain/repositories/export_task_repository.py new file mode 100644 index 0000000..68e053f --- /dev/null +++ b/backend/services/export-service/src/domain/repositories/export_task_repository.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from abc import abstractmethod + +from shared.base_repository import BaseRepository +from shared.types import ExportStatus +from src.domain.entities.export_task import ExportTask + + +class ExportTaskRepository(BaseRepository[ExportTask]): + """Persistence port for ExportTask aggregate.""" + + @abstractmethod + async def find_by_status(self, status: ExportStatus) -> list[ExportTask]: + ... diff --git a/backend/services/export-service/src/domain/services/__init__.py b/backend/services/export-service/src/domain/services/__init__.py new file mode 100644 index 0000000..94aa87c --- /dev/null +++ b/backend/services/export-service/src/domain/services/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from src.domain.services.export_strategy import get_export_strategy + +__all__ = ["get_export_strategy"] diff --git a/backend/services/export-service/src/domain/services/export_strategy.py b/backend/services/export-service/src/domain/services/export_strategy.py new file mode 100644 index 0000000..22b9188 --- /dev/null +++ b/backend/services/export-service/src/domain/services/export_strategy.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from shared.types import ExportFormat + + +def get_export_strategy(fmt: ExportFormat) -> str: + """Return the strategy name that handles the given export format.""" + mapping: dict[ExportFormat, str] = { + ExportFormat.PNG: "image_renderer", + ExportFormat.JPG: "image_renderer", + ExportFormat.SVG: "image_renderer", + ExportFormat.PDF: "pdf_generator", + ExportFormat.EXCEL: "excel_generator", + ExportFormat.PPT: "ppt_generator", + ExportFormat.HTML: "html_generator", + ExportFormat.TEMPLATE: "html_generator", + } + return mapping[fmt] diff --git a/backend/services/export-service/src/domain/value_objects/__init__.py b/backend/services/export-service/src/domain/value_objects/__init__.py new file mode 100644 index 0000000..f101f45 --- /dev/null +++ b/backend/services/export-service/src/domain/value_objects/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from src.domain.value_objects.export_format import ExportFormat +from src.domain.value_objects.export_status import ExportStatus + +__all__ = ["ExportFormat", "ExportStatus"] diff --git a/backend/services/export-service/src/domain/value_objects/export_format.py b/backend/services/export-service/src/domain/value_objects/export_format.py new file mode 100644 index 0000000..33bce6a --- /dev/null +++ b/backend/services/export-service/src/domain/value_objects/export_format.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from shared.types import ExportFormat + +__all__ = ["ExportFormat"] diff --git a/backend/services/export-service/src/domain/value_objects/export_status.py b/backend/services/export-service/src/domain/value_objects/export_status.py new file mode 100644 index 0000000..0706200 --- /dev/null +++ b/backend/services/export-service/src/domain/value_objects/export_status.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from shared.types import ExportStatus + +__all__ = ["ExportStatus"] diff --git a/backend/services/export-service/src/infrastructure/__init__.py b/backend/services/export-service/src/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/infrastructure/api/__init__.py b/backend/services/export-service/src/infrastructure/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/export-service/src/infrastructure/api/app.py b/backend/services/export-service/src/infrastructure/api/app.py new file mode 100644 index 0000000..00e7641 --- /dev/null +++ b/backend/services/export-service/src/infrastructure/api/app.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.adapters.persistence.database import init_engine +from src.infrastructure.api.routes import router +from src.infrastructure.config import settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + engine = init_engine(settings.DATABASE_URL) + yield + await engine.dispose() + + +def create_app() -> FastAPI: + app = FastAPI( + title="Export Service", + version="0.1.0", + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(router) + + @app.get("/health") + async def health(): + return {"status": "ok", "service": "export-service"} + + return app diff --git a/backend/services/export-service/src/infrastructure/api/dependencies.py b/backend/services/export-service/src/infrastructure/api/dependencies.py new file mode 100644 index 0000000..74564f0 --- /dev/null +++ b/backend/services/export-service/src/infrastructure/api/dependencies.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.http_client import ServiceHttpClient +from src.adapters.clients.chart_service_client_impl import ChartServiceClientImpl +from src.adapters.clients.data_service_client_impl import DataServiceClientImpl +from src.adapters.generators.jinja2_html_generator import Jinja2HtmlGenerator +from src.adapters.generators.openpyxl_excel_generator import OpenpyxlExcelGenerator +from src.adapters.generators.playwright_image_renderer import PlaceholderImageRenderer +from src.adapters.generators.pptx_generator import PptxGenerator +from src.adapters.generators.weasyprint_pdf_generator import ReportLabPdfGenerator +from src.adapters.persistence.database import get_session +from src.adapters.persistence.export_task_repository_impl import ( + ExportTaskRepositoryImpl, +) +from src.application.usecases.create_export_usecase import CreateExportUseCase +from src.application.usecases.get_export_status_usecase import GetExportStatusUseCase +from src.infrastructure.config import settings + + +# ── db session ───────────────────────────────────────────────────── +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +# ── repository ───────────────────────────────────────────────────── +def get_export_task_repo(session: SessionDep) -> ExportTaskRepositoryImpl: + return ExportTaskRepositoryImpl(session) + + +RepoDep = Annotated[ExportTaskRepositoryImpl, Depends(get_export_task_repo)] + + +# ── service clients ──────────────────────────────────────────────── +def get_data_client() -> DataServiceClientImpl: + http_client = ServiceHttpClient(settings.DATA_SERVICE_URL) + return DataServiceClientImpl(http_client) + + +def get_chart_client() -> ChartServiceClientImpl: + http_client = ServiceHttpClient(settings.CHART_SERVICE_URL) + return ChartServiceClientImpl(http_client) + + +# ── generators ───────────────────────────────────────────────────── +def get_image_renderer() -> PlaceholderImageRenderer: + return PlaceholderImageRenderer() + + +def get_pdf_generator() -> ReportLabPdfGenerator: + return ReportLabPdfGenerator() + + +def get_ppt_generator() -> PptxGenerator: + return PptxGenerator() + + +def get_excel_generator() -> OpenpyxlExcelGenerator: + return OpenpyxlExcelGenerator() + + +def get_html_generator() -> Jinja2HtmlGenerator: + return Jinja2HtmlGenerator() + + +# ── use cases ────────────────────────────────────────────────────── +def get_create_export_usecase( + repo: RepoDep, + chart_client: Annotated[ChartServiceClientImpl, Depends(get_chart_client)], + data_client: Annotated[DataServiceClientImpl, Depends(get_data_client)], + image_renderer: Annotated[PlaceholderImageRenderer, Depends(get_image_renderer)], + pdf_generator: Annotated[ReportLabPdfGenerator, Depends(get_pdf_generator)], + ppt_generator: Annotated[PptxGenerator, Depends(get_ppt_generator)], + excel_generator: Annotated[OpenpyxlExcelGenerator, Depends(get_excel_generator)], + html_generator: Annotated[Jinja2HtmlGenerator, Depends(get_html_generator)], +) -> CreateExportUseCase: + return CreateExportUseCase( + repo=repo, + chart_client=chart_client, + data_client=data_client, + image_renderer=image_renderer, + pdf_generator=pdf_generator, + ppt_generator=ppt_generator, + excel_generator=excel_generator, + html_generator=html_generator, + export_dir=settings.EXPORT_DIR, + ) + + +def get_get_export_status_usecase(repo: RepoDep) -> GetExportStatusUseCase: + return GetExportStatusUseCase(repo) + + +CreateExportDep = Annotated[CreateExportUseCase, Depends(get_create_export_usecase)] +GetExportStatusDep = Annotated[ + GetExportStatusUseCase, Depends(get_get_export_status_usecase) +] diff --git a/backend/services/export-service/src/infrastructure/api/routes.py b/backend/services/export-service/src/infrastructure/api/routes.py new file mode 100644 index 0000000..2d906d1 --- /dev/null +++ b/backend/services/export-service/src/infrastructure/api/routes.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import os +import uuid + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +from shared.exceptions import EntityNotFoundError +from src.application.dto.export_request import ExportRequest +from src.application.dto.export_response import ExportResponse +from src.infrastructure.api.dependencies import CreateExportDep, GetExportStatusDep + +router = APIRouter(prefix="/api/v1/exports", tags=["exports"]) + + +@router.post("", response_model=ExportResponse, status_code=201) +async def create_export( + request: ExportRequest, + usecase: CreateExportDep, +) -> ExportResponse: + return await usecase.execute(request) + + +@router.get("/{task_id}", response_model=ExportResponse) +async def get_export_status( + task_id: uuid.UUID, + usecase: GetExportStatusDep, +) -> ExportResponse: + try: + return await usecase.execute(task_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail="Export task not found") + + +@router.get("/{task_id}/download") +async def download_export( + task_id: uuid.UUID, + usecase: GetExportStatusDep, +) -> FileResponse: + try: + result = await usecase.execute(task_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail="Export task not found") + + if result.file_path is None or not os.path.isfile(result.file_path): + raise HTTPException(status_code=404, detail="Export file not available") + + filename = os.path.basename(result.file_path) + return FileResponse( + path=result.file_path, + filename=filename, + media_type="application/octet-stream", + ) diff --git a/backend/services/export-service/src/infrastructure/config.py b/backend/services/export-service/src/infrastructure/config.py new file mode 100644 index 0000000..eb5ce41 --- /dev/null +++ b/backend/services/export-service/src/infrastructure/config.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/export_db" + DATA_SERVICE_URL: str = "http://localhost:8000" + CHART_SERVICE_URL: str = "http://localhost:8000" + EXPORT_DIR: str = "./exports" + APP_HOST: str = "0.0.0.0" + APP_PORT: int = 8000 + CORS_ORIGINS: list[str] = ["*"] + + model_config = {"env_prefix": "", "case_sensitive": True} + + +settings = Settings() diff --git a/backend/services/export-service/src/infrastructure/main.py b/backend/services/export-service/src/infrastructure/main.py new file mode 100644 index 0000000..fef316b --- /dev/null +++ b/backend/services/export-service/src/infrastructure/main.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import uvicorn + +from src.infrastructure.api.app import create_app +from src.infrastructure.config import settings + +app = create_app() + +if __name__ == "__main__": + uvicorn.run( + "src.infrastructure.main:app", + host=settings.APP_HOST, + port=settings.APP_PORT, + reload=True, + ) diff --git a/backend/services/template-service/Dockerfile b/backend/services/template-service/Dockerfile new file mode 100644 index 0000000..2ee3d62 --- /dev/null +++ b/backend/services/template-service/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev && \ + rm -rf /var/lib/apt/lists/* + +# Copy shared library +COPY shared/ /app/shared/ + +# Copy service code +COPY services/template-service/pyproject.toml /app/ +COPY services/template-service/src/ /app/src/ +COPY services/template-service/alembic/ /app/alembic/ +COPY services/template-service/alembic.ini /app/alembic.ini + +# Install dependencies +RUN pip install --no-cache-dir poetry && \ + poetry config virtualenvs.create false && \ + poetry install --no-interaction --no-ansi --no-root + +# Set PYTHONPATH to include shared +ENV PYTHONPATH=/app:/app/src + +EXPOSE 8000 + +CMD ["python", "-m", "src.infrastructure.main"] diff --git a/backend/services/template-service/alembic.ini b/backend/services/template-service/alembic.ini new file mode 100644 index 0000000..27e668c --- /dev/null +++ b/backend/services/template-service/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/services/template-service/alembic/env.py b/backend/services/template-service/alembic/env.py new file mode 100644 index 0000000..c773a1f --- /dev/null +++ b/backend/services/template-service/alembic/env.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from src.adapters.persistence.models import Base +from src.infrastructure.config import get_settings + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/services/template-service/alembic/versions/__init__.py b/backend/services/template-service/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/template-service/pyproject.toml b/backend/services/template-service/pyproject.toml new file mode 100644 index 0000000..7a8472c --- /dev/null +++ b/backend/services/template-service/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "template-service" +version = "0.1.0" +description = "Template management microservice" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "sqlalchemy[asyncio]>=2.0.0", + "asyncpg>=0.30.0", + "alembic>=1.14.0", + "pydantic>=2.10.0", + "pydantic-settings>=2.7.0", +] + +[build-system] +requires = ["setuptools>=75.0"] +build-backend = "setuptools.build_meta" diff --git a/backend/services/template-service/src/__init__.py b/backend/services/template-service/src/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/template-service/src/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/template-service/src/adapters/__init__.py b/backend/services/template-service/src/adapters/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/template-service/src/adapters/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/template-service/src/adapters/persistence/__init__.py b/backend/services/template-service/src/adapters/persistence/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/template-service/src/adapters/persistence/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/template-service/src/adapters/persistence/database.py b/backend/services/template-service/src/adapters/persistence/database.py new file mode 100644 index 0000000..5e55eea --- /dev/null +++ b/backend/services/template-service/src/adapters/persistence/database.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from src.infrastructure.config import settings + +engine = create_async_engine( + settings.DATABASE_URL, + echo=False, + pool_size=10, + max_overflow=20, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_session() -> AsyncSession: # type: ignore[misc] + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/backend/services/template-service/src/adapters/persistence/models.py b/backend/services/template-service/src/adapters/persistence/models.py new file mode 100644 index 0000000..c3f543f --- /dev/null +++ b/backend/services/template-service/src/adapters/persistence/models.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class TemplateModel(Base): + __tablename__ = "templates" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + description: Mapped[str] = mapped_column(Text, nullable=False, default="") + template_type: Mapped[str] = mapped_column( + String(20), nullable=False, default="custom", index=True + ) + chart_configs: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + layout: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + theme: Mapped[str] = mapped_column(String(50), nullable=False, default="default") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/services/template-service/src/adapters/persistence/template_repository_impl.py b/backend/services/template-service/src/adapters/persistence/template_repository_impl.py new file mode 100644 index 0000000..2f01923 --- /dev/null +++ b/backend/services/template-service/src/adapters/persistence/template_repository_impl.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import uuid +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.types import TemplateType +from src.adapters.persistence.models import TemplateModel +from src.domain.entities.template import Template +from src.domain.repositories.template_repository import TemplateRepository + + +class TemplateRepositoryImpl(TemplateRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def find_by_id(self, entity_id: uuid.UUID) -> Optional[Template]: + stmt = select(TemplateModel).where(TemplateModel.id == entity_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + if model is None: + return None + return self._to_entity(model) + + async def find_all(self) -> list[Template]: + stmt = select(TemplateModel).order_by(TemplateModel.created_at.desc()) + result = await self._session.execute(stmt) + return [self._to_entity(m) for m in result.scalars().all()] + + async def save(self, entity: Template) -> Template: + stmt = select(TemplateModel).where(TemplateModel.id == entity.id) + result = await self._session.execute(stmt) + existing = result.scalar_one_or_none() + + if existing is not None: + existing.name = entity.name + existing.description = entity.description + existing.template_type = entity.template_type.value + existing.chart_configs = entity.chart_configs + existing.layout = entity.layout + existing.theme = entity.theme + existing.updated_at = entity.updated_at + model = existing + else: + model = TemplateModel( + id=entity.id, + name=entity.name, + description=entity.description, + template_type=entity.template_type.value, + chart_configs=entity.chart_configs, + layout=entity.layout, + theme=entity.theme, + created_at=entity.created_at, + updated_at=entity.updated_at, + ) + self._session.add(model) + + await self._session.flush() + return self._to_entity(model) + + async def delete(self, entity_id: uuid.UUID) -> None: + stmt = select(TemplateModel).where(TemplateModel.id == entity_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + if model is not None: + await self._session.delete(model) + await self._session.flush() + + async def find_by_type(self, template_type: TemplateType) -> list[Template]: + stmt = ( + select(TemplateModel) + .where(TemplateModel.template_type == template_type.value) + .order_by(TemplateModel.created_at.desc()) + ) + result = await self._session.execute(stmt) + return [self._to_entity(m) for m in result.scalars().all()] + + @staticmethod + def _to_entity(model: TemplateModel) -> Template: + return Template( + id=model.id, + created_at=model.created_at, + updated_at=model.updated_at, + name=model.name, + description=model.description, + template_type=TemplateType(model.template_type), + chart_configs=model.chart_configs or [], + layout=model.layout or [], + theme=model.theme, + ) diff --git a/backend/services/template-service/src/adapters/presenters/__init__.py b/backend/services/template-service/src/adapters/presenters/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/template-service/src/adapters/presenters/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/template-service/src/adapters/presenters/template_presenter.py b/backend/services/template-service/src/adapters/presenters/template_presenter.py new file mode 100644 index 0000000..3375ca0 --- /dev/null +++ b/backend/services/template-service/src/adapters/presenters/template_presenter.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Any + +from src.application.dto.template_response import TemplateResponse + + +class TemplatePresenter: + """Transforms TemplateResponse DTOs into API-friendly dictionaries.""" + + @staticmethod + def to_dict(response: TemplateResponse) -> dict[str, Any]: + return { + "id": str(response.id), + "name": response.name, + "description": response.description, + "template_type": response.template_type.value, + "chart_configs": response.chart_configs, + "layout": response.layout, + "theme": response.theme, + "created_at": response.created_at.isoformat(), + "updated_at": response.updated_at.isoformat(), + } + + @staticmethod + def to_list(responses: list[TemplateResponse]) -> list[dict[str, Any]]: + return [TemplatePresenter.to_dict(r) for r in responses] diff --git a/backend/services/template-service/src/application/__init__.py b/backend/services/template-service/src/application/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/template-service/src/application/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/template-service/src/application/dto/__init__.py b/backend/services/template-service/src/application/dto/__init__.py new file mode 100644 index 0000000..41a2aaf --- /dev/null +++ b/backend/services/template-service/src/application/dto/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .template_response import ImportTemplateRequest, SaveTemplateRequest, TemplateResponse + +__all__ = ["TemplateResponse", "SaveTemplateRequest", "ImportTemplateRequest"] diff --git a/backend/services/template-service/src/application/dto/template_response.py b/backend/services/template-service/src/application/dto/template_response.py new file mode 100644 index 0000000..39c029e --- /dev/null +++ b/backend/services/template-service/src/application/dto/template_response.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + +from shared.types import TemplateType + + +class TemplateResponse(BaseModel): + id: uuid.UUID + name: str + description: str + template_type: TemplateType + chart_configs: list[dict[str, Any]] + layout: list[dict[str, Any]] + theme: str + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class SaveTemplateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + description: str = Field(default="", max_length=2000) + template_type: TemplateType = TemplateType.CUSTOM + chart_configs: list[dict[str, Any]] = Field(default_factory=list) + layout: list[dict[str, Any]] = Field(default_factory=list) + theme: str = Field(default="default", max_length=50) + + +class ImportTemplateRequest(BaseModel): + data: dict[str, Any] diff --git a/backend/services/template-service/src/application/ports/__init__.py b/backend/services/template-service/src/application/ports/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/template-service/src/application/ports/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/template-service/src/application/ports/input/__init__.py b/backend/services/template-service/src/application/ports/input/__init__.py new file mode 100644 index 0000000..798a855 --- /dev/null +++ b/backend/services/template-service/src/application/ports/input/__init__.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from .save_template import ISaveTemplateUseCase +from .load_template import ILoadTemplateUseCase +from .list_templates import IListTemplatesUseCase +from .import_export_template import IImportTemplateUseCase, IExportTemplateUseCase + +__all__ = [ + "ISaveTemplateUseCase", + "ILoadTemplateUseCase", + "IListTemplatesUseCase", + "IImportTemplateUseCase", + "IExportTemplateUseCase", +] diff --git a/backend/services/template-service/src/application/ports/input/import_export_template.py b/backend/services/template-service/src/application/ports/input/import_export_template.py new file mode 100644 index 0000000..ed4fe59 --- /dev/null +++ b/backend/services/template-service/src/application/ports/input/import_export_template.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod +from typing import Any + +from src.application.dto.template_response import TemplateResponse + + +class IImportTemplateUseCase(ABC): + @abstractmethod + async def execute(self, data: dict[str, Any]) -> TemplateResponse: ... + + +class IExportTemplateUseCase(ABC): + @abstractmethod + async def execute(self, template_id: uuid.UUID) -> dict[str, Any]: ... diff --git a/backend/services/template-service/src/application/ports/input/list_templates.py b/backend/services/template-service/src/application/ports/input/list_templates.py new file mode 100644 index 0000000..c497617 --- /dev/null +++ b/backend/services/template-service/src/application/ports/input/list_templates.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Optional + +from shared.types import TemplateType +from src.application.dto.template_response import TemplateResponse + + +class IListTemplatesUseCase(ABC): + @abstractmethod + async def execute( + self, template_type: Optional[TemplateType] = None + ) -> list[TemplateResponse]: ... diff --git a/backend/services/template-service/src/application/ports/input/load_template.py b/backend/services/template-service/src/application/ports/input/load_template.py new file mode 100644 index 0000000..a62185f --- /dev/null +++ b/backend/services/template-service/src/application/ports/input/load_template.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod + +from src.application.dto.template_response import TemplateResponse + + +class ILoadTemplateUseCase(ABC): + @abstractmethod + async def execute(self, template_id: uuid.UUID) -> TemplateResponse: ... diff --git a/backend/services/template-service/src/application/ports/input/save_template.py b/backend/services/template-service/src/application/ports/input/save_template.py new file mode 100644 index 0000000..f7dbaa6 --- /dev/null +++ b/backend/services/template-service/src/application/ports/input/save_template.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from src.application.dto.template_response import SaveTemplateRequest, TemplateResponse + + +class ISaveTemplateUseCase(ABC): + @abstractmethod + async def execute(self, request: SaveTemplateRequest) -> TemplateResponse: ... diff --git a/backend/services/template-service/src/application/ports/output/__init__.py b/backend/services/template-service/src/application/ports/output/__init__.py new file mode 100644 index 0000000..e141932 --- /dev/null +++ b/backend/services/template-service/src/application/ports/output/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .template_storage import ITemplateStorage + +__all__ = ["ITemplateStorage"] diff --git a/backend/services/template-service/src/application/ports/output/template_storage.py b/backend/services/template-service/src/application/ports/output/template_storage.py new file mode 100644 index 0000000..1409f02 --- /dev/null +++ b/backend/services/template-service/src/application/ports/output/template_storage.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod +from typing import Optional + +from shared.types import TemplateType +from src.domain.entities.template import Template + + +class ITemplateStorage(ABC): + @abstractmethod + async def find_by_id(self, entity_id: uuid.UUID) -> Optional[Template]: ... + + @abstractmethod + async def find_all(self) -> list[Template]: ... + + @abstractmethod + async def save(self, entity: Template) -> Template: ... + + @abstractmethod + async def delete(self, entity_id: uuid.UUID) -> None: ... + + @abstractmethod + async def find_by_type(self, template_type: TemplateType) -> list[Template]: ... diff --git a/backend/services/template-service/src/application/usecases/__init__.py b/backend/services/template-service/src/application/usecases/__init__.py new file mode 100644 index 0000000..4b73491 --- /dev/null +++ b/backend/services/template-service/src/application/usecases/__init__.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from .delete_template_usecase import DeleteTemplateUseCase +from .import_export_usecase import ExportTemplateUseCase, ImportTemplateUseCase +from .list_templates_usecase import ListTemplatesUseCase +from .load_template_usecase import LoadTemplateUseCase +from .save_template_usecase import SaveTemplateUseCase + +__all__ = [ + "SaveTemplateUseCase", + "LoadTemplateUseCase", + "ListTemplatesUseCase", + "DeleteTemplateUseCase", + "ImportTemplateUseCase", + "ExportTemplateUseCase", +] diff --git a/backend/services/template-service/src/application/usecases/delete_template_usecase.py b/backend/services/template-service/src/application/usecases/delete_template_usecase.py new file mode 100644 index 0000000..e339a4f --- /dev/null +++ b/backend/services/template-service/src/application/usecases/delete_template_usecase.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import uuid + +from shared.exceptions import EntityNotFoundError +from src.domain.repositories.template_repository import TemplateRepository + + +class DeleteTemplateUseCase: + def __init__(self, repository: TemplateRepository) -> None: + self._repository = repository + + async def execute(self, template_id: uuid.UUID) -> None: + template = await self._repository.find_by_id(template_id) + if template is None: + raise EntityNotFoundError("Template", str(template_id)) + + await self._repository.delete(template_id) diff --git a/backend/services/template-service/src/application/usecases/import_export_usecase.py b/backend/services/template-service/src/application/usecases/import_export_usecase.py new file mode 100644 index 0000000..7622765 --- /dev/null +++ b/backend/services/template-service/src/application/usecases/import_export_usecase.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from shared.exceptions import EntityNotFoundError, ValidationError +from src.application.dto.template_response import TemplateResponse +from src.application.ports.input.import_export_template import ( + IExportTemplateUseCase, + IImportTemplateUseCase, +) +from src.domain.entities.template import Template +from src.domain.repositories.template_repository import TemplateRepository +from src.domain.services.template_validation import validate_template + + +class ImportTemplateUseCase(IImportTemplateUseCase): + def __init__(self, repository: TemplateRepository) -> None: + self._repository = repository + + async def execute(self, data: dict[str, Any]) -> TemplateResponse: + template = Template.from_import_json(data) + + errors = validate_template(template) + if errors: + raise ValidationError("; ".join(errors)) + + saved = await self._repository.save(template) + + return TemplateResponse( + id=saved.id, + name=saved.name, + description=saved.description, + template_type=saved.template_type, + chart_configs=saved.chart_configs, + layout=saved.layout, + theme=saved.theme, + created_at=saved.created_at, + updated_at=saved.updated_at, + ) + + +class ExportTemplateUseCase(IExportTemplateUseCase): + def __init__(self, repository: TemplateRepository) -> None: + self._repository = repository + + async def execute(self, template_id: uuid.UUID) -> dict[str, Any]: + template = await self._repository.find_by_id(template_id) + if template is None: + raise EntityNotFoundError("Template", str(template_id)) + + return template.to_export_json() diff --git a/backend/services/template-service/src/application/usecases/list_templates_usecase.py b/backend/services/template-service/src/application/usecases/list_templates_usecase.py new file mode 100644 index 0000000..348573c --- /dev/null +++ b/backend/services/template-service/src/application/usecases/list_templates_usecase.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Optional + +from shared.types import TemplateType +from src.application.dto.template_response import TemplateResponse +from src.application.ports.input.list_templates import IListTemplatesUseCase +from src.domain.repositories.template_repository import TemplateRepository + + +class ListTemplatesUseCase(IListTemplatesUseCase): + def __init__(self, repository: TemplateRepository) -> None: + self._repository = repository + + async def execute( + self, template_type: Optional[TemplateType] = None + ) -> list[TemplateResponse]: + if template_type is not None: + templates = await self._repository.find_by_type(template_type) + else: + templates = await self._repository.find_all() + + return [ + TemplateResponse( + id=t.id, + name=t.name, + description=t.description, + template_type=t.template_type, + chart_configs=t.chart_configs, + layout=t.layout, + theme=t.theme, + created_at=t.created_at, + updated_at=t.updated_at, + ) + for t in templates + ] diff --git a/backend/services/template-service/src/application/usecases/load_template_usecase.py b/backend/services/template-service/src/application/usecases/load_template_usecase.py new file mode 100644 index 0000000..0566de7 --- /dev/null +++ b/backend/services/template-service/src/application/usecases/load_template_usecase.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import uuid + +from shared.exceptions import EntityNotFoundError +from src.application.dto.template_response import TemplateResponse +from src.application.ports.input.load_template import ILoadTemplateUseCase +from src.domain.repositories.template_repository import TemplateRepository + + +class LoadTemplateUseCase(ILoadTemplateUseCase): + def __init__(self, repository: TemplateRepository) -> None: + self._repository = repository + + async def execute(self, template_id: uuid.UUID) -> TemplateResponse: + template = await self._repository.find_by_id(template_id) + if template is None: + raise EntityNotFoundError("Template", str(template_id)) + + return TemplateResponse( + id=template.id, + name=template.name, + description=template.description, + template_type=template.template_type, + chart_configs=template.chart_configs, + layout=template.layout, + theme=template.theme, + created_at=template.created_at, + updated_at=template.updated_at, + ) diff --git a/backend/services/template-service/src/application/usecases/save_template_usecase.py b/backend/services/template-service/src/application/usecases/save_template_usecase.py new file mode 100644 index 0000000..625656a --- /dev/null +++ b/backend/services/template-service/src/application/usecases/save_template_usecase.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from shared.exceptions import ValidationError +from src.application.dto.template_response import SaveTemplateRequest, TemplateResponse +from src.application.ports.input.save_template import ISaveTemplateUseCase +from src.domain.entities.template import Template +from src.domain.repositories.template_repository import TemplateRepository +from src.domain.services.template_validation import validate_template + + +class SaveTemplateUseCase(ISaveTemplateUseCase): + def __init__(self, repository: TemplateRepository) -> None: + self._repository = repository + + async def execute(self, request: SaveTemplateRequest) -> TemplateResponse: + template = Template( + id=uuid.uuid4(), + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + name=request.name, + description=request.description, + template_type=request.template_type, + chart_configs=request.chart_configs, + layout=request.layout, + theme=request.theme, + ) + + errors = validate_template(template) + if errors: + raise ValidationError("; ".join(errors)) + + saved = await self._repository.save(template) + + return TemplateResponse( + id=saved.id, + name=saved.name, + description=saved.description, + template_type=saved.template_type, + chart_configs=saved.chart_configs, + layout=saved.layout, + theme=saved.theme, + created_at=saved.created_at, + updated_at=saved.updated_at, + ) diff --git a/backend/services/template-service/src/domain/__init__.py b/backend/services/template-service/src/domain/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/template-service/src/domain/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/template-service/src/domain/entities/__init__.py b/backend/services/template-service/src/domain/entities/__init__.py new file mode 100644 index 0000000..6c34339 --- /dev/null +++ b/backend/services/template-service/src/domain/entities/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .template import Template + +__all__ = ["Template"] diff --git a/backend/services/template-service/src/domain/entities/template.py b/backend/services/template-service/src/domain/entities/template.py new file mode 100644 index 0000000..424cb58 --- /dev/null +++ b/backend/services/template-service/src/domain/entities/template.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from shared.base_entity import BaseEntity +from shared.types import TemplateType + + +@dataclass +class Template(BaseEntity): + name: str = "" + description: str = "" + template_type: TemplateType = TemplateType.CUSTOM + chart_configs: list[dict[str, Any]] = field(default_factory=list) + layout: list[dict[str, Any]] = field(default_factory=list) + theme: str = "default" + + _VALID_THEMES: list[str] = field( + default_factory=lambda: ["default", "dark", "light", "blue", "green"], + init=False, + repr=False, + compare=False, + ) + + def validate(self) -> list[str]: + errors: list[str] = [] + if not self.name or not self.name.strip(): + errors.append("Template name must not be empty") + if not isinstance(self.chart_configs, list): + errors.append("chart_configs must be a list") + if not isinstance(self.layout, list): + errors.append("layout must be a list") + if self.theme not in self._VALID_THEMES: + errors.append( + f"Invalid theme '{self.theme}'. Must be one of: {', '.join(self._VALID_THEMES)}" + ) + return errors + + def to_export_json(self) -> dict[str, Any]: + return { + "name": self.name, + "description": self.description, + "template_type": self.template_type.value, + "chart_configs": self.chart_configs, + "layout": self.layout, + "theme": self.theme, + } + + @classmethod + def from_import_json(cls, data: dict[str, Any]) -> Template: + return cls( + id=uuid.uuid4(), + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + name=data.get("name", ""), + description=data.get("description", ""), + template_type=TemplateType(data.get("template_type", "custom")), + chart_configs=data.get("chart_configs", []), + layout=data.get("layout", []), + theme=data.get("theme", "default"), + ) diff --git a/backend/services/template-service/src/domain/repositories/__init__.py b/backend/services/template-service/src/domain/repositories/__init__.py new file mode 100644 index 0000000..5d8fc90 --- /dev/null +++ b/backend/services/template-service/src/domain/repositories/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .template_repository import TemplateRepository + +__all__ = ["TemplateRepository"] diff --git a/backend/services/template-service/src/domain/repositories/template_repository.py b/backend/services/template-service/src/domain/repositories/template_repository.py new file mode 100644 index 0000000..2cd25b2 --- /dev/null +++ b/backend/services/template-service/src/domain/repositories/template_repository.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from abc import abstractmethod + +from shared.base_repository import BaseRepository +from shared.types import TemplateType +from src.domain.entities.template import Template + + +class TemplateRepository(BaseRepository[Template]): + @abstractmethod + async def find_by_type(self, template_type: TemplateType) -> list[Template]: ... diff --git a/backend/services/template-service/src/domain/services/__init__.py b/backend/services/template-service/src/domain/services/__init__.py new file mode 100644 index 0000000..5b674fa --- /dev/null +++ b/backend/services/template-service/src/domain/services/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .template_validation import validate_template + +__all__ = ["validate_template"] diff --git a/backend/services/template-service/src/domain/services/template_validation.py b/backend/services/template-service/src/domain/services/template_validation.py new file mode 100644 index 0000000..1139e22 --- /dev/null +++ b/backend/services/template-service/src/domain/services/template_validation.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from src.domain.entities.template import Template + + +def validate_template(template: Template) -> list[str]: + """Validate a template and return a list of error messages (empty if valid).""" + errors: list[str] = [] + + if not template.name or not template.name.strip(): + errors.append("Template name must not be empty") + + if not isinstance(template.chart_configs, list): + errors.append("chart_configs must be a list") + + if not isinstance(template.layout, list): + errors.append("layout must be a list") + + valid_themes = ["default", "dark", "light", "blue", "green"] + if template.theme not in valid_themes: + errors.append( + f"Invalid theme '{template.theme}'. Must be one of: {', '.join(valid_themes)}" + ) + + return errors diff --git a/backend/services/template-service/src/domain/value_objects/__init__.py b/backend/services/template-service/src/domain/value_objects/__init__.py new file mode 100644 index 0000000..44fdec7 --- /dev/null +++ b/backend/services/template-service/src/domain/value_objects/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .template_type import TemplateType + +__all__ = ["TemplateType"] diff --git a/backend/services/template-service/src/domain/value_objects/template_type.py b/backend/services/template-service/src/domain/value_objects/template_type.py new file mode 100644 index 0000000..667dfc4 --- /dev/null +++ b/backend/services/template-service/src/domain/value_objects/template_type.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from shared.types import TemplateType + +__all__ = ["TemplateType"] diff --git a/backend/services/template-service/src/infrastructure/__init__.py b/backend/services/template-service/src/infrastructure/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/template-service/src/infrastructure/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/template-service/src/infrastructure/api/__init__.py b/backend/services/template-service/src/infrastructure/api/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/services/template-service/src/infrastructure/api/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/services/template-service/src/infrastructure/api/app.py b/backend/services/template-service/src/infrastructure/api/app.py new file mode 100644 index 0000000..f9ae247 --- /dev/null +++ b/backend/services/template-service/src/infrastructure/api/app.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.infrastructure.api.routes import router +from src.infrastructure.config import settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + print(f"Starting template-service on {settings.APP_HOST}:{settings.APP_PORT}") + yield + print("Shutting down template-service") + + +def create_app() -> FastAPI: + app = FastAPI( + title="Template Service", + description="Dashboard template management microservice", + version="0.1.0", + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(router) + + @app.get("/health") + async def health_check() -> dict[str, str]: + return {"status": "ok", "service": "template-service"} + + return app diff --git a/backend/services/template-service/src/infrastructure/api/dependencies.py b/backend/services/template-service/src/infrastructure/api/dependencies.py new file mode 100644 index 0000000..737d2e1 --- /dev/null +++ b/backend/services/template-service/src/infrastructure/api/dependencies.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.persistence.database import get_session +from src.adapters.persistence.template_repository_impl import TemplateRepositoryImpl +from src.application.usecases.delete_template_usecase import DeleteTemplateUseCase +from src.application.usecases.import_export_usecase import ( + ExportTemplateUseCase, + ImportTemplateUseCase, +) +from src.application.usecases.list_templates_usecase import ListTemplatesUseCase +from src.application.usecases.load_template_usecase import LoadTemplateUseCase +from src.application.usecases.save_template_usecase import SaveTemplateUseCase + + +def get_repository( + session: AsyncSession = Depends(get_session), +) -> TemplateRepositoryImpl: + return TemplateRepositoryImpl(session) + + +def get_save_template_usecase( + repo: TemplateRepositoryImpl = Depends(get_repository), +) -> SaveTemplateUseCase: + return SaveTemplateUseCase(repo) + + +def get_load_template_usecase( + repo: TemplateRepositoryImpl = Depends(get_repository), +) -> LoadTemplateUseCase: + return LoadTemplateUseCase(repo) + + +def get_list_templates_usecase( + repo: TemplateRepositoryImpl = Depends(get_repository), +) -> ListTemplatesUseCase: + return ListTemplatesUseCase(repo) + + +def get_delete_template_usecase( + repo: TemplateRepositoryImpl = Depends(get_repository), +) -> DeleteTemplateUseCase: + return DeleteTemplateUseCase(repo) + + +def get_import_template_usecase( + repo: TemplateRepositoryImpl = Depends(get_repository), +) -> ImportTemplateUseCase: + return ImportTemplateUseCase(repo) + + +def get_export_template_usecase( + repo: TemplateRepositoryImpl = Depends(get_repository), +) -> ExportTemplateUseCase: + return ExportTemplateUseCase(repo) diff --git a/backend/services/template-service/src/infrastructure/api/routes.py b/backend/services/template-service/src/infrastructure/api/routes.py new file mode 100644 index 0000000..f6dff94 --- /dev/null +++ b/backend/services/template-service/src/infrastructure/api/routes.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import uuid +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from shared.exceptions import EntityNotFoundError, ValidationError +from shared.types import TemplateType +from src.adapters.presenters.template_presenter import TemplatePresenter +from src.application.dto.template_response import ImportTemplateRequest, SaveTemplateRequest +from src.application.usecases.delete_template_usecase import DeleteTemplateUseCase +from src.application.usecases.import_export_usecase import ( + ExportTemplateUseCase, + ImportTemplateUseCase, +) +from src.application.usecases.list_templates_usecase import ListTemplatesUseCase +from src.application.usecases.load_template_usecase import LoadTemplateUseCase +from src.application.usecases.save_template_usecase import SaveTemplateUseCase +from src.infrastructure.api.dependencies import ( + get_delete_template_usecase, + get_export_template_usecase, + get_import_template_usecase, + get_list_templates_usecase, + get_load_template_usecase, + get_save_template_usecase, +) + +router = APIRouter(prefix="/api/v1/templates", tags=["templates"]) +presenter = TemplatePresenter() + + +@router.post("", status_code=201) +async def create_template( + request: SaveTemplateRequest, + usecase: SaveTemplateUseCase = Depends(get_save_template_usecase), +) -> dict[str, Any]: + try: + result = await usecase.execute(request) + return presenter.to_dict(result) + except ValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) + + +@router.get("") +async def list_templates( + template_type: Optional[TemplateType] = Query(default=None), + usecase: ListTemplatesUseCase = Depends(get_list_templates_usecase), +) -> list[dict[str, Any]]: + result = await usecase.execute(template_type) + return presenter.to_list(result) + + +@router.get("/{template_id}") +async def get_template( + template_id: uuid.UUID, + usecase: LoadTemplateUseCase = Depends(get_load_template_usecase), +) -> dict[str, Any]: + try: + result = await usecase.execute(template_id) + return presenter.to_dict(result) + except EntityNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.put("/{template_id}") +async def update_template( + template_id: uuid.UUID, + request: SaveTemplateRequest, + save_usecase: SaveTemplateUseCase = Depends(get_save_template_usecase), + load_usecase: LoadTemplateUseCase = Depends(get_load_template_usecase), +) -> dict[str, Any]: + try: + # Verify exists + existing = await load_usecase.execute(template_id) + except EntityNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + + try: + from datetime import datetime, timezone + + from src.adapters.persistence.template_repository_impl import TemplateRepositoryImpl + from src.domain.entities.template import Template + from src.domain.services.template_validation import validate_template + + template = Template( + id=template_id, + created_at=existing.created_at, + updated_at=datetime.now(timezone.utc), + name=request.name, + description=request.description, + template_type=request.template_type, + chart_configs=request.chart_configs, + layout=request.layout, + theme=request.theme, + ) + + errors = validate_template(template) + if errors: + raise ValidationError("; ".join(errors)) + + repo: TemplateRepositoryImpl = save_usecase._repository # type: ignore[attr-defined] + saved = await repo.save(template) + + from src.application.dto.template_response import TemplateResponse + + response = TemplateResponse( + id=saved.id, + name=saved.name, + description=saved.description, + template_type=saved.template_type, + chart_configs=saved.chart_configs, + layout=saved.layout, + theme=saved.theme, + created_at=saved.created_at, + updated_at=saved.updated_at, + ) + return presenter.to_dict(response) + except ValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) + + +@router.delete("/{template_id}", status_code=204) +async def delete_template( + template_id: uuid.UUID, + usecase: DeleteTemplateUseCase = Depends(get_delete_template_usecase), +) -> None: + try: + await usecase.execute(template_id) + except EntityNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.post("/import", status_code=201) +async def import_template( + request: ImportTemplateRequest, + usecase: ImportTemplateUseCase = Depends(get_import_template_usecase), +) -> dict[str, Any]: + try: + result = await usecase.execute(request.data) + return presenter.to_dict(result) + except ValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) + + +@router.get("/{template_id}/export") +async def export_template( + template_id: uuid.UUID, + usecase: ExportTemplateUseCase = Depends(get_export_template_usecase), +) -> dict[str, Any]: + try: + return await usecase.execute(template_id) + except EntityNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) diff --git a/backend/services/template-service/src/infrastructure/config.py b/backend/services/template-service/src/infrastructure/config.py new file mode 100644 index 0000000..9d74bfa --- /dev/null +++ b/backend/services/template-service/src/infrastructure/config.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/template_db" + APP_HOST: str = "0.0.0.0" + APP_PORT: int = 8000 + CORS_ORIGINS: list[str] = ["*"] + + model_config = {"env_prefix": "", "case_sensitive": True} + + +settings = Settings() diff --git a/backend/services/template-service/src/infrastructure/main.py b/backend/services/template-service/src/infrastructure/main.py new file mode 100644 index 0000000..fef316b --- /dev/null +++ b/backend/services/template-service/src/infrastructure/main.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import uvicorn + +from src.infrastructure.api.app import create_app +from src.infrastructure.config import settings + +app = create_app() + +if __name__ == "__main__": + uvicorn.run( + "src.infrastructure.main:app", + host=settings.APP_HOST, + port=settings.APP_PORT, + reload=True, + ) diff --git a/backend/services/template-service/src/infrastructure/seed/builtin_templates.json b/backend/services/template-service/src/infrastructure/seed/builtin_templates.json new file mode 100644 index 0000000..3975640 --- /dev/null +++ b/backend/services/template-service/src/infrastructure/seed/builtin_templates.json @@ -0,0 +1,188 @@ +[ + { + "name": "经营分析", + "description": "企业经营分析仪表盘模板,包含收入、利润、成本等核心经营指标的可视化展示", + "template_type": "builtin", + "chart_configs": [ + { + "id": "revenue_kpi", + "type": "kpi", + "title": "总收入", + "metric": "revenue", + "aggregation": "sum" + }, + { + "id": "profit_kpi", + "type": "kpi", + "title": "净利润", + "metric": "profit", + "aggregation": "sum" + }, + { + "id": "revenue_trend", + "type": "line", + "title": "收入趋势", + "dimension": "month", + "metrics": ["revenue", "cost"], + "aggregation": "sum" + }, + { + "id": "category_revenue", + "type": "bar", + "title": "分类收入", + "dimension": "category", + "metrics": ["revenue"], + "aggregation": "sum" + }, + { + "id": "cost_breakdown", + "type": "pie", + "title": "成本构成", + "dimension": "cost_type", + "metrics": ["cost"], + "aggregation": "sum" + }, + { + "id": "profit_margin_trend", + "type": "area", + "title": "利润率趋势", + "dimension": "month", + "metrics": ["profit_margin"], + "aggregation": "avg" + } + ], + "layout": [ + {"id": "revenue_kpi", "x": 0, "y": 0, "w": 6, "h": 2}, + {"id": "profit_kpi", "x": 6, "y": 0, "w": 6, "h": 2}, + {"id": "revenue_trend", "x": 0, "y": 2, "w": 12, "h": 4}, + {"id": "category_revenue", "x": 0, "y": 6, "w": 6, "h": 4}, + {"id": "cost_breakdown", "x": 6, "y": 6, "w": 6, "h": 4}, + {"id": "profit_margin_trend", "x": 0, "y": 10, "w": 12, "h": 4} + ], + "theme": "default" + }, + { + "name": "投诉分析", + "description": "客户投诉分析仪表盘模板,包含投诉量、分类、趋势、处理时效等维度的分析", + "template_type": "builtin", + "chart_configs": [ + { + "id": "total_complaints", + "type": "kpi", + "title": "投诉总量", + "metric": "complaint_count", + "aggregation": "count" + }, + { + "id": "avg_resolution_time", + "type": "kpi", + "title": "平均处理时长(小时)", + "metric": "resolution_hours", + "aggregation": "avg" + }, + { + "id": "complaint_trend", + "type": "line", + "title": "投诉趋势", + "dimension": "month", + "metrics": ["complaint_count"], + "aggregation": "count" + }, + { + "id": "complaint_category", + "type": "horizontal-bar", + "title": "投诉分类", + "dimension": "category", + "metrics": ["complaint_count"], + "aggregation": "count" + }, + { + "id": "complaint_wordcloud", + "type": "wordcloud", + "title": "投诉热词", + "dimension": "keyword", + "metrics": ["frequency"], + "aggregation": "sum" + }, + { + "id": "resolution_rate", + "type": "donut", + "title": "处理状态分布", + "dimension": "status", + "metrics": ["complaint_count"], + "aggregation": "count" + } + ], + "layout": [ + {"id": "total_complaints", "x": 0, "y": 0, "w": 6, "h": 2}, + {"id": "avg_resolution_time", "x": 6, "y": 0, "w": 6, "h": 2}, + {"id": "complaint_trend", "x": 0, "y": 2, "w": 12, "h": 4}, + {"id": "complaint_category", "x": 0, "y": 6, "w": 6, "h": 4}, + {"id": "complaint_wordcloud", "x": 6, "y": 6, "w": 6, "h": 4}, + {"id": "resolution_rate", "x": 0, "y": 10, "w": 12, "h": 4} + ], + "theme": "default" + }, + { + "name": "区域对比", + "description": "多区域数据对比分析仪表盘模板,支持地图可视化、区域排名、多维对比等功能", + "template_type": "builtin", + "chart_configs": [ + { + "id": "total_regions", + "type": "kpi", + "title": "覆盖区域数", + "metric": "region_count", + "aggregation": "count" + }, + { + "id": "top_region_kpi", + "type": "kpi", + "title": "最高业绩区域", + "metric": "top_region_revenue", + "aggregation": "max" + }, + { + "id": "region_map", + "type": "map", + "title": "区域分布地图", + "dimension": "region", + "metrics": ["revenue"], + "aggregation": "sum" + }, + { + "id": "region_ranking", + "type": "horizontal-bar", + "title": "区域业绩排名", + "dimension": "region", + "metrics": ["revenue"], + "aggregation": "sum" + }, + { + "id": "region_comparison", + "type": "grouped-bar", + "title": "区域多指标对比", + "dimension": "region", + "metrics": ["revenue", "cost", "profit"], + "aggregation": "sum" + }, + { + "id": "region_radar", + "type": "radar", + "title": "区域综合评价", + "dimension": "indicator", + "metrics": ["score"], + "aggregation": "avg" + } + ], + "layout": [ + {"id": "total_regions", "x": 0, "y": 0, "w": 6, "h": 2}, + {"id": "top_region_kpi", "x": 6, "y": 0, "w": 6, "h": 2}, + {"id": "region_map", "x": 0, "y": 2, "w": 12, "h": 5}, + {"id": "region_ranking", "x": 0, "y": 7, "w": 6, "h": 4}, + {"id": "region_comparison", "x": 6, "y": 7, "w": 6, "h": 4}, + {"id": "region_radar", "x": 0, "y": 11, "w": 12, "h": 4} + ], + "theme": "default" + } +] diff --git a/backend/shared/__init__.py b/backend/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/shared/base_entity.py b/backend/shared/base_entity.py new file mode 100644 index 0000000..53bbdcb --- /dev/null +++ b/backend/shared/base_entity.py @@ -0,0 +1,14 @@ +from __future__ import annotations +import uuid +from datetime import datetime, timezone +from dataclasses import dataclass, field + + +@dataclass +class BaseEntity: + id: uuid.UUID = field(default_factory=uuid.uuid4) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def touch(self) -> None: + self.updated_at = datetime.now(timezone.utc) diff --git a/backend/shared/base_repository.py b/backend/shared/base_repository.py new file mode 100644 index 0000000..bc5b82f --- /dev/null +++ b/backend/shared/base_repository.py @@ -0,0 +1,19 @@ +from __future__ import annotations +import uuid +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, Optional + +T = TypeVar("T") + +class BaseRepository(ABC, Generic[T]): + @abstractmethod + async def find_by_id(self, entity_id: uuid.UUID) -> Optional[T]: ... + + @abstractmethod + async def find_all(self) -> list[T]: ... + + @abstractmethod + async def save(self, entity: T) -> T: ... + + @abstractmethod + async def delete(self, entity_id: uuid.UUID) -> None: ... diff --git a/backend/shared/base_use_case.py b/backend/shared/base_use_case.py new file mode 100644 index 0000000..d6632c9 --- /dev/null +++ b/backend/shared/base_use_case.py @@ -0,0 +1,10 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +TInput = TypeVar("TInput") +TOutput = TypeVar("TOutput") + +class BaseUseCase(ABC, Generic[TInput, TOutput]): + @abstractmethod + async def execute(self, input_data: TInput) -> TOutput: ... diff --git a/backend/shared/exceptions.py b/backend/shared/exceptions.py new file mode 100644 index 0000000..da70cd2 --- /dev/null +++ b/backend/shared/exceptions.py @@ -0,0 +1,24 @@ +class DomainError(Exception): + """Base domain error.""" + +class EntityNotFoundError(DomainError): + def __init__(self, entity_type: str, entity_id: str): + super().__init__(f"{entity_type} not found: {entity_id}") + self.entity_type = entity_type + self.entity_id = entity_id + +class ValidationError(DomainError): + def __init__(self, message: str, field: str | None = None): + super().__init__(message) + self.field = field + +class FileParsingError(DomainError): + pass + +class ExportError(DomainError): + pass + +class ServiceCommunicationError(DomainError): + def __init__(self, service_name: str, message: str): + super().__init__(f"Error communicating with {service_name}: {message}") + self.service_name = service_name diff --git a/backend/shared/http_client.py b/backend/shared/http_client.py new file mode 100644 index 0000000..d2f6423 --- /dev/null +++ b/backend/shared/http_client.py @@ -0,0 +1,33 @@ +from __future__ import annotations +from typing import Any +import httpx + +class ServiceHttpClient: + """Shared async HTTP client for inter-service communication.""" + + def __init__(self, base_url: str, timeout: float = 30.0): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.get(f"{self.base_url}{path}", params=params) + resp.raise_for_status() + return resp.json() + + async def post(self, path: str, json: dict[str, Any] | None = None) -> Any: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post(f"{self.base_url}{path}", json=json) + resp.raise_for_status() + return resp.json() + + async def put(self, path: str, json: dict[str, Any] | None = None) -> Any: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.put(f"{self.base_url}{path}", json=json) + resp.raise_for_status() + return resp.json() + + async def delete(self, path: str) -> None: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.delete(f"{self.base_url}{path}") + resp.raise_for_status() diff --git a/backend/shared/types.py b/backend/shared/types.py new file mode 100644 index 0000000..01d3d0b --- /dev/null +++ b/backend/shared/types.py @@ -0,0 +1,73 @@ +from __future__ import annotations +from enum import StrEnum + +class FieldType(StrEnum): + NUMBER = "number" + TEXT = "text" + DATE = "date" + PERCENTAGE = "percentage" + GEO = "geo" + +class ChartType(StrEnum): + KPI = "kpi" + BAR = "bar" + GROUPED_BAR = "grouped-bar" + STACKED_BAR = "stacked-bar" + HORIZONTAL_BAR = "horizontal-bar" + LINE = "line" + AREA = "area" + PIE = "pie" + DONUT = "donut" + SCATTER = "scatter" + RADAR = "radar" + WORDCLOUD = "wordcloud" + BOSTON_MATRIX = "boston-matrix" + HEATMAP = "heatmap" + MAP = "map" + COMBO = "combo" + DATA_TABLE = "data-table" + +class DataStructureType(StrEnum): + TOTAL = "total" + YOY_MOM = "yoy-mom" + SINGLE_DIM_SINGLE_METRIC = "single-dim-single-metric" + SINGLE_DIM_MULTI_METRIC = "single-dim-multi-metric" + DUAL_DIM_SINGLE_METRIC = "dual-dim-single-metric" + DUAL_DIM_MULTI_METRIC = "dual-dim-multi-metric" + TIME_SERIES = "time-series" + GEO = "geo" + TEXT_FREQUENCY = "text-frequency" + TWO_DIM_EVALUATION = "two-dim-evaluation" + +class ExportFormat(StrEnum): + PNG = "png" + JPG = "jpg" + SVG = "svg" + PDF = "pdf" + EXCEL = "excel" + PPT = "ppt" + HTML = "html" + TEMPLATE = "template" + +class ExportStatus(StrEnum): + PENDING = "pending" + PROCESSING = "processing" + DONE = "done" + FAILED = "failed" + +class TemplateType(StrEnum): + BUILTIN = "builtin" + CUSTOM = "custom" + +class AggregationType(StrEnum): + SUM = "sum" + COUNT = "count" + AVG = "avg" + MAX = "max" + MIN = "min" + +class FileFormat(StrEnum): + XLSX = "xlsx" + XLS = "xls" + CSV = "csv" + JSON = "json" diff --git a/backend/tests/chart-service/__init__.py b/backend/tests/chart-service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/chart-service/integration/__init__.py b/backend/tests/chart-service/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/chart-service/unit/__init__.py b/backend/tests/chart-service/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/chart-service/unit/application/__init__.py b/backend/tests/chart-service/unit/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/chart-service/unit/domain/__init__.py b/backend/tests/chart-service/unit/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/chart-service/unit/domain/test_binding_validation.py b/backend/tests/chart-service/unit/domain/test_binding_validation.py new file mode 100644 index 0000000..932972e --- /dev/null +++ b/backend/tests/chart-service/unit/domain/test_binding_validation.py @@ -0,0 +1,88 @@ +"""Tests for chart binding validation rules.""" + +import pytest +from dataclasses import dataclass, field + + +# --------------------------------------------------------------------------- +# Stub domain objects -- replace with real imports later +# --------------------------------------------------------------------------- +@dataclass +class Binding: + channel: str # e.g. "x", "y", "color", "size" + field_id: str + + +@dataclass +class ChartSpec: + chart_type: str + bindings: list[Binding] = field(default_factory=list) + + def validate(self) -> list[str]: + """Return a list of validation error messages (empty == valid).""" + errors: list[str] = [] + required = REQUIRED_BINDINGS.get(self.chart_type, []) + bound_channels = {b.channel for b in self.bindings} + for channel in required: + if channel not in bound_channels: + errors.append( + f"Chart type '{self.chart_type}' requires binding for '{channel}'." + ) + return errors + + +REQUIRED_BINDINGS: dict[str, list[str]] = { + "bar": ["x", "y"], + "line": ["x", "y"], + "pie": ["theta"], + "scatter": ["x", "y"], +} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- +class TestBindingValidation: + """Ensure chart specs enforce required bindings per chart type.""" + + def test_bar_chart_requires_x_and_y(self): + spec = ChartSpec(chart_type="bar", bindings=[]) + errors = spec.validate() + assert any("'x'" in e for e in errors) + assert any("'y'" in e for e in errors) + + def test_bar_chart_valid_with_x_and_y(self): + spec = ChartSpec( + chart_type="bar", + bindings=[ + Binding(channel="x", field_id="category"), + Binding(channel="y", field_id="amount"), + ], + ) + assert spec.validate() == [] + + def test_pie_chart_requires_theta(self): + spec = ChartSpec(chart_type="pie", bindings=[]) + errors = spec.validate() + assert any("'theta'" in e for e in errors) + + def test_pie_chart_valid_with_theta(self): + spec = ChartSpec( + chart_type="pie", + bindings=[Binding(channel="theta", field_id="sales")], + ) + assert spec.validate() == [] + + def test_line_chart_missing_y(self): + spec = ChartSpec( + chart_type="line", + bindings=[Binding(channel="x", field_id="date")], + ) + errors = spec.validate() + assert any("'y'" in e for e in errors) + assert not any("'x'" in e for e in errors) + + def test_scatter_chart_requires_x_and_y(self): + spec = ChartSpec(chart_type="scatter", bindings=[]) + errors = spec.validate() + assert len(errors) == 2 diff --git a/backend/tests/chart-service/unit/domain/test_chart_recommendation.py b/backend/tests/chart-service/unit/domain/test_chart_recommendation.py new file mode 100644 index 0000000..f5c0abe --- /dev/null +++ b/backend/tests/chart-service/unit/domain/test_chart_recommendation.py @@ -0,0 +1,19 @@ +"""Tests for chart type recommendation engine.""" + +import pytest + + +class TestChartRecommendation: + """Placeholder tests for the recommendation algorithm.""" + + def test_one_category_one_number_suggests_bar(self): + """A dataset with one categorical and one numeric column should suggest bar chart.""" + pass + + def test_time_series_suggests_line(self): + """A dataset with a date column and a numeric column should suggest line chart.""" + pass + + def test_two_numbers_suggests_scatter(self): + """Two numeric columns should suggest scatter plot.""" + pass diff --git a/backend/tests/data-service/__init__.py b/backend/tests/data-service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/data-service/integration/__init__.py b/backend/tests/data-service/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/data-service/integration/test_api.py b/backend/tests/data-service/integration/test_api.py new file mode 100644 index 0000000..e36da09 --- /dev/null +++ b/backend/tests/data-service/integration/test_api.py @@ -0,0 +1,15 @@ +"""Integration tests for the data-service HTTP API.""" + +import pytest + + +class TestDataServiceAPI: + """Placeholder integration tests -- require a running service.""" + + def test_health_endpoint(self): + """GET /health should return 200.""" + pass + + def test_upload_csv(self): + """POST /datasets/upload with a CSV file should return 201.""" + pass diff --git a/backend/tests/data-service/unit/__init__.py b/backend/tests/data-service/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/data-service/unit/application/__init__.py b/backend/tests/data-service/unit/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/data-service/unit/application/test_import_data_usecase.py b/backend/tests/data-service/unit/application/test_import_data_usecase.py new file mode 100644 index 0000000..c282638 --- /dev/null +++ b/backend/tests/data-service/unit/application/test_import_data_usecase.py @@ -0,0 +1,15 @@ +"""Tests for the ImportData use case.""" + +import pytest + + +class TestImportDataUseCase: + """Placeholder tests for data import orchestration.""" + + def test_csv_import_creates_dataset(self): + """Importing a CSV should create a Dataset aggregate.""" + pass + + def test_duplicate_import_raises(self): + """Re-importing the same file with same hash should raise.""" + pass diff --git a/backend/tests/data-service/unit/domain/__init__.py b/backend/tests/data-service/unit/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/data-service/unit/domain/test_data_structure_inference.py b/backend/tests/data-service/unit/domain/test_data_structure_inference.py new file mode 100644 index 0000000..91d4e38 --- /dev/null +++ b/backend/tests/data-service/unit/domain/test_data_structure_inference.py @@ -0,0 +1,20 @@ +"""Tests for data structure inference (flat table, pivot table, etc.).""" + +import pytest + + +class TestDataStructureInference: + """Placeholder tests for data structure detection.""" + + def test_flat_table_detected(self): + """A simple header + rows structure should be detected as flat table.""" + # TODO: implement once domain logic exists + pass + + def test_pivot_table_detected(self): + """A cross-tab structure should be detected as pivot table.""" + pass + + def test_single_column_detected(self): + """A single-column dataset should be flagged appropriately.""" + pass diff --git a/backend/tests/data-service/unit/domain/test_field_type_inference.py b/backend/tests/data-service/unit/domain/test_field_type_inference.py new file mode 100644 index 0000000..2aa50db --- /dev/null +++ b/backend/tests/data-service/unit/domain/test_field_type_inference.py @@ -0,0 +1,87 @@ +"""Tests for field type inference logic.""" + +import pytest + + +class TestFieldTypeInference: + """Verify that the inference engine correctly classifies column data types.""" + + def test_integer_values_return_number(self): + values = ["1", "2", "3", "100", "999"] + assert infer_field_type(values) == "NUMBER" + + def test_float_values_return_number(self): + values = ["1.5", "2.7", "3.14", "0.001"] + assert infer_field_type(values) == "NUMBER" + + def test_date_strings_return_date(self): + values = ["2024-01-01", "2024-06-15", "2024-12-31"] + assert infer_field_type(values) == "DATE" + + def test_chinese_date_strings_return_date(self): + values = ["2024年1月", "2024年6月", "2024年12月"] + assert infer_field_type(values) == "DATE" + + def test_chinese_provinces_return_geo(self): + values = ["广东省", "北京市", "浙江省", "上海市", "四川省"] + assert infer_field_type(values) == "GEO" + + def test_city_names_return_geo(self): + values = ["深圳", "广州", "杭州", "成都", "武汉"] + assert infer_field_type(values) == "GEO" + + def test_arbitrary_strings_return_category(self): + values = ["apple", "banana", "cherry"] + assert infer_field_type(values) == "CATEGORY" + + def test_empty_values_return_unknown(self): + values = [] + assert infer_field_type(values) == "UNKNOWN" + + def test_mixed_with_nulls_still_infers(self): + values = ["1", None, "3", "", "5"] + assert infer_field_type(values) == "NUMBER" + + +# --------------------------------------------------------------------------- +# Stub implementation -- replace with real import once the domain module exists +# --------------------------------------------------------------------------- +_CHINESE_GEO_KEYWORDS = [ + "省", "市", "自治区", "特别行政区", + "深圳", "广州", "杭州", "成都", "武汉", "北京", "上海", +] + + +def infer_field_type(values: list[str | None]) -> str: + """Minimal reference implementation for testing purposes.""" + cleaned = [v for v in values if v is not None and v != ""] + if not cleaned: + return "UNKNOWN" + + # Try numeric + numeric_count = 0 + for v in cleaned: + try: + float(v.replace(",", "")) + numeric_count += 1 + except (ValueError, AttributeError): + pass + if numeric_count == len(cleaned): + return "NUMBER" + + # Try date patterns + import re + date_pattern = re.compile( + r"^\d{4}[-/]\d{1,2}[-/]\d{1,2}$|^\d{4}年\d{1,2}月" + ) + if all(date_pattern.match(v) for v in cleaned): + return "DATE" + + # Try geo + if any( + any(kw in v for kw in _CHINESE_GEO_KEYWORDS) + for v in cleaned + ): + return "GEO" + + return "CATEGORY" diff --git a/backend/tests/export-service/__init__.py b/backend/tests/export-service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/export-service/integration/__init__.py b/backend/tests/export-service/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/export-service/unit/__init__.py b/backend/tests/export-service/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/export-service/unit/application/__init__.py b/backend/tests/export-service/unit/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/export-service/unit/domain/__init__.py b/backend/tests/export-service/unit/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/export-service/unit/domain/test_export_task.py b/backend/tests/export-service/unit/domain/test_export_task.py new file mode 100644 index 0000000..d4f7f11 --- /dev/null +++ b/backend/tests/export-service/unit/domain/test_export_task.py @@ -0,0 +1,109 @@ +"""Tests for ExportTask state machine transitions.""" + +import pytest +from dataclasses import dataclass, field +from enum import Enum + + +# --------------------------------------------------------------------------- +# Stub domain object -- replace with real import later +# --------------------------------------------------------------------------- +class ExportStatus(str, Enum): + PENDING = "pending" + PROCESSING = "processing" + DONE = "done" + FAILED = "failed" + + +class InvalidTransitionError(Exception): + pass + + +@dataclass +class ExportTask: + task_id: str + status: ExportStatus = ExportStatus.PENDING + error_message: str | None = None + output_url: str | None = None + + _VALID_TRANSITIONS: dict = field(default=None, init=False, repr=False) + + def __post_init__(self): + self._VALID_TRANSITIONS = { + ExportStatus.PENDING: {ExportStatus.PROCESSING, ExportStatus.FAILED}, + ExportStatus.PROCESSING: {ExportStatus.DONE, ExportStatus.FAILED}, + ExportStatus.DONE: set(), + ExportStatus.FAILED: {ExportStatus.PENDING}, # allow retry + } + + def transition_to(self, new_status: ExportStatus, **kwargs) -> None: + allowed = self._VALID_TRANSITIONS.get(self.status, set()) + if new_status not in allowed: + raise InvalidTransitionError( + f"Cannot transition from {self.status.value} to {new_status.value}." + ) + self.status = new_status + if new_status == ExportStatus.FAILED: + self.error_message = kwargs.get("error_message", "Unknown error") + if new_status == ExportStatus.DONE: + self.output_url = kwargs.get("output_url") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- +class TestExportTaskStateMachine: + """Verify allowed and disallowed state transitions.""" + + def test_initial_status_is_pending(self): + task = ExportTask(task_id="t1") + assert task.status == ExportStatus.PENDING + + def test_pending_to_processing(self): + task = ExportTask(task_id="t1") + task.transition_to(ExportStatus.PROCESSING) + assert task.status == ExportStatus.PROCESSING + + def test_processing_to_done(self): + task = ExportTask(task_id="t1") + task.transition_to(ExportStatus.PROCESSING) + task.transition_to(ExportStatus.DONE, output_url="https://cdn/export/t1.pdf") + assert task.status == ExportStatus.DONE + assert task.output_url == "https://cdn/export/t1.pdf" + + def test_processing_to_failed(self): + task = ExportTask(task_id="t1") + task.transition_to(ExportStatus.PROCESSING) + task.transition_to(ExportStatus.FAILED, error_message="Render timeout") + assert task.status == ExportStatus.FAILED + assert task.error_message == "Render timeout" + + def test_pending_to_failed(self): + task = ExportTask(task_id="t1") + task.transition_to(ExportStatus.FAILED, error_message="Validation error") + assert task.status == ExportStatus.FAILED + + def test_done_to_processing_raises(self): + task = ExportTask(task_id="t1") + task.transition_to(ExportStatus.PROCESSING) + task.transition_to(ExportStatus.DONE) + with pytest.raises(InvalidTransitionError): + task.transition_to(ExportStatus.PROCESSING) + + def test_done_to_pending_raises(self): + task = ExportTask(task_id="t1") + task.transition_to(ExportStatus.PROCESSING) + task.transition_to(ExportStatus.DONE) + with pytest.raises(InvalidTransitionError): + task.transition_to(ExportStatus.PENDING) + + def test_failed_can_retry_to_pending(self): + task = ExportTask(task_id="t1") + task.transition_to(ExportStatus.FAILED, error_message="Timeout") + task.transition_to(ExportStatus.PENDING) + assert task.status == ExportStatus.PENDING + + def test_pending_to_done_raises(self): + task = ExportTask(task_id="t1") + with pytest.raises(InvalidTransitionError): + task.transition_to(ExportStatus.DONE) diff --git a/backend/tests/template-service/__init__.py b/backend/tests/template-service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/template-service/integration/__init__.py b/backend/tests/template-service/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/template-service/unit/__init__.py b/backend/tests/template-service/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/template-service/unit/application/__init__.py b/backend/tests/template-service/unit/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/template-service/unit/domain/__init__.py b/backend/tests/template-service/unit/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/template-service/unit/domain/test_template_validation.py b/backend/tests/template-service/unit/domain/test_template_validation.py new file mode 100644 index 0000000..edd80e6 --- /dev/null +++ b/backend/tests/template-service/unit/domain/test_template_validation.py @@ -0,0 +1,53 @@ +"""Tests for template entity validation.""" + +import pytest +from dataclasses import dataclass + + +# --------------------------------------------------------------------------- +# Stub domain object -- replace with real import later +# --------------------------------------------------------------------------- +@dataclass +class Template: + name: str + description: str = "" + chart_spec_ids: list[str] | None = None + + def validate(self) -> list[str]: + """Return a list of validation error messages (empty == valid).""" + errors: list[str] = [] + if not self.name or not self.name.strip(): + errors.append("Template name must not be empty.") + if len(self.name) > 200: + errors.append("Template name must not exceed 200 characters.") + return errors + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- +class TestTemplateValidation: + """Ensure template entity enforces basic invariants.""" + + def test_empty_name_fails_validation(self): + t = Template(name="") + errors = t.validate() + assert any("empty" in e.lower() for e in errors) + + def test_whitespace_only_name_fails_validation(self): + t = Template(name=" ") + errors = t.validate() + assert any("empty" in e.lower() for e in errors) + + def test_valid_name_passes(self): + t = Template(name="Q1 Sales Report") + assert t.validate() == [] + + def test_excessively_long_name_fails(self): + t = Template(name="x" * 201) + errors = t.validate() + assert any("200" in e for e in errors) + + def test_name_at_boundary_passes(self): + t = Template(name="x" * 200) + assert t.validate() == [] diff --git a/docs/backend-architecture-guide.md b/docs/backend-architecture-guide.md new file mode 100644 index 0000000..1695330 --- /dev/null +++ b/docs/backend-architecture-guide.md @@ -0,0 +1,804 @@ +# DataViz Pro — 后端开发指南 + +## 一、技术栈 + +| 层面 | 选型 | +|------|------| +| 语言 | Python 3.12+ | +| Web 框架 | FastAPI | +| 数据库 | PostgreSQL 16 | +| ORM | SQLAlchemy 2.0 (async) | +| 数据库迁移 | Alembic | +| 文件解析 | openpyxl (xlsx) + xlrd (xls) + pandas (csv/json) | +| PDF 导出 | WeasyPrint / ReportLab | +| PPT 导出 | python-pptx | +| Excel 导出 | openpyxl | +| HTML 导出 | Jinja2 模板 + 内联 ECharts | +| 图片导出 | Playwright (服务端渲染 ECharts → 截图) | +| 容器化 | Docker + Docker Compose | +| 依赖管理 | Poetry | +| 代码质量 | Ruff (lint + format) + mypy (类型检查) | +| 测试 | pytest + pytest-asyncio | + +## 二、架构总览 + +### 架构风格 + +**DDD + Clean Architecture + 微服务** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 微服务拆分 │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ +│ │ data-service│ │chart-service│ │template-svc │ │export-service│ +│ │ :8001 │ │ :8002 │ │ :8003 │ │ :8004 │ +│ └─────────────┘ └─────────────┘ └──────────────┘ └──────────────┘ +│ │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ PostgreSQL :5432 ││ +│ │ data_db │ chart_db │ template_db │ export_db ││ +│ └─────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +### 服务职责 + +| 服务 | 端口 | 职责 | 数据库 | +|------|------|------|--------| +| **data-service** | 8001 | 文件上传、解析、数据集 CRUD、字段推断 | data_db | +| **chart-service** | 8002 | 图表实例 CRUD、字段绑定、样式配置、ECharts option 生成 | chart_db | +| **template-service** | 8003 | 模板 CRUD、导入导出、内置模板 | template_db | +| **export-service** | 8004 | 多格式导出(PNG/PDF/PPT/Excel/HTML) | export_db (任务队列) | + +### 服务间依赖关系 + +``` +data-service ← 独立,不依赖其他服务 +chart-service ← 依赖 data-service(获取数据集结构和数据) +template-service ← 独立,不依赖其他服务 +export-service ← 依赖 data-service(获取数据) + ← 依赖 chart-service(获取图表配置和 ECharts option) + +依赖方向为单向树形,无循环依赖 +服务间通信方式:HTTP 内部调用(Docker 内网) +``` + +## 三、每个微服务内部的 Clean Architecture 分层 + +``` +┌─────────────────────────────────────────────┐ +│ Infrastructure(基础设施层) │ +│ FastAPI Routes · SQLAlchemy · 外部服务客户端 │ +├─────────────────────────────────────────────┤ +│ Interface Adapters(适配层) │ +│ Controllers · Repositories Impl · Presenters│ +├─────────────────────────────────────────────┤ +│ Application(应用层) │ +│ Use Cases · Input/Output Ports · DTOs │ +├─────────────────────────────────────────────┤ +│ Domain(领域层) │ +│ Entities · Value Objects · Domain Services │ +└─────────────────────────────────────────────┘ + +依赖方向:外层 → 内层(绝对不可反向) +``` + +## 四、Monorepo 目录结构 + +``` +backend/ +├── docker-compose.yml # 一键编排所有服务 + 数据库 +├── pyproject.toml # Monorepo 根配置 +├── shared/ # 跨服务共享代码 +│ ├── __init__.py +│ ├── base_entity.py # 实体基类(id, created_at, updated_at) +│ ├── base_repository.py # 仓储接口基类 +│ ├── base_use_case.py # 用例接口基类 +│ ├── exceptions.py # 公共异常定义 +│ ├── types.py # 公共类型(FieldType, ChartType 等枚举) +│ └── http_client.py # 服务间 HTTP 调用客户端 +│ +├── services/ +│ ├── data-service/ # ========== 数据服务 ========== +│ │ ├── Dockerfile +│ │ ├── pyproject.toml +│ │ ├── alembic/ # 数据库迁移 +│ │ │ ├── alembic.ini +│ │ │ └── versions/ +│ │ │ +│ │ └── src/ +│ │ ├── domain/ # 领域层 +│ │ │ ├── entities/ +│ │ │ │ ├── dataset.py # DataSet 聚合根 +│ │ │ │ ├── column.py # Column 实体 +│ │ │ │ └── data_row.py # DataRow 值对象 +│ │ │ ├── value_objects/ +│ │ │ │ ├── field_type.py # FieldType 枚举 +│ │ │ │ ├── file_format.py # FileFormat 枚举 +│ │ │ │ └── data_structure.py # DataStructure 枚举 +│ │ │ ├── services/ +│ │ │ │ ├── field_type_inference.py # 字段类型推断规则 +│ │ │ │ └── data_structure_inference.py # 数据结构推断规则 +│ │ │ └── repositories/ +│ │ │ └── dataset_repository.py # 仓储接口(抽象类) +│ │ │ +│ │ ├── application/ # 应用层 +│ │ │ ├── ports/ +│ │ │ │ ├── input/ +│ │ │ │ │ ├── import_data.py # 导入数据用例接口 +│ │ │ │ │ ├── get_dataset.py # 获取数据集用例接口 +│ │ │ │ │ └── list_datasets.py # 列表数据集用例接口 +│ │ │ │ └── output/ +│ │ │ │ └── file_parser.py # 文件解析端口接口 +│ │ │ ├── usecases/ +│ │ │ │ ├── import_data_usecase.py # 导入数据用例实现 +│ │ │ │ ├── get_dataset_usecase.py # 获取数据集 +│ │ │ │ ├── list_datasets_usecase.py # 列表数据集 +│ │ │ │ └── delete_dataset_usecase.py # 删除数据集 +│ │ │ └── dto/ +│ │ │ ├── import_result.py +│ │ │ └── dataset_response.py +│ │ │ +│ │ ├── adapters/ # 适配层 +│ │ │ ├── persistence/ +│ │ │ │ ├── models.py # SQLAlchemy ORM 模型 +│ │ │ │ ├── dataset_repository_impl.py # 仓储实现 +│ │ │ │ └── database.py # 数据库连接配置 +│ │ │ ├── parsers/ +│ │ │ │ ├── xlsx_parser.py # Excel 解析器 +│ │ │ │ ├── csv_parser.py # CSV 解析器 +│ │ │ │ ├── json_parser.py # JSON 解析器 +│ │ │ │ └── parser_factory.py # 解析器工厂 +│ │ │ └── presenters/ +│ │ │ └── dataset_presenter.py # Entity → API Response +│ │ │ +│ │ └── infrastructure/ # 基础设施层 +│ │ ├── api/ +│ │ │ ├── app.py # FastAPI 应用实例 +│ │ │ ├── routes.py # 路由定义 +│ │ │ └── dependencies.py # 依赖注入 +│ │ ├── config.py # 配置(环境变量) +│ │ └── main.py # 入口 uvicorn +│ │ +│ ├── chart-service/ # ========== 图表服务 ========== +│ │ ├── Dockerfile +│ │ ├── pyproject.toml +│ │ ├── alembic/ +│ │ │ +│ │ └── src/ +│ │ ├── domain/ +│ │ │ ├── entities/ +│ │ │ │ ├── chart_instance.py # ChartInstance 聚合根 +│ │ │ │ ├── field_binding.py # FieldBinding 实体 +│ │ │ │ └── style_config.py # StyleConfig 值对象 +│ │ │ ├── value_objects/ +│ │ │ │ └── chart_type.py # ChartType 枚举 + 元数据 +│ │ │ ├── services/ +│ │ │ │ ├── chart_recommendation.py # 图表推荐规则 +│ │ │ │ ├── binding_validation.py # 绑定合法性校验 +│ │ │ │ └── option_builder.py # ECharts option 生成(领域规则) +│ │ │ └── repositories/ +│ │ │ └── chart_repository.py +│ │ │ +│ │ ├── application/ +│ │ │ ├── ports/ +│ │ │ │ ├── input/ +│ │ │ │ │ ├── create_chart.py +│ │ │ │ │ ├── update_chart.py +│ │ │ │ │ ├── get_chart_option.py # 获取 ECharts option +│ │ │ │ │ └── recommend_charts.py # 推荐图表 +│ │ │ │ └── output/ +│ │ │ │ └── data_service_client.py # 调用 data-service 的端口 +│ │ │ ├── usecases/ +│ │ │ │ ├── create_chart_usecase.py +│ │ │ │ ├── update_chart_usecase.py +│ │ │ │ ├── get_chart_option_usecase.py +│ │ │ │ └── recommend_charts_usecase.py +│ │ │ └── dto/ +│ │ │ ├── chart_response.py +│ │ │ └── echarts_option.py +│ │ │ +│ │ ├── adapters/ +│ │ │ ├── persistence/ +│ │ │ │ ├── models.py +│ │ │ │ ├── chart_repository_impl.py +│ │ │ │ └── database.py +│ │ │ ├── clients/ +│ │ │ │ └── data_service_client_impl.py # HTTP 调用 data-service +│ │ │ ├── option_builders/ # ECharts option 适配 +│ │ │ │ ├── bar_builder.py +│ │ │ │ ├── line_builder.py +│ │ │ │ ├── pie_builder.py +│ │ │ │ ├── scatter_builder.py +│ │ │ │ ├── radar_builder.py +│ │ │ │ ├── heatmap_builder.py +│ │ │ │ ├── map_builder.py +│ │ │ │ ├── wordcloud_builder.py +│ │ │ │ ├── combo_builder.py +│ │ │ │ └── builder_factory.py +│ │ │ └── presenters/ +│ │ │ └── chart_presenter.py +│ │ │ +│ │ └── infrastructure/ +│ │ ├── api/ +│ │ │ ├── app.py +│ │ │ ├── routes.py +│ │ │ └── dependencies.py +│ │ ├── config.py +│ │ └── main.py +│ │ +│ ├── template-service/ # ========== 模板服务 ========== +│ │ ├── Dockerfile +│ │ ├── pyproject.toml +│ │ ├── alembic/ +│ │ │ +│ │ └── src/ +│ │ ├── domain/ +│ │ │ ├── entities/ +│ │ │ │ └── template.py # Template 聚合根 +│ │ │ ├── value_objects/ +│ │ │ │ └── template_type.py # 内置/自定义 +│ │ │ ├── services/ +│ │ │ │ └── template_validation.py # 模板合法性校验 +│ │ │ └── repositories/ +│ │ │ └── template_repository.py +│ │ │ +│ │ ├── application/ +│ │ │ ├── ports/ +│ │ │ │ ├── input/ +│ │ │ │ │ ├── save_template.py +│ │ │ │ │ ├── load_template.py +│ │ │ │ │ ├── list_templates.py +│ │ │ │ │ └── import_export_template.py +│ │ │ │ └── output/ +│ │ │ │ └── template_storage.py # 持久化端口 +│ │ │ ├── usecases/ +│ │ │ │ ├── save_template_usecase.py +│ │ │ │ ├── load_template_usecase.py +│ │ │ │ ├── list_templates_usecase.py +│ │ │ │ ├── delete_template_usecase.py +│ │ │ │ └── import_export_usecase.py +│ │ │ └── dto/ +│ │ │ └── template_response.py +│ │ │ +│ │ ├── adapters/ +│ │ │ ├── persistence/ +│ │ │ │ ├── models.py +│ │ │ │ ├── template_repository_impl.py +│ │ │ │ └── database.py +│ │ │ └── presenters/ +│ │ │ └── template_presenter.py +│ │ │ +│ │ └── infrastructure/ +│ │ ├── api/ +│ │ │ ├── app.py +│ │ │ ├── routes.py +│ │ │ └── dependencies.py +│ │ ├── config.py +│ │ ├── seed/ # 内置模板种子数据 +│ │ │ └── builtin_templates.json +│ │ └── main.py +│ │ +│ └── export-service/ # ========== 导出服务 ========== +│ ├── Dockerfile +│ ├── pyproject.toml +│ ├── alembic/ +│ │ +│ └── src/ +│ ├── domain/ +│ │ ├── entities/ +│ │ │ └── export_task.py # ExportTask 聚合根 +│ │ ├── value_objects/ +│ │ │ ├── export_format.py # PNG/PDF/PPT/Excel/HTML +│ │ │ └── export_status.py # pending/processing/done/failed +│ │ ├── services/ +│ │ │ └── export_strategy.py # 导出策略选择规则 +│ │ └── repositories/ +│ │ └── export_task_repository.py +│ │ +│ ├── application/ +│ │ ├── ports/ +│ │ │ ├── input/ +│ │ │ │ ├── create_export.py +│ │ │ │ └── get_export_status.py +│ │ │ └── output/ +│ │ │ ├── data_service_client.py +│ │ │ ├── chart_service_client.py +│ │ │ ├── image_renderer.py # 图片渲染端口 +│ │ │ ├── pdf_generator.py # PDF 生成端口 +│ │ │ ├── ppt_generator.py # PPT 生成端口 +│ │ │ ├── excel_generator.py # Excel 生成端口 +│ │ │ └── html_generator.py # HTML 生成端口 +│ │ ├── usecases/ +│ │ │ ├── create_export_usecase.py +│ │ │ └── get_export_status_usecase.py +│ │ └── dto/ +│ │ ├── export_request.py +│ │ └── export_response.py +│ │ +│ ├── adapters/ +│ │ ├── persistence/ +│ │ │ ├── models.py +│ │ │ ├── export_task_repository_impl.py +│ │ │ └── database.py +│ │ ├── clients/ +│ │ │ ├── data_service_client_impl.py +│ │ │ └── chart_service_client_impl.py +│ │ ├── generators/ +│ │ │ ├── playwright_image_renderer.py # Playwright 截图 +│ │ │ ├── weasyprint_pdf_generator.py # PDF +│ │ │ ├── pptx_generator.py # PPT +│ │ │ ├── openpyxl_excel_generator.py # Excel +│ │ │ └── jinja2_html_generator.py # HTML +│ │ └── presenters/ +│ │ └── export_presenter.py +│ │ +│ └── infrastructure/ +│ ├── api/ +│ │ ├── app.py +│ │ ├── routes.py +│ │ └── dependencies.py +│ ├── config.py +│ └── main.py +│ +└── tests/ # 测试(镜像 services 结构) + ├── data-service/ + │ ├── unit/ + │ │ ├── domain/ + │ │ └── application/ + │ └── integration/ + ├── chart-service/ + │ ├── unit/ + │ └── integration/ + ├── template-service/ + │ ├── unit/ + │ └── integration/ + └── export-service/ + ├── unit/ + └── integration/ +``` + +## 五、DDD 领域模型 + +### 限界上下文映射 + +``` +┌─────────────────┐ ┌─────────────────┐ +│ Data Context │ │ Chart Context │ +│ │ │ │ +│ DataSet (聚合根) │────→│ ChartInstance │ +│ Column │ 引用 │ (聚合根) │ +│ DataRow │ ID │ FieldBinding │ +│ │ │ StyleConfig │ +└─────────────────┘ └─────────────────┘ + │ + ┌─────────────────┐ │ 引用 ID + │Template Context │ │ + │ │ │ + │ Template (聚合根) │←──┘ + │ │ + └─────────────────┘ + ┌─────────────────┐ + │ Export Context │ + │ │ + │ ExportTask (聚合根)│ + │ │ + └─────────────────┘ + 引用 DataSet ID + 引用 ChartInstance ID +``` + +### 聚合根设计 + +```python +# data-service: DataSet 聚合根 +class DataSet: + id: UUID + file_name: str + sheet_name: Optional[str] + columns: List[Column] # 聚合内实体 + row_count: int + data_structure: DataStructure # 推断出的数据结构类型 + created_at: datetime + updated_at: datetime + + def infer_field_types(self) -> None: ... + def infer_data_structure(self) -> None: ... + def get_column_by_name(self, name: str) -> Column: ... + def validate(self) -> None: ... + +# chart-service: ChartInstance 聚合根 +class ChartInstance: + id: UUID + dataset_id: UUID # 引用,非嵌套 + chart_type: ChartType + bindings: List[FieldBinding] # 聚合内实体 + style: StyleConfig # 聚合内值对象 + filters: List[FilterRule] + sort: Optional[SortConfig] + top_n: Optional[int] + created_at: datetime + updated_at: datetime + + def update_bindings(self, bindings: List[FieldBinding]) -> None: ... + def update_style(self, style: StyleConfig) -> None: ... + def validate_bindings(self) -> None: ... + +# template-service: Template 聚合根 +class Template: + id: UUID + name: str + description: str + template_type: TemplateType # builtin / custom + chart_configs: List[dict] # 图表配置 JSON + layout: List[dict] # 布局配置 JSON + theme: str + created_at: datetime + updated_at: datetime + + def validate(self) -> None: ... + def to_export_json(self) -> dict: ... + @classmethod + def from_import_json(cls, data: dict) -> 'Template': ... + +# export-service: ExportTask 聚合根 +class ExportTask: + id: UUID + format: ExportFormat + chart_ids: List[UUID] + dataset_id: UUID + status: ExportStatus # pending → processing → done / failed + file_path: Optional[str] + error_message: Optional[str] + created_at: datetime + completed_at: Optional[datetime] + + def start_processing(self) -> None: ... + def complete(self, file_path: str) -> None: ... + def fail(self, error: str) -> None: ... +``` + +## 六、Clean Architecture 依赖方向 + +以 data-service 为例: + +``` +infrastructure/ adapters/ application/ domain/ +(FastAPI, SQLAlchemy) (实现层) (用例层) (领域层) + +routes.py ──→ dependencies.py ──→ usecases/ ──→ entities/ + │ │ │ + │ │ value_objects/ + │ │ │ + │ │ services/ + │ │ │ + │ ▼ │ + │ ports/input │ + │ ports/output ←────┘ + │ ▲ + │ │(实现接口) + ├──→ persistence/ │ + ├──→ parsers/ │ + └──→ presenters/ + +import 方向全部从外向内 +ports/output 定义接口在 application 层,实现在 adapters 层(依赖反转) +``` + +## 七、依赖注入 + +```python +# infrastructure/api/dependencies.py +# 组装所有端口实现,注入到 Use Case + +from adapters.persistence.database import get_session +from adapters.persistence.dataset_repository_impl import DataSetRepositoryImpl +from adapters.parsers.parser_factory import ParserFactory +from application.usecases.import_data_usecase import ImportDataUseCase + +async def get_import_data_usecase( + session: AsyncSession = Depends(get_session), +) -> ImportDataUseCase: + repository = DataSetRepositoryImpl(session) + file_parser = ParserFactory() + return ImportDataUseCase( + repository=repository, + file_parser=file_parser, + ) + + +# infrastructure/api/routes.py +from fastapi import APIRouter, Depends, UploadFile +from infrastructure.api.dependencies import get_import_data_usecase + +router = APIRouter() + +@router.post("/datasets/import") +async def import_data( + file: UploadFile, + usecase: ImportDataUseCase = Depends(get_import_data_usecase), +): + result = await usecase.execute(file) + return DataSetPresenter.to_response(result) +``` + +## 八、API 设计 + +### data-service (:8001) + +``` +POST /api/v1/datasets/import 上传并解析文件 +GET /api/v1/datasets 列出所有数据集 +GET /api/v1/datasets/{id} 获取数据集详情(含列信息) +GET /api/v1/datasets/{id}/rows 获取数据行(分页) +PATCH /api/v1/datasets/{id}/rows/{row} 修改单元格 +DELETE /api/v1/datasets/{id} 删除数据集 +GET /api/v1/datasets/{id}/structure 获取推断的数据结构 +``` + +### chart-service (:8002) + +``` +POST /api/v1/charts 创建图表实例 +GET /api/v1/charts 列出所有图表 +GET /api/v1/charts/{id} 获取图表配置 +PUT /api/v1/charts/{id} 更新图表配置(绑定+样式) +DELETE /api/v1/charts/{id} 删除图表 +GET /api/v1/charts/{id}/option 获取 ECharts option JSON +POST /api/v1/charts/recommend 根据数据集推荐图表类型 +``` + +### template-service (:8003) + +``` +POST /api/v1/templates 保存模板 +GET /api/v1/templates 列出所有模板(含内置) +GET /api/v1/templates/{id} 获取模板详情 +PUT /api/v1/templates/{id} 更新模板 +DELETE /api/v1/templates/{id} 删除模板 +POST /api/v1/templates/import 导入模板 JSON +GET /api/v1/templates/{id}/export 导出模板 JSON +``` + +### export-service (:8004) + +``` +POST /api/v1/exports 创建导出任务 +GET /api/v1/exports/{id} 查询导出状态 +GET /api/v1/exports/{id}/download 下载导出文件 +``` + +## 九、服务间通信 + +```python +# chart-service 调用 data-service 获取数据集 + +# application/ports/output/data_service_client.py(端口接口) +from abc import ABC, abstractmethod + +class IDataServiceClient(ABC): + @abstractmethod + async def get_dataset(self, dataset_id: UUID) -> DataSetDTO: ... + + @abstractmethod + async def get_rows(self, dataset_id: UUID, limit: int, offset: int) -> List[dict]: ... + + +# adapters/clients/data_service_client_impl.py(适配实现) +import httpx + +class DataServiceClientImpl(IDataServiceClient): + def __init__(self, base_url: str = "http://data-service:8000"): + self.base_url = base_url + + async def get_dataset(self, dataset_id: UUID) -> DataSetDTO: + async with httpx.AsyncClient() as client: + resp = await client.get(f"{self.base_url}/api/v1/datasets/{dataset_id}") + resp.raise_for_status() + return DataSetDTO(**resp.json()) + + async def get_rows(self, dataset_id: UUID, limit: int, offset: int) -> List[dict]: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{self.base_url}/api/v1/datasets/{dataset_id}/rows", + params={"limit": limit, "offset": offset}, + ) + resp.raise_for_status() + return resp.json()["rows"] +``` + +## 十、数据库设计 + +### data_db + +```sql +CREATE TABLE datasets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + file_name VARCHAR(255) NOT NULL, + sheet_name VARCHAR(255), + row_count INTEGER NOT NULL DEFAULT 0, + data_structure VARCHAR(50), -- single_dim / dual_dim / time_series / geo ... + raw_data JSONB NOT NULL, -- 原始行数据 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE columns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dataset_id UUID NOT NULL REFERENCES datasets(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + field_type VARCHAR(50) NOT NULL, -- number / text / date / percentage / geo + sample_values JSONB, + ordinal INTEGER NOT NULL -- 列顺序 +); +``` + +### chart_db + +```sql +CREATE TABLE chart_instances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dataset_id UUID NOT NULL, -- 引用 data_db.datasets.id(跨库引用,不加外键) + chart_type VARCHAR(50) NOT NULL, + bindings JSONB NOT NULL, -- FieldBinding[] + style JSONB NOT NULL, -- StyleConfig + filters JSONB DEFAULT '[]', + sort_config JSONB, + top_n INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +### template_db + +```sql +CREATE TABLE templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + template_type VARCHAR(20) NOT NULL DEFAULT 'custom', -- builtin / custom + chart_configs JSONB NOT NULL, -- 图表配置数组 + layout JSONB NOT NULL, -- 布局配置数组 + theme VARCHAR(50) NOT NULL DEFAULT 'light', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +### export_db + +```sql +CREATE TABLE export_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + format VARCHAR(20) NOT NULL, -- png / pdf / ppt / excel / html + chart_ids UUID[] NOT NULL, + dataset_id UUID NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending / processing / done / failed + file_path VARCHAR(500), + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); +``` + +## 十一、Docker Compose 编排 + +```yaml +# docker-compose.yml +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: dataviz + POSTGRES_PASSWORD: dataviz_local + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./init-databases.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dataviz"] + interval: 5s + retries: 5 + + data-service: + build: ./services/data-service + ports: + - "8001:8000" + environment: + DATABASE_URL: postgresql+asyncpg://dataviz:dataviz_local@postgres:5432/data_db + depends_on: + postgres: + condition: service_healthy + + chart-service: + build: ./services/chart-service + ports: + - "8002:8000" + environment: + DATABASE_URL: postgresql+asyncpg://dataviz:dataviz_local@postgres:5432/chart_db + DATA_SERVICE_URL: http://data-service:8000 + depends_on: + postgres: + condition: service_healthy + data-service: + condition: service_started + + template-service: + build: ./services/template-service + ports: + - "8003:8000" + environment: + DATABASE_URL: postgresql+asyncpg://dataviz:dataviz_local@postgres:5432/template_db + depends_on: + postgres: + condition: service_healthy + + export-service: + build: ./services/export-service + ports: + - "8004:8000" + environment: + DATABASE_URL: postgresql+asyncpg://dataviz:dataviz_local@postgres:5432/export_db + DATA_SERVICE_URL: http://data-service:8000 + CHART_SERVICE_URL: http://chart-service:8000 + depends_on: + postgres: + condition: service_healthy + data-service: + condition: service_started + chart-service: + condition: service_started + +volumes: + pgdata: +``` + +```sql +-- init-databases.sql +CREATE DATABASE data_db; +CREATE DATABASE chart_db; +CREATE DATABASE template_db; +CREATE DATABASE export_db; +``` + +## 十二、本地开发启动 + +```bash +# 一键启动所有服务 +docker-compose up --build + +# 服务地址 +# data-service → http://localhost:8001/docs +# chart-service → http://localhost:8002/docs +# template-service → http://localhost:8003/docs +# export-service → http://localhost:8004/docs + +# 单独重启某个服务(开发时) +docker-compose restart chart-service + +# 查看日志 +docker-compose logs -f export-service +``` + +## 十三、前端对接配置 + +```typescript +// 前端服务地址枚举 +enum ServiceURL { + Data = 'http://localhost:8001', + Chart = 'http://localhost:8002', + Template = 'http://localhost:8003', + Export = 'http://localhost:8004', +} + +// 示例调用 +const response = await fetch(`${ServiceURL.Data}/api/v1/datasets/import`, { + method: 'POST', + body: formData, +}); +``` diff --git a/docs/frontend-architecture-guide.md b/docs/frontend-architecture-guide.md new file mode 100644 index 0000000..464dd14 --- /dev/null +++ b/docs/frontend-architecture-guide.md @@ -0,0 +1,466 @@ +# DataViz Pro — 前端开发指南 + +## 一、Clean Architecture 分层总览 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frameworks & Drivers(最外层) │ +│ Next.js · React · ECharts · SheetJS · jsPDF · AG Grid │ +├─────────────────────────────────────────────────────────────────┤ +│ Interface Adapters(适配层) │ +│ Redux Toolkit Slices · Zustand Stores · Presenters · Gateways │ +├─────────────────────────────────────────────────────────────────┤ +│ Application(用例层) │ +│ Use Cases · Input/Output Ports(接口定义) │ +├─────────────────────────────────────────────────────────────────┤ +│ Domain(领域层,最内层) │ +│ Entities · Value Objects · Domain Services · Business Rules │ +└─────────────────────────────────────────────────────────────────┘ + +依赖方向:外层 → 内层(绝对不可反向) +``` + +## 二、目录结构 + +``` +src/ +├── domain/ # 领域层(零依赖,纯 TypeScript) +│ ├── entities/ +│ │ ├── DataSet.ts # 数据集实体 +│ │ ├── Column.ts # 列实体(含类型推断规则) +│ │ ├── ChartInstance.ts # 图表实例实体 +│ │ ├── FieldBinding.ts # 字段绑定实体 +│ │ ├── StyleConfig.ts # 样式配置实体 +│ │ ├── LayoutItem.ts # 布局项实体 +│ │ └── Template.ts # 模板实体 +│ │ +│ ├── valueObjects/ +│ │ ├── FieldType.ts # 字段类型(number|text|date|percentage|geo) +│ │ ├── ChartType.ts # 图表类型枚举 + 元数据 +│ │ ├── Aggregation.ts # 聚合方式 +│ │ ├── SortOrder.ts # 排序方向 +│ │ └── ExportFormat.ts # 导出格式 +│ │ +│ ├── services/ # 领域服务(纯业务规则) +│ │ ├── FieldTypeInferenceService.ts # 字段类型推断规则 +│ │ ├── DataStructureInferenceService.ts # 数据结构推断规则 +│ │ ├── ChartRecommendationService.ts# 图表推荐规则 +│ │ ├── DataTransformService.ts # 聚合/排序/TopN 规则 +│ │ └── ValidationService.ts # 数据绑定合法性校验 +│ │ +│ └── rules/ # 业务规则常量 +│ ├── chartBindingRules.ts # 每种图表需要什么字段绑定 +│ └── chartCompatibility.ts # 字段类型与图表的兼容矩阵 +│ +├── application/ # 用例层(依赖 domain,不依赖外层) +│ ├── ports/ # 端口定义(接口) +│ │ ├── input/ # 输入端口(Use Case 接口) +│ │ │ ├── IImportDataUseCase.ts +│ │ │ ├── ICreateChartUseCase.ts +│ │ │ ├── IUpdateChartConfigUseCase.ts +│ │ │ ├── IExportUseCase.ts +│ │ │ ├── ITemplateUseCase.ts +│ │ │ └── ILayoutUseCase.ts +│ │ │ +│ │ └── output/ # 输出端口(外部依赖的接口) +│ │ ├── IFileParser.ts # 文件解析能力 +│ │ ├── IChartRenderer.ts # 图表渲染能力(生成 option) +│ │ ├── IExportGateway.ts # 导出能力 +│ │ ├── IStorageGateway.ts # 持久化能力 +│ │ └── IGeoDataProvider.ts # 地图数据能力 +│ │ +│ ├── usecases/ # 用例实现 +│ │ ├── ImportDataUseCase.ts # 导入数据:解析→推断→入库 +│ │ ├── CreateChartUseCase.ts # 创建图表:推荐→默认绑定→生成 +│ │ ├── UpdateChartConfigUseCase.ts # 更新图表配置:校验→转换→更新 +│ │ ├── ExportUseCase.ts # 导出:按格式调用 Gateway +│ │ ├── TemplateUseCase.ts # 模板:保存/加载/导入导出 +│ │ └── LayoutUseCase.ts # 布局:增删改查 +│ │ +│ └── dto/ # 数据传输对象 +│ ├── ImportResult.ts +│ ├── ChartSuggestion.ts +│ └── ExportOptions.ts +│ +├── adapters/ # 适配层(实现端口,连接内外) +│ ├── gateways/ # 输出端口的具体实现 +│ │ ├── SheetJSFileParser.ts # IFileParser → SheetJS 实现 +│ │ ├── EChartsOptionBuilder.ts # IChartRenderer → ECharts option 构建 +│ │ │ ├── barOptionBuilder.ts +│ │ │ ├── lineOptionBuilder.ts +│ │ │ ├── pieOptionBuilder.ts +│ │ │ ├── scatterOptionBuilder.ts +│ │ │ ├── radarOptionBuilder.ts +│ │ │ ├── heatmapOptionBuilder.ts +│ │ │ ├── mapOptionBuilder.ts +│ │ │ ├── wordcloudOptionBuilder.ts +│ │ │ ├── comboOptionBuilder.ts +│ │ │ └── index.ts +│ │ ├── exportGateway/ # IExportGateway 实现 +│ │ │ ├── ImageExportGateway.ts # PNG/JPG/SVG +│ │ │ ├── PDFExportGateway.ts # jsPDF +│ │ │ ├── ExcelExportGateway.ts # SheetJS +│ │ │ ├── PPTExportGateway.ts # PptxGenJS +│ │ │ ├── HTMLExportGateway.ts # 独立 HTML +│ │ │ └── index.ts +│ │ ├── LocalStorageGateway.ts # IStorageGateway → localStorage +│ │ └── GeoJsonProvider.ts # IGeoDataProvider → 静态 GeoJSON +│ │ +│ ├── state/ # 状态管理适配 +│ │ ├── redux/ # Redux Toolkit(复杂全局状态) +│ │ │ ├── store.ts # configureStore +│ │ │ ├── dataSlice.ts # 数据集 CRUD +│ │ │ ├── chartSlice.ts # 图表实例 CRUD + 配置变更 +│ │ │ ├── layoutSlice.ts # 布局状态 +│ │ │ └── templateSlice.ts # 模板状态 +│ │ │ +│ │ └── zustand/ # Zustand(轻量 UI 状态) +│ │ ├── uiStore.ts # 面板开关/活跃选项卡/拖拽状态 +│ │ ├── themeStore.ts # 主题/色板 +│ │ └── interactionStore.ts # 悬停/选中/缩放等交互态 +│ │ +│ └── presenters/ # Presenter(Use Case 输出 → View Model) +│ ├── DataPreviewPresenter.ts # DataSet → 表格展示数据 +│ ├── ChartPresenter.ts # ChartInstance → ECharts option +│ ├── FieldListPresenter.ts # Column[] → 左侧字段列表 VM +│ └── ExportPresenter.ts # 导出进度/结果 → UI 状态 +│ +├── frameworks/ # 框架层(Next.js + React 组件) +│ ├── app/ # Next.js App Router +│ │ ├── layout.tsx # 根布局 +│ │ ├── page.tsx # 首页(仪表盘编辑器) +│ │ ├── providers.tsx # Redux Provider + 主题 Provider +│ │ └── globals.css +│ │ +│ ├── components/ # React 组件(纯 UI) +│ │ ├── layout/ +│ │ │ ├── TopToolbar.tsx +│ │ │ ├── LeftPanel.tsx +│ │ │ ├── CenterCanvas.tsx +│ │ │ ├── RightPanel.tsx +│ │ │ └── BottomStatusBar.tsx +│ │ │ +│ │ ├── dataImport/ +│ │ │ ├── DropZone.tsx +│ │ │ ├── DataPreviewTable.tsx +│ │ │ └── SheetSelector.tsx +│ │ │ +│ │ ├── charts/ +│ │ │ ├── ChartWrapper.tsx +│ │ │ ├── ChartRenderer.tsx +│ │ │ ├── KPICard.tsx +│ │ │ ├── EChartsBase.tsx +│ │ │ └── DataTable.tsx +│ │ │ +│ │ ├── configPanel/ +│ │ │ ├── DataBinding.tsx +│ │ │ ├── StyleConfig.tsx +│ │ │ ├── TitleConfig.tsx +│ │ │ ├── ColorConfig.tsx +│ │ │ ├── AxisConfig.tsx +│ │ │ ├── LegendConfig.tsx +│ │ │ ├── LabelConfig.tsx +│ │ │ ├── SizeConfig.tsx +│ │ │ └── InteractionConfig.tsx +│ │ │ +│ │ ├── template/ +│ │ │ ├── TemplateGallery.tsx +│ │ │ ├── TemplateCard.tsx +│ │ │ └── TemplateSaveDialog.tsx +│ │ │ +│ │ └── export/ +│ │ └── ExportDialog.tsx +│ │ +│ ├── hooks/ # React Hooks(连接 Use Case 与组件) +│ │ ├── useImportData.ts # 调用 ImportDataUseCase +│ │ ├── useCreateChart.ts # 调用 CreateChartUseCase +│ │ ├── useChartConfig.ts # 调用 UpdateChartConfigUseCase +│ │ ├── useExport.ts # 调用 ExportUseCase +│ │ ├── useTemplate.ts # 调用 TemplateUseCase +│ │ └── useLayout.ts # 调用 LayoutUseCase +│ │ +│ └── di/ # 依赖注入容器 +│ └── container.ts # 组装所有端口实现 +│ +├── assets/ +│ ├── geo/ # 中国省市 GeoJSON +│ └── sample/ # 示例数据集 +│ +├── next.config.ts +├── tsconfig.json +└── package.json +``` + +## 三、依赖方向图 + +``` +frameworks/ adapters/ application/ domain/ +(Next.js+React) (实现层) (用例层) (领域层) + + components ──→ hooks ──→ usecases ──→ entities + │ │ │ + │ │ valueObjects + │ │ │ + │ │ services + │ │ │ + │ ▼ │ + │ ports/input │ + │ ports/output ←────┘ + │ ▲ + │ │(实现) + ├──→ state/redux + ├──→ state/zustand + ├──→ gateways + └──→ presenters + +箭头 = import 方向,全部从外向内,绝不反向 +ports/output 定义接口在 application 层,实现在 adapters 层(依赖反转) +``` + +## 四、Zustand + Redux Toolkit 混合策略 + +``` +┌──────────────────────────────────────────────────────────┐ +│ Redux Toolkit(大厂模式) │ +│ │ +│ 管理:业务核心状态,需要以下特性的数据 │ +│ · 复杂的状态转换(reducer 逻辑多) │ +│ · 需要 middleware(如 thunk、日志) │ +│ · 多组件深层共享 + 可预测性要求高 │ +│ · DevTools 调试需求 │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌────────────┐ │ +│ │dataSlice │ │chartSlice│ │layoutSlice│ │templateSlice│ │ +│ │数据集CRUD │ │图表实例 │ │画布布局 │ │模板管理 │ │ +│ │多Sheet │ │绑定+样式 │ │网格位置 │ │保存/加载 │ │ +│ └──────────┘ └──────────┘ └───────────┘ └────────────┘ │ +└──────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────┐ +│ Zustand(轻量快速) │ +│ │ +│ 管理:UI 瞬态,不进 Redux 以避免频繁 dispatch 开销 │ +│ · 高频变化(拖拽中坐标、hover 状态) │ +│ · 局部 UI 状态(面板折叠、Tab 选中) │ +│ · 无需 DevTools 追踪的状态 │ +│ │ +│ ┌──────────┐ ┌────────────┐ ┌──────────────────┐ │ +│ │ uiStore │ │ themeStore │ │ interactionStore │ │ +│ │面板开关 │ │亮/暗主题 │ │hover/选中/拖拽中 │ │ +│ │活跃Tab │ │当前色板 │ │缩放级别 │ │ +│ │Modal状态 │ │ │ │ │ │ +│ └──────────┘ └────────────┘ └──────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +### 划分原则 + +| 判断标准 | → Redux Toolkit | → Zustand | +|----------|----------------|-----------| +| 影响图表输出? | 是 | 否 | +| 需要持久化/撤销? | 是 | 否 | +| 变化频率? | 低~中 | 高 | +| 多组件共享? | 3+ 组件 | 1~2 组件 | +| 需要 DevTools? | 是 | 否 | + +## 五、依赖注入(DI) + +```typescript +// frameworks/di/container.ts +// 组装所有端口实现,向上提供给 hooks + +import { ImportDataUseCase } from '@/application/usecases/ImportDataUseCase'; +import { SheetJSFileParser } from '@/adapters/gateways/SheetJSFileParser'; +import { LocalStorageGateway } from '@/adapters/gateways/LocalStorageGateway'; +// ... + +// 实例化 output port 实现 +const fileParser = new SheetJSFileParser(); +const storageGateway = new LocalStorageGateway(); +const chartRenderer = new EChartsOptionBuilder(); +const exportGateway = new CompositeExportGateway(/* ... */); +const geoProvider = new GeoJsonProvider(); + +// 注入到 use case +export const importDataUseCase = new ImportDataUseCase(fileParser); +export const createChartUseCase = new CreateChartUseCase(chartRenderer); +export const updateChartConfigUseCase = new UpdateChartConfigUseCase(chartRenderer); +export const exportUseCase = new ExportUseCase(exportGateway); +export const templateUseCase = new TemplateUseCase(storageGateway); +export const layoutUseCase = new LayoutUseCase(); +``` + +```typescript +// frameworks/hooks/useImportData.ts +// Hook 只做:调 use case → 写 store → 返回状态 + +import { importDataUseCase } from '@/frameworks/di/container'; +import { useAppDispatch } from '@/adapters/state/redux/store'; +import { addDataSet } from '@/adapters/state/redux/dataSlice'; + +export function useImportData() { + const dispatch = useAppDispatch(); + + const handleImport = async (file: File) => { + const result = await importDataUseCase.execute(file); // 纯业务 + dispatch(addDataSet(result.dataSet)); // 写状态 + return result.suggestions; // 返回推荐 + }; + + return { handleImport }; +} +``` + +## 六、Use Case 调用链路示例 + +### 场景:用户拖入一个 Excel 文件 + +``` +DropZone.tsx (UI) + │ onDrop(file) + ▼ +useImportData hook (frameworks/hooks) + │ 调用 importDataUseCase.execute(file) + ▼ +ImportDataUseCase (application/usecases) + │ ① 调用 IFileParser.parse(file) ←── 输出端口 + │ ② 调用 FieldTypeInferenceService ←── 领域服务 + │ ③ 调用 DataStructureInferenceService ←── 领域服务 + │ ④ 调用 ChartRecommendationService ←── 领域服务 + │ ⑤ 返回 { dataSet, suggestions } + ▼ +SheetJSFileParser (adapters/gateways) ←── 端口实现 + │ SheetJS 解析 Excel → 二维数组 → DataSet Entity + ▼ +回到 hook + │ dispatch(addDataSet(dataSet)) ←── Redux + │ 返回 suggestions 给组件 + ▼ +LeftPanel.tsx 更新字段列表 +CenterCanvas.tsx 可选推荐图表 +``` + +## 七、核心类型定义 + +```typescript +// ===== domain/entities ===== + +type FieldType = 'number' | 'text' | 'date' | 'percentage' | 'geo'; + +interface Column { + name: string; // 列名 + type: FieldType; // 推断出的类型 + sampleValues: any[]; // 前 10 个样本值 +} + +interface DataSet { + id: string; + fileName: string; + sheetName?: string; + columns: Column[]; + rows: Record[]; // 每行是 { 列名: 值 } +} + +type ChartType = + | 'kpi' | 'bar' | 'grouped-bar' + | 'stacked-bar' | 'horizontal-bar' + | 'line' | 'area' | 'pie' + | 'donut' | 'scatter' | 'radar' + | 'wordcloud' | 'boston-matrix' + | 'heatmap' | 'map' | 'combo' + | 'data-table'; + +interface FieldBinding { + axis: 'x' | 'y' | 'series' | 'color' | 'size' | 'label' | 'value'; + columnName: string; + aggregation?: 'sum' | 'count' | 'avg' | 'max' | 'min'; +} + +interface StyleConfig { + title: { text: string; fontSize: number; color: string; position: string }; + colors: string[]; // 色板 + legend: { show: boolean; position: string; orient: string }; + axis: { + xLabel: string; yLabel: string; + showGrid: boolean; labelRotate: number; + }; + label: { show: boolean; format: 'value' | 'percent' | 'custom'; template?: string }; + background: { color: string; opacity: number }; + border: { color: string; width: number; radius: number }; + animation: boolean; +} + +interface ChartInstance { + id: string; + type: ChartType; + dataSetId: string; + bindings: FieldBinding[]; + style: StyleConfig; + filters?: FilterRule[]; + sort?: { column: string; order: 'asc' | 'desc' }; + topN?: number; +} + +// ===== layout ===== + +interface LayoutItem { + chartId: string; + x: number; y: number; + w: number; h: number; +} + +// ===== template ===== + +interface Template { + id: string; + name: string; + description: string; + charts: Omit[]; + layout: LayoutItem[]; + theme: string; + createdAt: string; +} +``` + +## 八、各 Store 职责 + +| Store | 状态 | 核心操作 | +|-------|------|---------| +| **dataSlice** (Redux) | dataSets[], activeDataSetId | addDataSet, removeDataSet, updateCell | +| **chartSlice** (Redux) | charts[], activeChartId | addChart, removeChart, updateBinding, updateStyle | +| **layoutSlice** (Redux) | layouts[], canvasSize, snapToGrid | updateLayout, resizeCanvas | +| **templateSlice** (Redux) | templates[] | saveTemplate, loadTemplate, deleteTemplate, exportTemplate, importTemplate | +| **uiStore** (Zustand) | panelStates, activeTab, modalVisible | togglePanel, setActiveTab, openModal, closeModal | +| **themeStore** (Zustand) | currentTheme, customPalettes | setTheme, addPalette | +| **interactionStore** (Zustand) | hoveredChartId, dragState, zoomLevel | setHover, setDrag, setZoom | + +## 九、Next.js 使用策略 + +| 特性 | 用法 | +|------|------| +| App Router | 当前只有一个页面 `/`,但架构预留多页扩展 | +| Server Component | `layout.tsx` 作为 Server Component 提供外壳 | +| Client Component | 所有交互组件标记 `'use client'` | +| Static Export | `next.config.ts` 配置 `output: 'export'`,构建为纯静态文件 | +| 路由预留 | 未来可扩展 `/templates`、`/help` 等页面 | + +## 十、技术栈总览 + +| 层面 | 选型 | +|------|------| +| 框架 | Next.js (App Router) + React 18 + TypeScript | +| 构建 | Next.js 内置 (Turbopack) | +| 图表引擎 | ECharts 5 | +| 词云 | echarts-wordcloud | +| 表格 | AG Grid Community | +| 文件解析 | SheetJS (xlsx/csv) + 原生 JSON.parse | +| 拖拽布局 | react-grid-layout | +| 导出 PNG/SVG | ECharts 内置 + html2canvas | +| 导出 PDF | jsPDF + html2canvas | +| 导出 PPT | PptxGenJS | +| 导出 Excel | SheetJS | +| 导出 HTML | 内联打包 ECharts + 数据 | +| 状态管理 | Redux Toolkit (业务状态) + Zustand (UI 状态) | +| UI 组件 | Ant Design 5 | +| 地图数据 | 中国省市 GeoJSON | +| 本地存储 | localStorage (模板/配置持久化) |