qapu-cli 0.63.0__tar.gz → 0.65.0__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.
Files changed (39) hide show
  1. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/PKG-INFO +1 -1
  2. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/pyproject.toml +1 -1
  3. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/client.py +8 -0
  4. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/main.py +62 -0
  5. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/device_edit.py +32 -11
  6. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/PKG-INFO +1 -1
  7. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_tui_device_edit.py +31 -0
  8. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/README.md +0 -0
  9. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/__init__.py +0 -0
  10. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/__init__.py +0 -0
  11. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/app.py +0 -0
  12. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/app.tcss +0 -0
  13. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/data.py +0 -0
  14. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/rendering.py +0 -0
  15. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/__init__.py +0 -0
  16. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/catalog.py +0 -0
  17. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/catalog_form.py +0 -0
  18. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/catalog_picker.py +0 -0
  19. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/confirm.py +0 -0
  20. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/dashboard.py +0 -0
  21. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/device_list.py +0 -0
  22. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/equipment_form.py +0 -0
  23. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/group_detail.py +0 -0
  24. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/group_form.py +0 -0
  25. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/group_list.py +0 -0
  26. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/infrastructure.py +0 -0
  27. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/widgets/__init__.py +0 -0
  28. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/widgets/device_table.py +0 -0
  29. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/SOURCES.txt +0 -0
  30. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/dependency_links.txt +0 -0
  31. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/entry_points.txt +0 -0
  32. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/requires.txt +0 -0
  33. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/top_level.txt +0 -0
  34. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/setup.cfg +0 -0
  35. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_login.py +0 -0
  36. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_tui_catalog.py +0 -0
  37. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_tui_groups.py +0 -0
  38. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_tui_infrastructure.py +0 -0
  39. {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_tui_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qapu-cli
3
- Version: 0.63.0
3
+ Version: 0.65.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qapu-cli"
3
- version = "0.63.0"
3
+ version = "0.65.0"
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"
@@ -473,6 +473,14 @@ def auth_post(path: str, json_body: dict[str, Any] | None = None) -> Any:
473
473
  return _auth_request("POST", path, json_body=json_body)
474
474
 
475
475
 
476
+ def auth_put(path: str, params: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None) -> Any:
477
+
478
+ # Issue a Bearer-Authenticated PUT Request to services/auth (2026-10-05,
479
+ # Operator-Access Tiers - the First PUT Caller on This Seam, e.g.
480
+ # PUT /user/{user_id}/tier)
481
+ return _auth_request("PUT", path, params=params, json_body=json_body)
482
+
483
+
476
484
  def auth_delete(path: str, params: dict[str, Any] | None = None) -> Any:
477
485
 
478
486
  # Issue a Bearer-Authenticated DELETE Request to services/auth
@@ -6012,6 +6012,68 @@ def user_delete(user_id: int = typer.Argument(...)):
6012
6012
  typer.echo(result.get("Message", result))
6013
6013
 
6014
6014
 
6015
+ # Operator-Access Tiers (2026-10-05) - These 3 Commands go Through
6016
+ # services/auth's own Bearer-JWT Seam (client.auth_get/auth_put, Requires
6017
+ # `qapu login` First), not the Hermes Shared-Secret Seam Every Other
6018
+ # `user *` Command Above Uses - Tier/Permission-Package Management Lives
6019
+ # Exclusively on services/auth, Admin-Only (require_token_management_or_
6020
+ # admin, Which Also Accepts the Scope-Limited Token a Normal `qapu login`
6021
+ # Issues, as Long as the Logged-in Account's own Role_ID is Admin).
6022
+ @user_app.command("tiers")
6023
+ def user_tiers(as_json: bool = typer.Option(False, "--json")):
6024
+ """List the 3 named operator-access tiers (user/supervisor/admin) and the permission package codes each one carries (GET /tiers, admin only)."""
6025
+ result: dict[str, list[str]] = client.auth_get("/tiers")
6026
+ if as_json:
6027
+ typer.echo(json.dumps(result, indent=2, ensure_ascii=False))
6028
+ return
6029
+ table = Table(title="Operatör Yetki Seviyeleri", box=box.ROUNDED)
6030
+ table.add_column("SEVİYE", style="bold")
6031
+ table.add_column("PAKETLER")
6032
+ for tier, packages in result.items():
6033
+ table.add_row(tier, ", ".join(sorted(packages)))
6034
+ console.print(table)
6035
+
6036
+
6037
+ # List a User's Currently Granted Permission Packages - the Underlying
6038
+ # Grants a Tier Assignment (set-role Below) Actually Writes, Exposed
6039
+ # Directly for When Someone's Access Looks Off and the Real Question is
6040
+ # "what packages does this account ACTUALLY hold right now."
6041
+ @user_app.command("permissions")
6042
+ def user_permissions(user_id: int = typer.Argument(...), as_json: bool = typer.Option(False, "--json")):
6043
+ """List a user's currently granted permission packages (GET /user/{user_id}/permissions, admin only)."""
6044
+ items: list[dict[str, Any]] = client.auth_get(f"/user/{user_id}/permissions")
6045
+ if as_json:
6046
+ typer.echo(json.dumps(items, indent=2, ensure_ascii=False))
6047
+ return
6048
+ if not items:
6049
+ console.print(f"[dim]Kullanıcı {user_id} için tanımlı yetki paketi yok.[/dim]")
6050
+ return
6051
+ table = Table(title=f"Kullanıcı {user_id} - Yetki Paketleri", box=box.ROUNDED)
6052
+ table.add_column("PAKET", style="bold")
6053
+ table.add_column("AÇIKLAMA")
6054
+ table.add_column("VEREN", justify="right")
6055
+ table.add_column("TARİH")
6056
+ for item in sorted(items, key=lambda i: (i.get("Package") or {}).get("Code") or ""):
6057
+ package = item.get("Package") or {}
6058
+ table.add_row(package.get("Code") or "-", package.get("Description") or "-", str(item.get("Granted_By") or "-"), str(item.get("Create_Time") or "-"))
6059
+ console.print(table)
6060
+
6061
+
6062
+ # Set a User's Operator-Access Tier - the Real Admin Command This Whole
6063
+ # 3-Tier Redesign (2026-10-05) was Built For: Grants Everything the
6064
+ # Target Tier Carries, Revokes Anything From a Higher Tier the Account
6065
+ # Might Currently Hold, in One Call (apply_tier()'s own "EXACT Sync"
6066
+ # Semantics - see common/qapu_common/domain/permission.py).
6067
+ @user_app.command("set-role")
6068
+ def user_set_role(
6069
+ user_id: int = typer.Argument(...),
6070
+ tier: str = typer.Argument(..., help="One of: user, supervisor, admin."),
6071
+ ):
6072
+ """Apply a named operator-access tier to a user - readonly packages for 'user', plus write/mcp:full-access for 'supervisor', plus admin-only packages for 'admin' (PUT /user/{user_id}/tier, admin only)."""
6073
+ result = client.auth_put(f"/user/{user_id}/tier", params={"tier": tier})
6074
+ typer.echo(result.get("Message", result))
6075
+
6076
+
6015
6077
  # List One Device's Irrigation Events
6016
6078
  @irrigation_app.command("list")
6017
6079
  def irrigation_list(device_id: str = typer.Argument(...), as_json: bool = typer.Option(False, "--json")):
@@ -116,35 +116,52 @@ class DeviceEditScreen(ModalScreen[bool]):
116
116
  self._all_groups = groups
117
117
  self._original_group_ids = group_ids
118
118
 
119
- # Proje
119
+ # Proje - Cihazın Şu Anki Project.ID Katalogda Bulunmayabilir (en
120
+ # Başta id=0 "Unknown" Placeholder - GET /hermes/projects Onu
121
+ # Bilerek Hariç Tutuyor, bkz. Device.list()'in Aynı Kuralı) -
122
+ # Select.value'yu Sadece Gerçekten Seçenekler Arasındaysa Set Et,
123
+ # Yoksa Boş Bırak (Select.NULL) - Kör Atama Textual'ın Kendi
124
+ # InvalidSelectValueError'ını Fırlatıp Tüm TUI'yi Çökertiyordu
125
+ # (Canlı Yakalandı, 2026-10-05: Project_ID=0 Olan Gerçek bir Cihaz
126
+ # Üzerinde). _do_save() zaten Select.NULL'ü "Kullanıcı Seçim
127
+ # Yapmadı" Olarak Okuyor, o Yüzden Boş Bırakmak Mevcut Değeri
128
+ # Sessizce Korur - Kullanıcı Elle Yeni bir Proje Seçerse Yine
129
+ # Normal Şekilde pending'e Girer.
130
+ project_options = [(p.get("Name") or f"#{p['ID']}", p["ID"]) for p in projects if p.get("ID") is not None]
120
131
  project_select = self.query_one("#edit-project", Select)
121
- project_select.set_options([(p.get("Name") or f"#{p['ID']}", p["ID"]) for p in projects if p.get("ID") is not None])
122
- if self._initial_project_id is not None:
132
+ project_select.set_options(project_options)
133
+ if self._initial_project_id is not None and self._initial_project_id in {pid for _, pid in project_options}:
123
134
  project_select.value = self._initial_project_id
124
135
 
125
136
  # Cihaz Durumu - Sadece 1xx (Cihaz Durumları) Aralığı, Başlık
126
- # Satırı (id=100) Hariç - device_edit()'in Kendi Filtresiyle Birebir
137
+ # Satırı (id=100) Hariç - device_edit()'in Kendi Filtresiyle Birebir.
138
+ # Aynı Gerekçeyle (Yukarıdaki Proje Notu) Mevcut Status.ID Filtreli
139
+ # Listede Yoksa Boş Bırak.
127
140
  device_statuses = [s for s in statuses if s.get("ID") is not None and 100 <= s["ID"] < 200 and s["ID"] != 100]
141
+ status_options = [(s.get("Name") or f"#{s['ID']}", s["ID"]) for s in device_statuses]
128
142
  status_select = self.query_one("#edit-status", Select)
129
- status_select.set_options([(s.get("Name") or f"#{s['ID']}", s["ID"]) for s in device_statuses])
130
- if self._initial_status_id is not None:
143
+ status_select.set_options(status_options)
144
+ if self._initial_status_id is not None and self._initial_status_id in {sid for _, sid in status_options}:
131
145
  status_select.value = self._initial_status_id
132
146
 
133
147
  # Üretici - Yuvarlak-Bin Kategori Başlık Satırlarını Hariç Tut (Aynı
134
148
  # device_edit()/manufacturer_list Konvansiyonu)
135
149
  real_manufacturers = [m for m in manufacturers if m.get("ID") is not None and m["ID"] % 1000 != 0]
150
+ manufacturer_options = [(m.get("Name") or f"#{m['ID']}", m["ID"]) for m in real_manufacturers]
136
151
  manufacturer_select = self.query_one("#edit-manufacturer", Select)
137
- manufacturer_select.set_options([(m.get("Name") or f"#{m['ID']}", m["ID"]) for m in real_manufacturers])
152
+ manufacturer_select.set_options(manufacturer_options)
138
153
 
139
154
  # Model - Mevcut Modelin Üreticisini manufacturer_models'daki Kendi
140
155
  # Kaydından Çöz (Hardware.Model'in Manufacturer_ID Taşıdığı
141
- # Varsayılmıyor - Doğrulanmadı), Sonra o Üreticiye Göre Filtrele
156
+ # Varsayılmıyor - Doğrulanmadı), Sonra o Üreticiye Göre Filtrele.
157
+ # initial_manufacturer_id'nin Üretici Listesinde Gerçekten Var
158
+ # Olduğu da Ayrıca Doğrulanıyor (Aynı Gerekçe).
142
159
  initial_manufacturer_id: Optional[int] = None
143
160
  if self._initial_model_id is not None:
144
161
  current_model = next((mo for mo in models if mo.get("ID") == self._initial_model_id), None)
145
162
  if current_model:
146
163
  initial_manufacturer_id = current_model.get("Manufacturer_ID")
147
- if initial_manufacturer_id is not None:
164
+ if initial_manufacturer_id is not None and initial_manufacturer_id in {mid for _, mid in manufacturer_options}:
148
165
  manufacturer_select.value = initial_manufacturer_id
149
166
  self._set_model_options(initial_manufacturer_id, preselect=self._initial_model_id)
150
167
 
@@ -184,8 +201,12 @@ class DeviceEditScreen(ModalScreen[bool]):
184
201
  def _set_model_options(self, manufacturer_id: int, preselect: Optional[int]) -> None:
185
202
  model_select = self.query_one("#edit-model", Select)
186
203
  matching = [mo for mo in self._all_models if mo.get("Manufacturer_ID") == manufacturer_id]
187
- model_select.set_options([(mo.get("Name") or f"#{mo['ID']}", mo["ID"]) for mo in matching if mo.get("ID") is not None])
188
- if preselect is not None:
204
+ model_options = [(mo.get("Name") or f"#{mo['ID']}", mo["ID"]) for mo in matching if mo.get("ID") is not None]
205
+ model_select.set_options(model_options)
206
+ # Aynı Gerekçe (Yukarıdaki Proje/Durum/Üretici Notu) - preselect Bu
207
+ # Üreticinin Filtrelenmiş Model Listesinde Gerçekten Yoksa Boş
208
+ # Bırak, Kör Atama Çökertmesin.
209
+ if preselect is not None and preselect in {mid for _, mid in model_options}:
189
210
  model_select.value = preselect
190
211
  else:
191
212
  model_select.value = Select.NULL
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qapu-cli
3
- Version: 0.63.0
3
+ Version: 0.65.0
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
@@ -127,6 +127,37 @@ async def test_group_change_with_assigned_by_calls_assign(monkeypatch):
127
127
  assert assign_calls == [(2, "TEST-DEVICE-1", 204)]
128
128
 
129
129
 
130
+ @pytest.mark.asyncio
131
+ async def test_device_with_project_not_in_catalog_opens_without_crashing(monkeypatch):
132
+ # Real Crash Caught Live (2026-10-05): a Device Whose Current
133
+ # Project.ID is 0 ("Unknown" Placeholder, Deliberately Excluded From
134
+ # GET /hermes/projects - Same Exclusion Project.list() Already
135
+ # Applies) has no Matching Entry in the Catalog-Fetched Options, so
136
+ # Blindly Assigning Select.value = 0 Raised Textual's own
137
+ # InvalidSelectValueError and Crashed the Whole TUI. The Fix Leaves
138
+ # the Field Blank (Select.NULL) Instead of Crashing - Confirmed Here
139
+ # for Project/Status/Manufacturer/Model all at Once (Same Bug Class,
140
+ # Same Fix Shape, in _apply_options()/_set_model_options()).
141
+ _install_catalog_mocks(monkeypatch, device_group_ids=[])
142
+ device_with_unknown_everything = {
143
+ "ID": "TEST-DEVICE-2",
144
+ "Name": "Eski Cihaz",
145
+ "Description": "",
146
+ "Project": {"ID": 0, "Name": "Unknown"},
147
+ "Status": {"ID": 999, "Name": "Not In Catalog"},
148
+ "Hardware": {"Model": {"ID": 9999, "Name": "Not In Catalog"}},
149
+ }
150
+ app = QapuTUIApp()
151
+ async with app.run_test() as pilot:
152
+ await app.push_screen(DeviceEditScreen("TEST-DEVICE-2", device_with_unknown_everything))
153
+ await pilot.pause(0.2)
154
+ from textual.widgets import Select
155
+ assert isinstance(app.screen, DeviceEditScreen)
156
+ assert app.screen.query_one("#edit-project", Select).value is Select.NULL
157
+ assert app.screen.query_one("#edit-status", Select).value is Select.NULL
158
+ assert app.screen.query_one("#edit-model", Select).value is Select.NULL
159
+
160
+
130
161
  @pytest.mark.asyncio
131
162
  async def test_save_with_nothing_changed_calls_no_mutation(monkeypatch):
132
163
  _install_catalog_mocks(monkeypatch, device_group_ids=[1])
File without changes
File without changes
File without changes
File without changes