qapu-cli 0.6.2__tar.gz → 0.6.3__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.
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/PKG-INFO +15 -4
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/README.md +14 -3
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/pyproject.toml +1 -1
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/qapu_cli/main.py +114 -0
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/qapu_cli.egg-info/PKG-INFO +15 -4
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/qapu_cli/__init__.py +0 -0
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/qapu_cli/client.py +0 -0
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/qapu_cli.egg-info/SOURCES.txt +0 -0
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/qapu_cli.egg-info/dependency_links.txt +0 -0
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/qapu_cli.egg-info/entry_points.txt +0 -0
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/qapu_cli.egg-info/requires.txt +0 -0
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/qapu_cli.egg-info/top_level.txt +0 -0
- {qapu_cli-0.6.2 → qapu_cli-0.6.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qapu-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm.
|
|
5
5
|
Author: OVOO Technology
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -182,7 +182,10 @@ qapu stream list <device_id> [--page N] [--size N] [--json] # GET /hermes/stre
|
|
|
182
182
|
qapu stream get <stream_id> # GET /hermes/stream/detail/{stream_id}
|
|
183
183
|
|
|
184
184
|
qapu synthesis list [--device-id] [--variable-id] [--active/--inactive] [--json] # GET /hermes/synthesis - derived-metric calculation rules
|
|
185
|
-
|
|
185
|
+
qapu synthesis get <rule_id> [--json] # GET /hermes/synthesis/{rule_id}
|
|
186
|
+
qapu synthesis add <variable_id> --required-variables A,B,C --calculation '{"formula": "A + B"}' [--device-id] [--conditions] [--min-value] [--max-value] [--active/--inactive] [--description] # POST /hermes/synthesis
|
|
187
|
+
qapu synthesis update <rule_id> [--variable-id] [--required-variables] [--calculation] [--device-id] [--conditions] [--min-value] [--max-value] [--active/--inactive] [--description] # PUT /hermes/synthesis/{rule_id}
|
|
188
|
+
qapu synthesis delete <rule_id> # DELETE /hermes/synthesis/{rule_id}
|
|
186
189
|
qapu user-role list [--json] [--limit N] # GET /hermes/user_roles - e.g. admin/farmer/technician, NOT the User class itself (PII, deliberately excluded)
|
|
187
190
|
qapu user-role get <role_id> [--json] # GET /hermes/user_roles/{id}
|
|
188
191
|
qapu user-role add <id> "Name" [--description] # POST /hermes/user_roles - ID is REQUIRED, this catalog's ID is NOT auto-increment
|
|
@@ -200,6 +203,16 @@ qapu project update <project_id> [--name] [--description] [--active/--inactive]
|
|
|
200
203
|
qapu project delete <project_id> # DELETE /hermes/projects/{project_id}
|
|
201
204
|
```
|
|
202
205
|
|
|
206
|
+
**`synthesis` extended to full CRUD (2026-09-03)**, per direct user correction after the read-only version shipped the day before ("qapu synthesis için tam crud yapmamışsın sadece list var"). The 2026-09-02 read-only version was a faithful reflection of the domain class at the time - `Synthesized_Metrics` genuinely had no `add()`/`update()`/`delete()`, only `get_list()`. Added `detail()`/`add()`/`update()`/`delete()` to `common/qapu_common/domain/synthesized_metrics.py` (following the same partial-update/cache-invalidation pattern every other domain class in this session uses), then wired all four into `services/api/src/routers/hermes.py` and the CLI. `--required-variables`/`--conditions` take comma-separated/JSON-string input respectively (`Required_Variables` is a plain list, `Conditions`/`Calculation` are JSON columns) - `--calculation`/`--conditions` are validated as JSON client-side before the request goes out, with a clear error instead of a confusing 400 from the API. New unit tests in `common/tests/test_synthesized_metrics.py` (detail/add/update/delete, 10 new cases) - 502 tests passing.
|
|
207
|
+
|
|
208
|
+
**Two real production findings surfaced while live-testing this, neither a bug in the new code**:
|
|
209
|
+
1. **`synthesis_variable_rules` is currently completely empty in production** - `qapu synthesis list` had been showing 12 rules the whole time, but those were a stale Redis cache (`synthesis:list`) with nothing backing it in the actual table (confirmed via a direct `SELECT count(*)` - zero rows - and via `Synthesized_Metrics().get_list(update=True)`, which forces a real DB read and also returns empty). Nothing in this session's own work emptied it - it was already this way, just never surfaced before since nothing had triggered a forced cache refresh. Not fixed here (there's nothing to "fix" code-wise - the table is legitimately empty right now) - flagged so the 12 previously-visible rules aren't mistaken for real, currently-active synthesis logic.
|
|
210
|
+
2. **A "global" rule (`Device_ID="0"`, applied to every device) cannot currently be added at all** - `synthesis_variable_rules.device_id` is a foreign key into `devices.id`, and there is no `id='0'` placeholder row in `devices` (unlike `projects`/`statuses`, which do have a real `id=0` "Unknown" row) - `POST /hermes/synthesis` with no `Device_ID` (which defaults to `"0"`) fails with a `ForeignKeyViolationError`, surfaced to the caller as a generic 500. Confirmed the fix isn't in this new code - adding a real device-scoped rule (an actual device ID that exists in `devices`) works correctly end to end. Not fixed here (adding a synthetic `id='0'` row to `devices`, a load-bearing table, wasn't done unilaterally) - every synthesis rule going forward needs a real, existing `Device_ID` until this is explicitly decided one way or the other.
|
|
211
|
+
|
|
212
|
+
Live-tested against real prod data (after the two findings above were understood): a full add→get→update(confirm)→delete→get(404) cycle using a real device ID (`1000000000000003`) and a real variable ID (`VRMS_A`, itself already in the `variables` catalog) - all steps confirmed via direct DB queries, table left empty afterward exactly as found.
|
|
213
|
+
|
|
214
|
+
**(From the original 2026-09-02 read-only version)** `synthesis list` supports `--device-id`/`--variable-id`/`--active`/`--inactive` filters. A real CLI bug was caught while first testing it: `Calculation` is a JSON object (e.g. `{"formula": "(VRMS_R + VRMS_S + VRMS_T) / 3"}`), not a string - the table crashed with `NotRenderableError: unable to render dict` until values were `json.dumps()`-ed before being handed to the table row.
|
|
215
|
+
|
|
203
216
|
**`project` (added 2026-09-02) is full read/write**, per an analyst-workflow review of the CLI ("Cinga ve diğer ürün/aile ayrımlarında proje bazlı gezinme lazım") - `Project` (`common/qapu_common/domain/project.py`) already had full CRUD (list/detail/add/update/delete) with zero Hermes/CLI wiring at all until now, same "remaining domain, wire it up" pattern as every other catalog this session. Same low-sensitivity reasoning as `group`/`manufacturer`/`calibration`: a plain project catalog (Cinga/WeatherStat/...), no device actuation, no financial/PII. `project devices <project_id>` returns full device detail (not just IDs, unlike `group devices`) via `Device.list(project_id=...)`, which already existed and already returns the full `Device_Detail_Schema` shape - more useful for an analyst than a bare ID list. Live-tested against real prod data: `project list` correctly showed the one real active project (`Cinga V1`, `id=1` - the `id=0` "Unknown" row is deliberately excluded by `Project.list()` itself); `project devices 1 --limit 3` correctly showed 3 real devices; a full add→get(confirm)→update(confirm)→delete→get(404) cycle on a disposable test project, the real `Cinga V1` row never touched.
|
|
204
217
|
|
|
205
218
|
```bash
|
|
@@ -247,8 +260,6 @@ These six catalogs (`manufacturer`/`model`/`modem`/`sim`/`firmware`/`status`) pl
|
|
|
247
260
|
|
|
248
261
|
**`stream get`'s detail was extended the same day**, per user request: `Command_ID` (a bare FK integer, not meaningful on its own) is now resolved and returned as `Command_Name` instead (via `qapu_common.domain.command.Command.detail()`); a new `Variable_Count` field reports how many variables `Stream.get_stream_measurements()` actually recorded from that packet - not previously visible from `stream get` at all. `Size` is now rendered as e.g. `418 byte` in both `stream get` and `stream list`'s table (the raw `--json` output stays a plain int, only the human-readable rendering changed). The Hermes endpoint's `response_model` was dropped to `None` for this route specifically, since the response shape now deliberately diverges from the underlying `Stream_Schema` (swapped field, added field) - every other Hermes route keeps its typed `response_model`, this is a one-off. Live-tested against real prod data: `stream get 4879335` correctly showed `Command_Name: Timed` and `Variable_Count: 18`.
|
|
249
262
|
|
|
250
|
-
**`synthesis` (added 2026-09-02) is read-only, `list` only** - `synthesis_variable_rules` (how a derived metric like `VRMS_A`/`AE_TOT`/power-factor-imbalance gets calculated from raw variables) has no `add()`/`update()`/`delete()` on the domain class at all, it's a small, directly-DB-managed rule set. `Device_ID='0'` rows are global (applied to every device); a real device ID scopes a rule to just that device. `synthesis list` supports `--device-id`/`--variable-id`/`--active`/`--inactive` filters, showing ID/Device/Variable/Priority/Required Variables/Calculation/Active in a table. **Real CLI bug found and fixed while testing this**: `Calculation` is a JSON object (the actual formula, e.g. `{"formula": "(VRMS_R + VRMS_S + VRMS_T) / 3"}`), not a string - the first version of this table crashed with `NotRenderableError: unable to render dict` the moment it hit a real row; fixed by `json.dumps()`-ing dict values before handing them to the table. Live-tested against real prod data after the fix: all 12 real global synthesis rules rendered correctly (readable formulas), `--active`/`--variable-id` filters both confirmed correct counts.
|
|
251
|
-
|
|
252
263
|
**`user-role` (added 2026-09-02) is the ONLY piece of the `user` domain wired up here, and deliberately so** - the user was asked explicitly first, since `user`/`finance` had been excluded from this router since it was first built (a leaked `QAPU_HERMES_KEY` - a shared secret, not real per-admin JWT - must never expose PII or financial data). Chose "read-only, PII-free fields only" as the safe subset: `User_Role` (`common/qapu_common/domain/user.py`) is a plain ID/Name/Description role catalog (admin/farmer/technician/...) with zero PII, structurally identical to `crop-type`/`pump-type`. The actual `User` class (name/phone/email) and `Authorization` (which user owns which device) are NOT wired up and were never considered - PII and device-ownership data stay off this router entirely, same as `finance`.
|
|
253
264
|
- **Real bug found and fixed while testing this**: `User_Role.add()` requires an explicit `ID` - unlike `Status` (which falls back to auto-increment when `ID` is omitted), `user_roles.id` is not an auto-increment column at all (`Column(Integer, primary_key=True, nullable=False)`, no `autoincrement`). The first CLI version only took `Name`/`--description`, so every `add` failed with a misleading `"User role Name is required"` (Name *was* provided - `ID` was the actual missing field). Fixed by making `id` a required positional argument on `user-role add` and clarifying the Hermes error message. Live-tested end to end against real prod data (10 real roles - admin/supervisor/technician/field_technician/accountant/dealer/farmer/farmer_employee/farmer_electrician/demo_user, confirmed zero PII, none touched): a full add(explicit ID `999`)→get→update→get(confirm)→delete→get(404) cycle on a disposable row.
|
|
254
265
|
|
|
@@ -170,7 +170,10 @@ qapu stream list <device_id> [--page N] [--size N] [--json] # GET /hermes/stre
|
|
|
170
170
|
qapu stream get <stream_id> # GET /hermes/stream/detail/{stream_id}
|
|
171
171
|
|
|
172
172
|
qapu synthesis list [--device-id] [--variable-id] [--active/--inactive] [--json] # GET /hermes/synthesis - derived-metric calculation rules
|
|
173
|
-
|
|
173
|
+
qapu synthesis get <rule_id> [--json] # GET /hermes/synthesis/{rule_id}
|
|
174
|
+
qapu synthesis add <variable_id> --required-variables A,B,C --calculation '{"formula": "A + B"}' [--device-id] [--conditions] [--min-value] [--max-value] [--active/--inactive] [--description] # POST /hermes/synthesis
|
|
175
|
+
qapu synthesis update <rule_id> [--variable-id] [--required-variables] [--calculation] [--device-id] [--conditions] [--min-value] [--max-value] [--active/--inactive] [--description] # PUT /hermes/synthesis/{rule_id}
|
|
176
|
+
qapu synthesis delete <rule_id> # DELETE /hermes/synthesis/{rule_id}
|
|
174
177
|
qapu user-role list [--json] [--limit N] # GET /hermes/user_roles - e.g. admin/farmer/technician, NOT the User class itself (PII, deliberately excluded)
|
|
175
178
|
qapu user-role get <role_id> [--json] # GET /hermes/user_roles/{id}
|
|
176
179
|
qapu user-role add <id> "Name" [--description] # POST /hermes/user_roles - ID is REQUIRED, this catalog's ID is NOT auto-increment
|
|
@@ -188,6 +191,16 @@ qapu project update <project_id> [--name] [--description] [--active/--inactive]
|
|
|
188
191
|
qapu project delete <project_id> # DELETE /hermes/projects/{project_id}
|
|
189
192
|
```
|
|
190
193
|
|
|
194
|
+
**`synthesis` extended to full CRUD (2026-09-03)**, per direct user correction after the read-only version shipped the day before ("qapu synthesis için tam crud yapmamışsın sadece list var"). The 2026-09-02 read-only version was a faithful reflection of the domain class at the time - `Synthesized_Metrics` genuinely had no `add()`/`update()`/`delete()`, only `get_list()`. Added `detail()`/`add()`/`update()`/`delete()` to `common/qapu_common/domain/synthesized_metrics.py` (following the same partial-update/cache-invalidation pattern every other domain class in this session uses), then wired all four into `services/api/src/routers/hermes.py` and the CLI. `--required-variables`/`--conditions` take comma-separated/JSON-string input respectively (`Required_Variables` is a plain list, `Conditions`/`Calculation` are JSON columns) - `--calculation`/`--conditions` are validated as JSON client-side before the request goes out, with a clear error instead of a confusing 400 from the API. New unit tests in `common/tests/test_synthesized_metrics.py` (detail/add/update/delete, 10 new cases) - 502 tests passing.
|
|
195
|
+
|
|
196
|
+
**Two real production findings surfaced while live-testing this, neither a bug in the new code**:
|
|
197
|
+
1. **`synthesis_variable_rules` is currently completely empty in production** - `qapu synthesis list` had been showing 12 rules the whole time, but those were a stale Redis cache (`synthesis:list`) with nothing backing it in the actual table (confirmed via a direct `SELECT count(*)` - zero rows - and via `Synthesized_Metrics().get_list(update=True)`, which forces a real DB read and also returns empty). Nothing in this session's own work emptied it - it was already this way, just never surfaced before since nothing had triggered a forced cache refresh. Not fixed here (there's nothing to "fix" code-wise - the table is legitimately empty right now) - flagged so the 12 previously-visible rules aren't mistaken for real, currently-active synthesis logic.
|
|
198
|
+
2. **A "global" rule (`Device_ID="0"`, applied to every device) cannot currently be added at all** - `synthesis_variable_rules.device_id` is a foreign key into `devices.id`, and there is no `id='0'` placeholder row in `devices` (unlike `projects`/`statuses`, which do have a real `id=0` "Unknown" row) - `POST /hermes/synthesis` with no `Device_ID` (which defaults to `"0"`) fails with a `ForeignKeyViolationError`, surfaced to the caller as a generic 500. Confirmed the fix isn't in this new code - adding a real device-scoped rule (an actual device ID that exists in `devices`) works correctly end to end. Not fixed here (adding a synthetic `id='0'` row to `devices`, a load-bearing table, wasn't done unilaterally) - every synthesis rule going forward needs a real, existing `Device_ID` until this is explicitly decided one way or the other.
|
|
199
|
+
|
|
200
|
+
Live-tested against real prod data (after the two findings above were understood): a full add→get→update(confirm)→delete→get(404) cycle using a real device ID (`1000000000000003`) and a real variable ID (`VRMS_A`, itself already in the `variables` catalog) - all steps confirmed via direct DB queries, table left empty afterward exactly as found.
|
|
201
|
+
|
|
202
|
+
**(From the original 2026-09-02 read-only version)** `synthesis list` supports `--device-id`/`--variable-id`/`--active`/`--inactive` filters. A real CLI bug was caught while first testing it: `Calculation` is a JSON object (e.g. `{"formula": "(VRMS_R + VRMS_S + VRMS_T) / 3"}`), not a string - the table crashed with `NotRenderableError: unable to render dict` until values were `json.dumps()`-ed before being handed to the table row.
|
|
203
|
+
|
|
191
204
|
**`project` (added 2026-09-02) is full read/write**, per an analyst-workflow review of the CLI ("Cinga ve diğer ürün/aile ayrımlarında proje bazlı gezinme lazım") - `Project` (`common/qapu_common/domain/project.py`) already had full CRUD (list/detail/add/update/delete) with zero Hermes/CLI wiring at all until now, same "remaining domain, wire it up" pattern as every other catalog this session. Same low-sensitivity reasoning as `group`/`manufacturer`/`calibration`: a plain project catalog (Cinga/WeatherStat/...), no device actuation, no financial/PII. `project devices <project_id>` returns full device detail (not just IDs, unlike `group devices`) via `Device.list(project_id=...)`, which already existed and already returns the full `Device_Detail_Schema` shape - more useful for an analyst than a bare ID list. Live-tested against real prod data: `project list` correctly showed the one real active project (`Cinga V1`, `id=1` - the `id=0` "Unknown" row is deliberately excluded by `Project.list()` itself); `project devices 1 --limit 3` correctly showed 3 real devices; a full add→get(confirm)→update(confirm)→delete→get(404) cycle on a disposable test project, the real `Cinga V1` row never touched.
|
|
192
205
|
|
|
193
206
|
```bash
|
|
@@ -235,8 +248,6 @@ These six catalogs (`manufacturer`/`model`/`modem`/`sim`/`firmware`/`status`) pl
|
|
|
235
248
|
|
|
236
249
|
**`stream get`'s detail was extended the same day**, per user request: `Command_ID` (a bare FK integer, not meaningful on its own) is now resolved and returned as `Command_Name` instead (via `qapu_common.domain.command.Command.detail()`); a new `Variable_Count` field reports how many variables `Stream.get_stream_measurements()` actually recorded from that packet - not previously visible from `stream get` at all. `Size` is now rendered as e.g. `418 byte` in both `stream get` and `stream list`'s table (the raw `--json` output stays a plain int, only the human-readable rendering changed). The Hermes endpoint's `response_model` was dropped to `None` for this route specifically, since the response shape now deliberately diverges from the underlying `Stream_Schema` (swapped field, added field) - every other Hermes route keeps its typed `response_model`, this is a one-off. Live-tested against real prod data: `stream get 4879335` correctly showed `Command_Name: Timed` and `Variable_Count: 18`.
|
|
237
250
|
|
|
238
|
-
**`synthesis` (added 2026-09-02) is read-only, `list` only** - `synthesis_variable_rules` (how a derived metric like `VRMS_A`/`AE_TOT`/power-factor-imbalance gets calculated from raw variables) has no `add()`/`update()`/`delete()` on the domain class at all, it's a small, directly-DB-managed rule set. `Device_ID='0'` rows are global (applied to every device); a real device ID scopes a rule to just that device. `synthesis list` supports `--device-id`/`--variable-id`/`--active`/`--inactive` filters, showing ID/Device/Variable/Priority/Required Variables/Calculation/Active in a table. **Real CLI bug found and fixed while testing this**: `Calculation` is a JSON object (the actual formula, e.g. `{"formula": "(VRMS_R + VRMS_S + VRMS_T) / 3"}`), not a string - the first version of this table crashed with `NotRenderableError: unable to render dict` the moment it hit a real row; fixed by `json.dumps()`-ing dict values before handing them to the table. Live-tested against real prod data after the fix: all 12 real global synthesis rules rendered correctly (readable formulas), `--active`/`--variable-id` filters both confirmed correct counts.
|
|
239
|
-
|
|
240
251
|
**`user-role` (added 2026-09-02) is the ONLY piece of the `user` domain wired up here, and deliberately so** - the user was asked explicitly first, since `user`/`finance` had been excluded from this router since it was first built (a leaked `QAPU_HERMES_KEY` - a shared secret, not real per-admin JWT - must never expose PII or financial data). Chose "read-only, PII-free fields only" as the safe subset: `User_Role` (`common/qapu_common/domain/user.py`) is a plain ID/Name/Description role catalog (admin/farmer/technician/...) with zero PII, structurally identical to `crop-type`/`pump-type`. The actual `User` class (name/phone/email) and `Authorization` (which user owns which device) are NOT wired up and were never considered - PII and device-ownership data stay off this router entirely, same as `finance`.
|
|
241
252
|
- **Real bug found and fixed while testing this**: `User_Role.add()` requires an explicit `ID` - unlike `Status` (which falls back to auto-increment when `ID` is omitted), `user_roles.id` is not an auto-increment column at all (`Column(Integer, primary_key=True, nullable=False)`, no `autoincrement`). The first CLI version only took `Name`/`--description`, so every `add` failed with a misleading `"User role Name is required"` (Name *was* provided - `ID` was the actual missing field). Fixed by making `id` a required positional argument on `user-role add` and clarifying the Hermes error message. Live-tested end to end against real prod data (10 real roles - admin/supervisor/technician/field_technician/accountant/dealer/farmer/farmer_employee/farmer_electrician/demo_user, confirmed zero PII, none touched): a full add(explicit ID `999`)→get→update→get(confirm)→delete→get(404) cycle on a disposable row.
|
|
242
253
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "qapu-cli"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.3"
|
|
4
4
|
description = "CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -2689,6 +2689,120 @@ def synthesis_list(
|
|
|
2689
2689
|
console.print(table)
|
|
2690
2690
|
|
|
2691
2691
|
|
|
2692
|
+
# Get One Synthesis Rule's Detail
|
|
2693
|
+
@synthesis_app.command("get")
|
|
2694
|
+
def synthesis_get(rule_id: int = typer.Argument(...), as_json: bool = typer.Option(False, "--json")):
|
|
2695
|
+
"""Get one synthesis rule's detail (GET /hermes/synthesis/{rule_id})."""
|
|
2696
|
+
result = client.get(f"/hermes/synthesis/{rule_id}")
|
|
2697
|
+
if as_json:
|
|
2698
|
+
typer.echo(json.dumps(result, indent=2))
|
|
2699
|
+
return
|
|
2700
|
+
_print_detail(result)
|
|
2701
|
+
|
|
2702
|
+
|
|
2703
|
+
# Add a New Synthesis Rule
|
|
2704
|
+
@synthesis_app.command("add")
|
|
2705
|
+
def synthesis_add(
|
|
2706
|
+
variable_id: str = typer.Argument(..., help="Variable ID this rule calculates, e.g. 'P_TOT'."),
|
|
2707
|
+
required_variables: str = typer.Option(..., "--required-variables", help="Comma-separated list of variables the formula needs, e.g. P_R,P_S,P_T."),
|
|
2708
|
+
calculation: str = typer.Option(..., "--calculation", help='Calculation formula as JSON, e.g. \'{"formula": "P_R + P_S"}\'.'),
|
|
2709
|
+
device_id: Optional[str] = typer.Option(None, "--device-id", help="Scope to one device instead of every device (default: global, Device_ID='0')."),
|
|
2710
|
+
conditions: Optional[str] = typer.Option(None, "--conditions", help='Conditions as a JSON list, e.g. \'[{"Variable":"X","Condition":">","Value":5}]\'.'),
|
|
2711
|
+
min_value: Optional[float] = typer.Option(None, "--min-value"),
|
|
2712
|
+
max_value: Optional[float] = typer.Option(None, "--max-value"),
|
|
2713
|
+
active: bool = typer.Option(False, "--active/--inactive"),
|
|
2714
|
+
description: Optional[str] = typer.Option(None, "--description"),
|
|
2715
|
+
):
|
|
2716
|
+
"""Create a new synthesis rule (POST /hermes/synthesis)."""
|
|
2717
|
+
try:
|
|
2718
|
+
calculation_data = json.loads(calculation)
|
|
2719
|
+
except json.JSONDecodeError:
|
|
2720
|
+
typer.echo("Error: --calculation must be valid JSON.", err=True)
|
|
2721
|
+
raise typer.Exit(code=1)
|
|
2722
|
+
conditions_data = None
|
|
2723
|
+
if conditions is not None:
|
|
2724
|
+
try:
|
|
2725
|
+
conditions_data = json.loads(conditions)
|
|
2726
|
+
except json.JSONDecodeError:
|
|
2727
|
+
typer.echo("Error: --conditions must be valid JSON.", err=True)
|
|
2728
|
+
raise typer.Exit(code=1)
|
|
2729
|
+
body: dict[str, Any] = {
|
|
2730
|
+
"Variable_ID": variable_id,
|
|
2731
|
+
"Required_Variables": [v.strip() for v in required_variables.split(",") if v.strip()],
|
|
2732
|
+
"Calculation": calculation_data,
|
|
2733
|
+
"Status": active,
|
|
2734
|
+
}
|
|
2735
|
+
if device_id is not None:
|
|
2736
|
+
body["Device_ID"] = device_id
|
|
2737
|
+
if conditions_data is not None:
|
|
2738
|
+
body["Conditions"] = conditions_data
|
|
2739
|
+
if min_value is not None:
|
|
2740
|
+
body["Minimum_Value"] = min_value
|
|
2741
|
+
if max_value is not None:
|
|
2742
|
+
body["Maximum_Value"] = max_value
|
|
2743
|
+
if description is not None:
|
|
2744
|
+
body["Description"] = description
|
|
2745
|
+
result = client.post("/hermes/synthesis", json_body=body)
|
|
2746
|
+
typer.echo(result.get("Message", result))
|
|
2747
|
+
|
|
2748
|
+
|
|
2749
|
+
# Update an Existing Synthesis Rule
|
|
2750
|
+
@synthesis_app.command("update")
|
|
2751
|
+
def synthesis_update(
|
|
2752
|
+
rule_id: int = typer.Argument(...),
|
|
2753
|
+
variable_id: Optional[str] = typer.Option(None, "--variable-id"),
|
|
2754
|
+
required_variables: Optional[str] = typer.Option(None, "--required-variables", help="Comma-separated list, replaces the existing list entirely."),
|
|
2755
|
+
calculation: Optional[str] = typer.Option(None, "--calculation", help='Calculation formula as JSON, e.g. \'{"formula": "P_R + P_S"}\'.'),
|
|
2756
|
+
device_id: Optional[str] = typer.Option(None, "--device-id"),
|
|
2757
|
+
conditions: Optional[str] = typer.Option(None, "--conditions", help="Conditions as a JSON list, replaces the existing list entirely."),
|
|
2758
|
+
min_value: Optional[float] = typer.Option(None, "--min-value"),
|
|
2759
|
+
max_value: Optional[float] = typer.Option(None, "--max-value"),
|
|
2760
|
+
active: Optional[bool] = typer.Option(None, "--active/--inactive"),
|
|
2761
|
+
description: Optional[str] = typer.Option(None, "--description"),
|
|
2762
|
+
):
|
|
2763
|
+
"""Update an existing synthesis rule - only the fields passed are changed (PUT /hermes/synthesis/{rule_id})."""
|
|
2764
|
+
body: dict[str, Any] = {}
|
|
2765
|
+
if variable_id is not None:
|
|
2766
|
+
body["Variable_ID"] = variable_id
|
|
2767
|
+
if required_variables is not None:
|
|
2768
|
+
body["Required_Variables"] = [v.strip() for v in required_variables.split(",") if v.strip()]
|
|
2769
|
+
if calculation is not None:
|
|
2770
|
+
try:
|
|
2771
|
+
body["Calculation"] = json.loads(calculation)
|
|
2772
|
+
except json.JSONDecodeError:
|
|
2773
|
+
typer.echo("Error: --calculation must be valid JSON.", err=True)
|
|
2774
|
+
raise typer.Exit(code=1)
|
|
2775
|
+
if device_id is not None:
|
|
2776
|
+
body["Device_ID"] = device_id
|
|
2777
|
+
if conditions is not None:
|
|
2778
|
+
try:
|
|
2779
|
+
body["Conditions"] = json.loads(conditions)
|
|
2780
|
+
except json.JSONDecodeError:
|
|
2781
|
+
typer.echo("Error: --conditions must be valid JSON.", err=True)
|
|
2782
|
+
raise typer.Exit(code=1)
|
|
2783
|
+
if min_value is not None:
|
|
2784
|
+
body["Minimum_Value"] = min_value
|
|
2785
|
+
if max_value is not None:
|
|
2786
|
+
body["Maximum_Value"] = max_value
|
|
2787
|
+
if active is not None:
|
|
2788
|
+
body["Status"] = active
|
|
2789
|
+
if description is not None:
|
|
2790
|
+
body["Description"] = description
|
|
2791
|
+
if not body:
|
|
2792
|
+
typer.echo("Error: nothing to update - pass at least one field.", err=True)
|
|
2793
|
+
raise typer.Exit(code=1)
|
|
2794
|
+
result = client.put(f"/hermes/synthesis/{rule_id}", json_body=body)
|
|
2795
|
+
typer.echo(result.get("Message", result))
|
|
2796
|
+
|
|
2797
|
+
|
|
2798
|
+
# Delete an Existing Synthesis Rule
|
|
2799
|
+
@synthesis_app.command("delete")
|
|
2800
|
+
def synthesis_delete(rule_id: int = typer.Argument(...)):
|
|
2801
|
+
"""Delete an existing synthesis rule (DELETE /hermes/synthesis/{rule_id})."""
|
|
2802
|
+
result = client.delete(f"/hermes/synthesis/{rule_id}")
|
|
2803
|
+
typer.echo(result.get("Message", result))
|
|
2804
|
+
|
|
2805
|
+
|
|
2692
2806
|
# List User Roles
|
|
2693
2807
|
@user_role_app.command("list")
|
|
2694
2808
|
def user_role_list(as_json: bool = typer.Option(False, "--json"), limit: Optional[int] = typer.Option(None, "--limit")):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qapu-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm.
|
|
5
5
|
Author: OVOO Technology
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -182,7 +182,10 @@ qapu stream list <device_id> [--page N] [--size N] [--json] # GET /hermes/stre
|
|
|
182
182
|
qapu stream get <stream_id> # GET /hermes/stream/detail/{stream_id}
|
|
183
183
|
|
|
184
184
|
qapu synthesis list [--device-id] [--variable-id] [--active/--inactive] [--json] # GET /hermes/synthesis - derived-metric calculation rules
|
|
185
|
-
|
|
185
|
+
qapu synthesis get <rule_id> [--json] # GET /hermes/synthesis/{rule_id}
|
|
186
|
+
qapu synthesis add <variable_id> --required-variables A,B,C --calculation '{"formula": "A + B"}' [--device-id] [--conditions] [--min-value] [--max-value] [--active/--inactive] [--description] # POST /hermes/synthesis
|
|
187
|
+
qapu synthesis update <rule_id> [--variable-id] [--required-variables] [--calculation] [--device-id] [--conditions] [--min-value] [--max-value] [--active/--inactive] [--description] # PUT /hermes/synthesis/{rule_id}
|
|
188
|
+
qapu synthesis delete <rule_id> # DELETE /hermes/synthesis/{rule_id}
|
|
186
189
|
qapu user-role list [--json] [--limit N] # GET /hermes/user_roles - e.g. admin/farmer/technician, NOT the User class itself (PII, deliberately excluded)
|
|
187
190
|
qapu user-role get <role_id> [--json] # GET /hermes/user_roles/{id}
|
|
188
191
|
qapu user-role add <id> "Name" [--description] # POST /hermes/user_roles - ID is REQUIRED, this catalog's ID is NOT auto-increment
|
|
@@ -200,6 +203,16 @@ qapu project update <project_id> [--name] [--description] [--active/--inactive]
|
|
|
200
203
|
qapu project delete <project_id> # DELETE /hermes/projects/{project_id}
|
|
201
204
|
```
|
|
202
205
|
|
|
206
|
+
**`synthesis` extended to full CRUD (2026-09-03)**, per direct user correction after the read-only version shipped the day before ("qapu synthesis için tam crud yapmamışsın sadece list var"). The 2026-09-02 read-only version was a faithful reflection of the domain class at the time - `Synthesized_Metrics` genuinely had no `add()`/`update()`/`delete()`, only `get_list()`. Added `detail()`/`add()`/`update()`/`delete()` to `common/qapu_common/domain/synthesized_metrics.py` (following the same partial-update/cache-invalidation pattern every other domain class in this session uses), then wired all four into `services/api/src/routers/hermes.py` and the CLI. `--required-variables`/`--conditions` take comma-separated/JSON-string input respectively (`Required_Variables` is a plain list, `Conditions`/`Calculation` are JSON columns) - `--calculation`/`--conditions` are validated as JSON client-side before the request goes out, with a clear error instead of a confusing 400 from the API. New unit tests in `common/tests/test_synthesized_metrics.py` (detail/add/update/delete, 10 new cases) - 502 tests passing.
|
|
207
|
+
|
|
208
|
+
**Two real production findings surfaced while live-testing this, neither a bug in the new code**:
|
|
209
|
+
1. **`synthesis_variable_rules` is currently completely empty in production** - `qapu synthesis list` had been showing 12 rules the whole time, but those were a stale Redis cache (`synthesis:list`) with nothing backing it in the actual table (confirmed via a direct `SELECT count(*)` - zero rows - and via `Synthesized_Metrics().get_list(update=True)`, which forces a real DB read and also returns empty). Nothing in this session's own work emptied it - it was already this way, just never surfaced before since nothing had triggered a forced cache refresh. Not fixed here (there's nothing to "fix" code-wise - the table is legitimately empty right now) - flagged so the 12 previously-visible rules aren't mistaken for real, currently-active synthesis logic.
|
|
210
|
+
2. **A "global" rule (`Device_ID="0"`, applied to every device) cannot currently be added at all** - `synthesis_variable_rules.device_id` is a foreign key into `devices.id`, and there is no `id='0'` placeholder row in `devices` (unlike `projects`/`statuses`, which do have a real `id=0` "Unknown" row) - `POST /hermes/synthesis` with no `Device_ID` (which defaults to `"0"`) fails with a `ForeignKeyViolationError`, surfaced to the caller as a generic 500. Confirmed the fix isn't in this new code - adding a real device-scoped rule (an actual device ID that exists in `devices`) works correctly end to end. Not fixed here (adding a synthetic `id='0'` row to `devices`, a load-bearing table, wasn't done unilaterally) - every synthesis rule going forward needs a real, existing `Device_ID` until this is explicitly decided one way or the other.
|
|
211
|
+
|
|
212
|
+
Live-tested against real prod data (after the two findings above were understood): a full add→get→update(confirm)→delete→get(404) cycle using a real device ID (`1000000000000003`) and a real variable ID (`VRMS_A`, itself already in the `variables` catalog) - all steps confirmed via direct DB queries, table left empty afterward exactly as found.
|
|
213
|
+
|
|
214
|
+
**(From the original 2026-09-02 read-only version)** `synthesis list` supports `--device-id`/`--variable-id`/`--active`/`--inactive` filters. A real CLI bug was caught while first testing it: `Calculation` is a JSON object (e.g. `{"formula": "(VRMS_R + VRMS_S + VRMS_T) / 3"}`), not a string - the table crashed with `NotRenderableError: unable to render dict` until values were `json.dumps()`-ed before being handed to the table row.
|
|
215
|
+
|
|
203
216
|
**`project` (added 2026-09-02) is full read/write**, per an analyst-workflow review of the CLI ("Cinga ve diğer ürün/aile ayrımlarında proje bazlı gezinme lazım") - `Project` (`common/qapu_common/domain/project.py`) already had full CRUD (list/detail/add/update/delete) with zero Hermes/CLI wiring at all until now, same "remaining domain, wire it up" pattern as every other catalog this session. Same low-sensitivity reasoning as `group`/`manufacturer`/`calibration`: a plain project catalog (Cinga/WeatherStat/...), no device actuation, no financial/PII. `project devices <project_id>` returns full device detail (not just IDs, unlike `group devices`) via `Device.list(project_id=...)`, which already existed and already returns the full `Device_Detail_Schema` shape - more useful for an analyst than a bare ID list. Live-tested against real prod data: `project list` correctly showed the one real active project (`Cinga V1`, `id=1` - the `id=0` "Unknown" row is deliberately excluded by `Project.list()` itself); `project devices 1 --limit 3` correctly showed 3 real devices; a full add→get(confirm)→update(confirm)→delete→get(404) cycle on a disposable test project, the real `Cinga V1` row never touched.
|
|
204
217
|
|
|
205
218
|
```bash
|
|
@@ -247,8 +260,6 @@ These six catalogs (`manufacturer`/`model`/`modem`/`sim`/`firmware`/`status`) pl
|
|
|
247
260
|
|
|
248
261
|
**`stream get`'s detail was extended the same day**, per user request: `Command_ID` (a bare FK integer, not meaningful on its own) is now resolved and returned as `Command_Name` instead (via `qapu_common.domain.command.Command.detail()`); a new `Variable_Count` field reports how many variables `Stream.get_stream_measurements()` actually recorded from that packet - not previously visible from `stream get` at all. `Size` is now rendered as e.g. `418 byte` in both `stream get` and `stream list`'s table (the raw `--json` output stays a plain int, only the human-readable rendering changed). The Hermes endpoint's `response_model` was dropped to `None` for this route specifically, since the response shape now deliberately diverges from the underlying `Stream_Schema` (swapped field, added field) - every other Hermes route keeps its typed `response_model`, this is a one-off. Live-tested against real prod data: `stream get 4879335` correctly showed `Command_Name: Timed` and `Variable_Count: 18`.
|
|
249
262
|
|
|
250
|
-
**`synthesis` (added 2026-09-02) is read-only, `list` only** - `synthesis_variable_rules` (how a derived metric like `VRMS_A`/`AE_TOT`/power-factor-imbalance gets calculated from raw variables) has no `add()`/`update()`/`delete()` on the domain class at all, it's a small, directly-DB-managed rule set. `Device_ID='0'` rows are global (applied to every device); a real device ID scopes a rule to just that device. `synthesis list` supports `--device-id`/`--variable-id`/`--active`/`--inactive` filters, showing ID/Device/Variable/Priority/Required Variables/Calculation/Active in a table. **Real CLI bug found and fixed while testing this**: `Calculation` is a JSON object (the actual formula, e.g. `{"formula": "(VRMS_R + VRMS_S + VRMS_T) / 3"}`), not a string - the first version of this table crashed with `NotRenderableError: unable to render dict` the moment it hit a real row; fixed by `json.dumps()`-ing dict values before handing them to the table. Live-tested against real prod data after the fix: all 12 real global synthesis rules rendered correctly (readable formulas), `--active`/`--variable-id` filters both confirmed correct counts.
|
|
251
|
-
|
|
252
263
|
**`user-role` (added 2026-09-02) is the ONLY piece of the `user` domain wired up here, and deliberately so** - the user was asked explicitly first, since `user`/`finance` had been excluded from this router since it was first built (a leaked `QAPU_HERMES_KEY` - a shared secret, not real per-admin JWT - must never expose PII or financial data). Chose "read-only, PII-free fields only" as the safe subset: `User_Role` (`common/qapu_common/domain/user.py`) is a plain ID/Name/Description role catalog (admin/farmer/technician/...) with zero PII, structurally identical to `crop-type`/`pump-type`. The actual `User` class (name/phone/email) and `Authorization` (which user owns which device) are NOT wired up and were never considered - PII and device-ownership data stay off this router entirely, same as `finance`.
|
|
253
264
|
- **Real bug found and fixed while testing this**: `User_Role.add()` requires an explicit `ID` - unlike `Status` (which falls back to auto-increment when `ID` is omitted), `user_roles.id` is not an auto-increment column at all (`Column(Integer, primary_key=True, nullable=False)`, no `autoincrement`). The first CLI version only took `Name`/`--description`, so every `add` failed with a misleading `"User role Name is required"` (Name *was* provided - `ID` was the actual missing field). Fixed by making `id` a required positional argument on `user-role add` and clarifying the Hermes error message. Live-tested end to end against real prod data (10 real roles - admin/supervisor/technician/field_technician/accountant/dealer/farmer/farmer_employee/farmer_electrician/demo_user, confirmed zero PII, none touched): a full add(explicit ID `999`)→get→update→get(confirm)→delete→get(404) cycle on a disposable row.
|
|
254
265
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|