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.
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/PKG-INFO +1 -1
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/pyproject.toml +1 -1
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/client.py +8 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/main.py +62 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/device_edit.py +32 -11
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/PKG-INFO +1 -1
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_tui_device_edit.py +31 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/README.md +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/__init__.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/__init__.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/app.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/app.tcss +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/data.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/rendering.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/__init__.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/catalog.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/catalog_form.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/catalog_picker.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/confirm.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/dashboard.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/device_list.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/equipment_form.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/group_detail.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/group_form.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/group_list.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/screens/infrastructure.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/widgets/__init__.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli/tui/widgets/device_table.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/SOURCES.txt +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/dependency_links.txt +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/entry_points.txt +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/requires.txt +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/qapu_cli.egg-info/top_level.txt +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/setup.cfg +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_login.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_tui_catalog.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_tui_groups.py +0 -0
- {qapu_cli-0.63.0 → qapu_cli-0.65.0}/tests/test_tui_infrastructure.py +0 -0
- {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.
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
188
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|