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) <noreply@anthropic.com>
This commit is contained in:
commit
6e3127e7d6
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# DataViz Pro
|
||||
|
||||
Data visualization platform for statistical analysis and chart rendering.
|
||||
|
|
@ -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:
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
CREATE DATABASE data_db;
|
||||
CREATE DATABASE chart_db;
|
||||
CREATE DATABASE template_db;
|
||||
CREATE DATABASE export_db;
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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", [])
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)"}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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}},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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}
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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}
|
||||
|
|
@ -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()"
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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: ...
|
||||
|
|
@ -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]: ...
|
||||
|
|
@ -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]: ...
|
||||
|
|
@ -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: ...
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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]: ...
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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."""
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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."""
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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]: ...
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from src.domain.value_objects.chart_type import ChartType
|
||||
|
||||
__all__ = ["ChartType"]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from shared.types import ChartType
|
||||
|
||||
__all__ = ["ChartType"]
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .dataset_response import ColumnInfo, DataSetResponse
|
||||
from .import_result import ImportResult
|
||||
|
||||
__all__ = ["ColumnInfo", "DataSetResponse", "ImportResult"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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] = []
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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: ...
|
||||
|
|
@ -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]: ...
|
||||
|
|
@ -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]: ...
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .file_parser import IFileParser, ParsedSheet
|
||||
|
||||
__all__ = ["IFileParser", "ParsedSheet"]
|
||||
|
|
@ -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]: ...
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue