e-data 2.0.1.dev132__tar.gz → 2.0.2.dev134__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {e_data-2.0.1.dev132/e_data.egg-info → e_data-2.0.2.dev134}/PKG-INFO +1 -1
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134/e_data.egg-info}/PKG-INFO +1 -1
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/e_data.egg-info/SOURCES.txt +4 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/cli.py +33 -0
- e_data-2.0.2.dev134/edata/database/migrations/__init__.py +36 -0
- e_data-2.0.2.dev134/edata/database/migrations/base.py +19 -0
- e_data-2.0.2.dev134/edata/database/migrations/legacy_json_1_3_3.py +134 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/providers/datadis.py +1 -1
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/services/data_service.py +23 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/tests/test_datadis_connector.py +10 -0
- e_data-2.0.2.dev134/edata/tests/test_migrations.py +185 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/pyproject.toml +1 -1
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/LICENSE +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/MANIFEST.in +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/README.md +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/e_data.egg-info/dependency_links.txt +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/e_data.egg-info/requires.txt +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/e_data.egg-info/top_level.txt +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/__init__.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/core/__init__.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/core/completion.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/core/const.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/core/utils.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/database/__init__.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/database/controller.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/database/models.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/database/queries.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/database/utils.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/models/__init__.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/models/bill.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/models/data.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/models/supply.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/providers/__init__.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/providers/redata.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/services/bill_service.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/tests/__init__.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/tests/test_completion.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/tests/test_incremental.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/tests/test_redata_connector.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/tests/test_services.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/edata/tests/test_utils.py +0 -0
- {e_data-2.0.1.dev132 → e_data-2.0.2.dev134}/setup.cfg +0 -0
|
@@ -18,6 +18,9 @@ edata/database/controller.py
|
|
|
18
18
|
edata/database/models.py
|
|
19
19
|
edata/database/queries.py
|
|
20
20
|
edata/database/utils.py
|
|
21
|
+
edata/database/migrations/__init__.py
|
|
22
|
+
edata/database/migrations/base.py
|
|
23
|
+
edata/database/migrations/legacy_json_1_3_3.py
|
|
21
24
|
edata/models/__init__.py
|
|
22
25
|
edata/models/bill.py
|
|
23
26
|
edata/models/data.py
|
|
@@ -31,6 +34,7 @@ edata/tests/__init__.py
|
|
|
31
34
|
edata/tests/test_completion.py
|
|
32
35
|
edata/tests/test_datadis_connector.py
|
|
33
36
|
edata/tests/test_incremental.py
|
|
37
|
+
edata/tests/test_migrations.py
|
|
34
38
|
edata/tests/test_redata_connector.py
|
|
35
39
|
edata/tests/test_services.py
|
|
36
40
|
edata/tests/test_utils.py
|
|
@@ -141,5 +141,38 @@ def update_pvpc_bill(
|
|
|
141
141
|
)
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
async def _migrate_legacy(
|
|
145
|
+
cups: str, storage_path: str, compile_statistics: bool
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Import a 1.3.3 JSON export into the 2.0 store (no datadis account needed)."""
|
|
148
|
+
|
|
149
|
+
service = DataService(cups, "", "", storage_path=storage_path)
|
|
150
|
+
results = await service.run_migrations(compile_statistics=compile_statistics)
|
|
151
|
+
if not results:
|
|
152
|
+
typer.echo("No legacy storage found to migrate.")
|
|
153
|
+
return
|
|
154
|
+
for result in results:
|
|
155
|
+
typer.echo(
|
|
156
|
+
f"{result.name}: {result.supplies} supplies, {result.contracts} contracts, "
|
|
157
|
+
f"{result.energy} energy, {result.power} power, {result.pvpc} pvpc"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command()
|
|
162
|
+
def migrate_legacy(
|
|
163
|
+
cups: Annotated[str, typer.Option(help="The identifier of the Supply")],
|
|
164
|
+
storage_path: Annotated[
|
|
165
|
+
str,
|
|
166
|
+
typer.Option(help="Root dir holding the legacy edata/ folder and edata.db"),
|
|
167
|
+
] = "./edata_cli",
|
|
168
|
+
compile_statistics: Annotated[
|
|
169
|
+
bool, typer.Option(help="Compile day/month statistics after import")
|
|
170
|
+
] = True,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Import a python-edata 1.3.3 JSON export into the 2.0 SQLite store."""
|
|
173
|
+
|
|
174
|
+
asyncio.run(_migrate_legacy(cups, storage_path, compile_statistics))
|
|
175
|
+
|
|
176
|
+
|
|
144
177
|
if __name__ == "__main__":
|
|
145
178
|
app()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Versioned data migrations for the edata SQLite store.
|
|
2
|
+
|
|
3
|
+
Each migration is a self-contained module exposing ``NAME`` and an async
|
|
4
|
+
``apply(db, storage_dir, cups)`` that returns a :class:`MigrationResult` (or
|
|
5
|
+
``None`` when it does not apply). ``run_migrations`` runs the ordered registry.
|
|
6
|
+
|
|
7
|
+
To retire a migration in a future version, delete its module and drop it from
|
|
8
|
+
``MIGRATIONS``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from edata.database.controller import EdataDB
|
|
14
|
+
from edata.database.migrations import legacy_json_1_3_3
|
|
15
|
+
from edata.database.migrations.base import MigrationResult
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Ordered registry of migrations to run.
|
|
20
|
+
MIGRATIONS = [legacy_json_1_3_3]
|
|
21
|
+
|
|
22
|
+
__all__ = ["MIGRATIONS", "MigrationResult", "run_migrations"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def run_migrations(
|
|
26
|
+
db: EdataDB, storage_dir: str, cups: str
|
|
27
|
+
) -> list[MigrationResult]:
|
|
28
|
+
"""Run every registered migration; return the results that applied."""
|
|
29
|
+
|
|
30
|
+
results: list[MigrationResult] = []
|
|
31
|
+
for migration in MIGRATIONS:
|
|
32
|
+
result = await migration.apply(db, storage_dir, cups)
|
|
33
|
+
if result is not None:
|
|
34
|
+
_LOGGER.info("Applied migration '%s'", result.name)
|
|
35
|
+
results.append(result)
|
|
36
|
+
return results
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared types for the migrations package.
|
|
2
|
+
|
|
3
|
+
Kept separate from ``__init__`` and from the individual migration modules so a
|
|
4
|
+
migration file can be deleted in a future version without breaking imports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class MigrationResult:
|
|
12
|
+
"""Summary of what a single migration wrote."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
supplies: int = 0
|
|
16
|
+
contracts: int = 0
|
|
17
|
+
energy: int = 0
|
|
18
|
+
power: int = 0
|
|
19
|
+
pvpc: int = 0
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Migration: import a python-edata 1.3.3 JSON storage export into the 2.0 DB.
|
|
2
|
+
|
|
3
|
+
1.x persisted everything as a single JSON file per supply at
|
|
4
|
+
``{storage_dir}/edata/edata_{cups}.json`` (ISO datetimes). 2.0 uses a SQLite
|
|
5
|
+
store. This migration imports the raw records only (supplies, contracts,
|
|
6
|
+
consumptions, maximeter, pvpc); the old ``*_sum`` aggregates are ignored because
|
|
7
|
+
2.0 recomputes daily/monthly statistics deterministically.
|
|
8
|
+
|
|
9
|
+
Self-contained on purpose: everything it needs lives here, so a future version
|
|
10
|
+
can drop this file (and its entry in the registry) once the upgrade window closes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from edata.database.controller import EdataDB
|
|
19
|
+
from edata.database.migrations.base import MigrationResult
|
|
20
|
+
from edata.models import Contract, Energy, Power, Supply
|
|
21
|
+
from edata.models.bill import EnergyPrice
|
|
22
|
+
|
|
23
|
+
_LOGGER = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
NAME = "legacy_json_1_3_3"
|
|
26
|
+
LEGACY_SUBDIR = "edata"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _legacy_path(storage_dir: str, cups: str) -> str:
|
|
30
|
+
"""Return the 1.3.3 storage file path for a supply."""
|
|
31
|
+
|
|
32
|
+
return os.path.join(storage_dir, LEGACY_SUBDIR, f"edata_{cups.lower()}.json")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_supply(d: dict) -> Supply:
|
|
36
|
+
return Supply(
|
|
37
|
+
cups=d["cups"],
|
|
38
|
+
date_start=datetime.fromisoformat(d["date_start"]),
|
|
39
|
+
date_end=datetime.fromisoformat(d["date_end"]),
|
|
40
|
+
address=d.get("address"),
|
|
41
|
+
postal_code=d.get("postal_code"),
|
|
42
|
+
province=d.get("province"),
|
|
43
|
+
municipality=d.get("municipality"),
|
|
44
|
+
distributor=d.get("distributor"),
|
|
45
|
+
point_type=d["pointType"],
|
|
46
|
+
distributor_code=d["distributorCode"],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _to_contract(d: dict) -> Contract:
|
|
51
|
+
power = [p for p in (d.get("power_p1"), d.get("power_p2")) if p is not None]
|
|
52
|
+
return Contract(
|
|
53
|
+
date_start=datetime.fromisoformat(d["date_start"]),
|
|
54
|
+
date_end=datetime.fromisoformat(d["date_end"]),
|
|
55
|
+
marketer=d["marketer"],
|
|
56
|
+
distributor_code=d["distributorCode"],
|
|
57
|
+
power=power,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _to_energy(d: dict) -> Energy:
|
|
62
|
+
return Energy(
|
|
63
|
+
datetime=datetime.fromisoformat(d["datetime"]),
|
|
64
|
+
delta_h=d["delta_h"],
|
|
65
|
+
consumption_kwh=d["value_kWh"],
|
|
66
|
+
surplus_kwh=d.get("surplus_kWh", 0),
|
|
67
|
+
real=d["real"],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _to_power(d: dict) -> Power:
|
|
72
|
+
return Power(
|
|
73
|
+
datetime=datetime.fromisoformat(d["datetime"]),
|
|
74
|
+
value_kw=d["value_kW"],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _to_pvpc(d: dict) -> EnergyPrice:
|
|
79
|
+
return EnergyPrice(
|
|
80
|
+
datetime=datetime.fromisoformat(d["datetime"]),
|
|
81
|
+
value_eur_kwh=d["value_eur_kWh"],
|
|
82
|
+
delta_h=d["delta_h"],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def apply(db: EdataDB, storage_dir: str, cups: str) -> MigrationResult | None:
|
|
87
|
+
"""Import a 1.3.3 JSON export. Returns None (no-op) if the file is absent."""
|
|
88
|
+
|
|
89
|
+
path = _legacy_path(storage_dir, cups)
|
|
90
|
+
if not os.path.isfile(path):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
_LOGGER.info("Migrating legacy 1.3.3 storage from %s", path)
|
|
94
|
+
with open(path, encoding="utf-8") as f:
|
|
95
|
+
raw = json.load(f)
|
|
96
|
+
|
|
97
|
+
result = MigrationResult(name=NAME)
|
|
98
|
+
|
|
99
|
+
supplies = [_to_supply(x) for x in raw.get("supplies", [])]
|
|
100
|
+
for supply in supplies:
|
|
101
|
+
await db.add_supply(supply)
|
|
102
|
+
result.supplies = len(supplies)
|
|
103
|
+
|
|
104
|
+
# Contracts/energy/power reference a supply by cups; take it from the imported
|
|
105
|
+
# supply record rather than the (lower-cased) filename id.
|
|
106
|
+
target_cups = supplies[0].cups if supplies else cups
|
|
107
|
+
|
|
108
|
+
contracts = [_to_contract(x) for x in raw.get("contracts", [])]
|
|
109
|
+
for contract in contracts:
|
|
110
|
+
await db.add_contract(target_cups, contract)
|
|
111
|
+
result.contracts = len(contracts)
|
|
112
|
+
|
|
113
|
+
energy = [_to_energy(x) for x in raw.get("consumptions", [])]
|
|
114
|
+
await db.add_energy_list(target_cups, energy)
|
|
115
|
+
result.energy = len(energy)
|
|
116
|
+
|
|
117
|
+
power = [_to_power(x) for x in raw.get("maximeter", [])]
|
|
118
|
+
await db.add_power_list(target_cups, power)
|
|
119
|
+
result.power = len(power)
|
|
120
|
+
|
|
121
|
+
pvpc = [_to_pvpc(x) for x in raw.get("pvpc", [])]
|
|
122
|
+
await db.add_pvpc_list(pvpc)
|
|
123
|
+
result.pvpc = len(pvpc)
|
|
124
|
+
|
|
125
|
+
_LOGGER.info(
|
|
126
|
+
"%s: imported %s supplies, %s contracts, %s energy, %s power, %s pvpc",
|
|
127
|
+
NAME,
|
|
128
|
+
result.supplies,
|
|
129
|
+
result.contracts,
|
|
130
|
+
result.energy,
|
|
131
|
+
result.power,
|
|
132
|
+
result.pvpc,
|
|
133
|
+
)
|
|
134
|
+
return result
|
|
@@ -19,6 +19,7 @@ from edata.core.utils import (
|
|
|
19
19
|
redacted_cups,
|
|
20
20
|
)
|
|
21
21
|
from edata.database.controller import EdataDB
|
|
22
|
+
from edata.database.migrations import MigrationResult, run_migrations
|
|
22
23
|
from edata.models import Contract, Energy, Power, Statistics, Supply
|
|
23
24
|
from edata.models.bill import EnergyPrice
|
|
24
25
|
from edata.providers import DatadisConnector, REDataConnector
|
|
@@ -55,6 +56,7 @@ class DataService:
|
|
|
55
56
|
)
|
|
56
57
|
self._authorized_nif = None
|
|
57
58
|
|
|
59
|
+
self._storage_path = storage_path
|
|
58
60
|
self.db = EdataDB(get_db_path(storage_path))
|
|
59
61
|
|
|
60
62
|
# data (in-memory cache)
|
|
@@ -317,6 +319,27 @@ class DataService:
|
|
|
317
319
|
_LOGGER.warning("%s unable to fetch pvpc prices", self._scups)
|
|
318
320
|
return False
|
|
319
321
|
|
|
322
|
+
async def run_migrations(
|
|
323
|
+
self, compile_statistics: bool = True
|
|
324
|
+
) -> list[MigrationResult]:
|
|
325
|
+
"""Run pending data migrations (e.g. import a legacy 1.3.3 JSON export).
|
|
326
|
+
|
|
327
|
+
Migrations import raw records only; when anything is imported and
|
|
328
|
+
``compile_statistics`` is set, the day/month statistics are compiled from
|
|
329
|
+
the freshly-imported energy so the history is queryable right away. Bills
|
|
330
|
+
are left to the normal billing flow (they need user rules).
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
results = await run_migrations(self.db, self._storage_path, self._cups)
|
|
334
|
+
if not results or not compile_statistics:
|
|
335
|
+
return results
|
|
336
|
+
|
|
337
|
+
supply = await self.get_supply()
|
|
338
|
+
last_energy_dt = await self._get_last_energy_dt()
|
|
339
|
+
if supply is not None and last_energy_dt is not None:
|
|
340
|
+
await self.update_statistics(supply.date_start, last_energy_dt)
|
|
341
|
+
return results
|
|
342
|
+
|
|
320
343
|
async def update_statistics(self, start: datetime, end: datetime) -> None:
|
|
321
344
|
"""Compile the statistics for a range plus any incomplete backlog."""
|
|
322
345
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for DatadisConnector (offline)."""
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
|
+
import os
|
|
4
5
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
5
6
|
|
|
6
7
|
from edata.providers.datadis import DatadisConnector
|
|
@@ -8,6 +9,15 @@ from edata.providers.datadis import DatadisConnector
|
|
|
8
9
|
MOCK_USERNAME = "USERNAME"
|
|
9
10
|
MOCK_PASSWORD = "PASSWORD"
|
|
10
11
|
|
|
12
|
+
|
|
13
|
+
def test_connector_uses_namespaced_cache_dir(tmp_path) -> None:
|
|
14
|
+
"""A storage_path connector creates the namespaced 'edata_cache' dir."""
|
|
15
|
+
connector = DatadisConnector(
|
|
16
|
+
MOCK_USERNAME, MOCK_PASSWORD, storage_path=str(tmp_path)
|
|
17
|
+
)
|
|
18
|
+
assert os.path.basename(connector._recent_cache_dir) == "edata_cache"
|
|
19
|
+
assert (tmp_path / "edata_cache").is_dir()
|
|
20
|
+
|
|
11
21
|
SUPPLIES_RESPONSE = {
|
|
12
22
|
"supplies": [
|
|
13
23
|
{
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Tests for the 1.3.3 -> 2.0 legacy JSON migration."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
import pytest_asyncio
|
|
12
|
+
|
|
13
|
+
from edata.database.controller import EdataDB
|
|
14
|
+
from edata.database.migrations import legacy_json_1_3_3 as legacy
|
|
15
|
+
from edata.services.data_service import DataService
|
|
16
|
+
|
|
17
|
+
ASSETS = os.path.join(os.path.dirname(__file__), "assets")
|
|
18
|
+
CUPS = "ESXXXXXXXXXXXXXXXXTEST"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _reset_singleton() -> None:
|
|
22
|
+
EdataDB._instance = None
|
|
23
|
+
EdataDB._engine = None
|
|
24
|
+
EdataDB._db_url = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _raw() -> dict:
|
|
28
|
+
with open(os.path.join(ASSETS, "legacy_1.3.3.json"), encoding="utf-8") as f:
|
|
29
|
+
return json.load(f)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _install_legacy_file(storage_dir: str, cups: str = CUPS) -> str:
|
|
33
|
+
"""Copy the asset into the 1.3.3 storage location for ``storage_dir``."""
|
|
34
|
+
dest = legacy._legacy_path(storage_dir, cups)
|
|
35
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
36
|
+
shutil.copy(os.path.join(ASSETS, "legacy_1.3.3.json"), dest)
|
|
37
|
+
return dest
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest_asyncio.fixture
|
|
41
|
+
async def data_service(tmp_path) -> AsyncIterator[DataService]:
|
|
42
|
+
"""A DataService on an isolated on-disk database, singleton reset around it."""
|
|
43
|
+
_reset_singleton()
|
|
44
|
+
with (
|
|
45
|
+
patch("edata.services.data_service.DatadisConnector"),
|
|
46
|
+
patch("edata.services.data_service.REDataConnector"),
|
|
47
|
+
):
|
|
48
|
+
service = DataService(CUPS, "user", "pwd", storage_path=str(tmp_path))
|
|
49
|
+
yield service
|
|
50
|
+
if EdataDB._engine is not None:
|
|
51
|
+
await EdataDB._engine.dispose()
|
|
52
|
+
_reset_singleton()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- pure mappers ---
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_to_supply_renames_fields() -> None:
|
|
59
|
+
"""Supply maps camelCase fields to the 2.0 snake_case model."""
|
|
60
|
+
supply = legacy._to_supply(_raw()["supplies"][0])
|
|
61
|
+
assert supply.cups == CUPS
|
|
62
|
+
assert supply.point_type == 5
|
|
63
|
+
assert supply.distributor_code == "2"
|
|
64
|
+
assert supply.date_start == datetime(2023, 1, 1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_to_contract_builds_power_list_dropping_none() -> None:
|
|
68
|
+
"""Contract power list is [p1, p2], dropping a None p2."""
|
|
69
|
+
contracts = [legacy._to_contract(c) for c in _raw()["contracts"]]
|
|
70
|
+
assert contracts[0].power == [4.6, 4.6]
|
|
71
|
+
assert contracts[0].distributor_code == "2"
|
|
72
|
+
# second contract has power_p2=None -> single-element power list
|
|
73
|
+
assert contracts[1].power == [3.3]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_to_energy_renames_and_defaults() -> None:
|
|
77
|
+
"""Energy renames value_kWh and defaults generation/selfconsumption."""
|
|
78
|
+
energy = legacy._to_energy(_raw()["consumptions"][2])
|
|
79
|
+
assert energy.consumption_kwh == 0.3
|
|
80
|
+
assert energy.surplus_kwh == 0.1
|
|
81
|
+
assert energy.generation_kwh == 0
|
|
82
|
+
assert energy.selfconsumption_kwh == 0
|
|
83
|
+
assert energy.real is True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_to_power_and_pvpc_rename() -> None:
|
|
87
|
+
"""Power and pvpc rename their value fields to snake_case."""
|
|
88
|
+
power = legacy._to_power(_raw()["maximeter"][0])
|
|
89
|
+
assert power.value_kw == 3.2
|
|
90
|
+
pvpc = legacy._to_pvpc(_raw()["pvpc"][0])
|
|
91
|
+
assert pvpc.value_eur_kwh == 0.12
|
|
92
|
+
assert pvpc.delta_h == 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --- migration apply ---
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.asyncio
|
|
99
|
+
async def test_apply_imports_raw_records_only(
|
|
100
|
+
data_service: DataService, tmp_path
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Import the raw records and ignores old *_sum aggregates."""
|
|
103
|
+
_install_legacy_file(str(tmp_path))
|
|
104
|
+
result = await legacy.apply(data_service.db, str(tmp_path), CUPS)
|
|
105
|
+
|
|
106
|
+
assert result is not None
|
|
107
|
+
assert result.name == "legacy_json_1_3_3"
|
|
108
|
+
assert (
|
|
109
|
+
result.supplies,
|
|
110
|
+
result.contracts,
|
|
111
|
+
result.energy,
|
|
112
|
+
result.power,
|
|
113
|
+
result.pvpc,
|
|
114
|
+
) == (1, 2, 5, 2, 2)
|
|
115
|
+
|
|
116
|
+
supply = await data_service.get_supply()
|
|
117
|
+
assert supply is not None and supply.cups == CUPS
|
|
118
|
+
assert len(await data_service.get_contracts()) == 2
|
|
119
|
+
assert len(await data_service.get_energy()) == 5
|
|
120
|
+
assert len(await data_service.get_power()) == 2
|
|
121
|
+
assert len(await data_service.get_pvpc()) == 2
|
|
122
|
+
|
|
123
|
+
# The old *_sum aggregates in the file must be ignored by the migration.
|
|
124
|
+
assert await data_service.get_statistics("day") == []
|
|
125
|
+
assert await data_service.get_statistics("month") == []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.asyncio
|
|
129
|
+
async def test_apply_missing_file_is_noop(data_service: DataService, tmp_path) -> None:
|
|
130
|
+
"""Return None and write nothing when no legacy file exists."""
|
|
131
|
+
result = await legacy.apply(data_service.db, str(tmp_path), CUPS)
|
|
132
|
+
assert result is None
|
|
133
|
+
assert await data_service.get_supply() is None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.mark.asyncio
|
|
137
|
+
async def test_apply_is_idempotent(data_service: DataService, tmp_path) -> None:
|
|
138
|
+
"""Re-running apply does not duplicate rows (upsert)."""
|
|
139
|
+
_install_legacy_file(str(tmp_path))
|
|
140
|
+
await legacy.apply(data_service.db, str(tmp_path), CUPS)
|
|
141
|
+
await legacy.apply(data_service.db, str(tmp_path), CUPS)
|
|
142
|
+
|
|
143
|
+
assert len(await data_service.get_energy()) == 5
|
|
144
|
+
assert len(await data_service.get_contracts()) == 2
|
|
145
|
+
assert len(await data_service.get_power()) == 2
|
|
146
|
+
assert len(await data_service.get_pvpc()) == 2
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- orchestrator (DataService.run_migrations) ---
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.asyncio
|
|
153
|
+
async def test_run_migrations_compiles_statistics(
|
|
154
|
+
data_service: DataService, tmp_path
|
|
155
|
+
) -> None:
|
|
156
|
+
"""run_migrations imports and compiles day/month statistics."""
|
|
157
|
+
_install_legacy_file(str(tmp_path))
|
|
158
|
+
results = await data_service.run_migrations(compile_statistics=True)
|
|
159
|
+
|
|
160
|
+
assert [r.name for r in results] == ["legacy_json_1_3_3"]
|
|
161
|
+
# Raw records imported and day/month statistics compiled immediately.
|
|
162
|
+
assert len(await data_service.get_energy()) == 5
|
|
163
|
+
assert len(await data_service.get_statistics("day")) > 0
|
|
164
|
+
assert len(await data_service.get_statistics("month")) > 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@pytest.mark.asyncio
|
|
168
|
+
async def test_run_migrations_can_skip_compilation(
|
|
169
|
+
data_service: DataService, tmp_path
|
|
170
|
+
) -> None:
|
|
171
|
+
"""compile_statistics=False imports raw records without statistics."""
|
|
172
|
+
_install_legacy_file(str(tmp_path))
|
|
173
|
+
results = await data_service.run_migrations(compile_statistics=False)
|
|
174
|
+
|
|
175
|
+
assert len(results) == 1
|
|
176
|
+
assert len(await data_service.get_energy()) == 5
|
|
177
|
+
assert await data_service.get_statistics("day") == []
|
|
178
|
+
assert await data_service.get_statistics("month") == []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@pytest.mark.asyncio
|
|
182
|
+
async def test_run_migrations_no_legacy_file(data_service: DataService) -> None:
|
|
183
|
+
"""run_migrations is a no-op when there is no legacy file."""
|
|
184
|
+
results = await data_service.run_migrations()
|
|
185
|
+
assert results == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|