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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qapu-cli
3
- Version: 0.6.2
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.2"
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.2
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