kctl-api 0.2.0__py3-none-any.whl
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.
- kctl_api/__init__.py +3 -0
- kctl_api/__main__.py +5 -0
- kctl_api/cli.py +238 -0
- kctl_api/commands/__init__.py +1 -0
- kctl_api/commands/ai.py +250 -0
- kctl_api/commands/aliases.py +84 -0
- kctl_api/commands/apps.py +172 -0
- kctl_api/commands/auth.py +313 -0
- kctl_api/commands/automation.py +242 -0
- kctl_api/commands/build_cmd.py +87 -0
- kctl_api/commands/clean.py +182 -0
- kctl_api/commands/config_cmd.py +443 -0
- kctl_api/commands/dashboard.py +139 -0
- kctl_api/commands/db.py +599 -0
- kctl_api/commands/deploy.py +84 -0
- kctl_api/commands/deps.py +289 -0
- kctl_api/commands/dev.py +136 -0
- kctl_api/commands/docker_cmd.py +252 -0
- kctl_api/commands/doctor_cmd.py +286 -0
- kctl_api/commands/env.py +289 -0
- kctl_api/commands/files.py +250 -0
- kctl_api/commands/fmt_cmd.py +58 -0
- kctl_api/commands/health.py +479 -0
- kctl_api/commands/jobs.py +169 -0
- kctl_api/commands/lint_cmd.py +81 -0
- kctl_api/commands/logs.py +258 -0
- kctl_api/commands/marketplace.py +316 -0
- kctl_api/commands/monitor_cmd.py +243 -0
- kctl_api/commands/notifications.py +132 -0
- kctl_api/commands/odoo_proxy.py +182 -0
- kctl_api/commands/openapi.py +299 -0
- kctl_api/commands/perf.py +307 -0
- kctl_api/commands/rate_limit.py +223 -0
- kctl_api/commands/realtime.py +100 -0
- kctl_api/commands/redis_cmd.py +609 -0
- kctl_api/commands/routes_cmd.py +277 -0
- kctl_api/commands/saas.py +145 -0
- kctl_api/commands/scaffold.py +362 -0
- kctl_api/commands/security_cmd.py +350 -0
- kctl_api/commands/services.py +191 -0
- kctl_api/commands/shell.py +197 -0
- kctl_api/commands/skill_cmd.py +58 -0
- kctl_api/commands/streams.py +309 -0
- kctl_api/commands/stripe_cmd.py +105 -0
- kctl_api/commands/tenant_ai.py +169 -0
- kctl_api/commands/test_cmd.py +95 -0
- kctl_api/commands/users.py +302 -0
- kctl_api/commands/webhooks.py +56 -0
- kctl_api/commands/workflows.py +127 -0
- kctl_api/commands/ws.py +323 -0
- kctl_api/core/__init__.py +1 -0
- kctl_api/core/async_client.py +120 -0
- kctl_api/core/callbacks.py +88 -0
- kctl_api/core/client.py +190 -0
- kctl_api/core/config.py +260 -0
- kctl_api/core/db.py +65 -0
- kctl_api/core/exceptions.py +43 -0
- kctl_api/core/output.py +5 -0
- kctl_api/core/plugins.py +26 -0
- kctl_api/core/redis.py +35 -0
- kctl_api/core/resolve.py +47 -0
- kctl_api/core/utils.py +109 -0
- kctl_api-0.2.0.dist-info/METADATA +34 -0
- kctl_api-0.2.0.dist-info/RECORD +66 -0
- kctl_api-0.2.0.dist-info/WHEEL +4 -0
- kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Production monitoring commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Real-time metrics, alerts, and uptime monitoring.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from kctl_api.core.callbacks import AppContext
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="monitor", help="Production monitoring — live, metrics, uptime.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# live
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
@app.command()
|
|
22
|
+
def live(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
interval: Annotated[int, typer.Option("--interval", "-i", help="Refresh interval in seconds.")] = 5,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Live dashboard — API health, DB, Redis, container status refreshed every N seconds."""
|
|
27
|
+
actx: AppContext = ctx.obj
|
|
28
|
+
out = actx.output
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import contextlib
|
|
32
|
+
import datetime
|
|
33
|
+
import subprocess
|
|
34
|
+
|
|
35
|
+
from kctl_api.core.utils import find_project_root
|
|
36
|
+
|
|
37
|
+
root = find_project_root()
|
|
38
|
+
|
|
39
|
+
def _get_container_status() -> list[dict]:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["docker", "compose", "-f", str(root / "docker-compose.yml"), "ps", "--format", "json"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
)
|
|
45
|
+
if result.returncode != 0:
|
|
46
|
+
return []
|
|
47
|
+
import json
|
|
48
|
+
|
|
49
|
+
containers = []
|
|
50
|
+
for line in result.stdout.strip().splitlines():
|
|
51
|
+
if line.strip():
|
|
52
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
53
|
+
containers.append(json.loads(line))
|
|
54
|
+
return containers
|
|
55
|
+
|
|
56
|
+
async def _check_services() -> dict:
|
|
57
|
+
checks: dict[str, str] = {}
|
|
58
|
+
|
|
59
|
+
# API
|
|
60
|
+
try:
|
|
61
|
+
healthy, _ = actx.client.health_check()
|
|
62
|
+
checks["api-main"] = "[green]ok[/green]" if healthy else "[red]error[/red]"
|
|
63
|
+
except Exception:
|
|
64
|
+
checks["api-main"] = "[red]down[/red]"
|
|
65
|
+
|
|
66
|
+
# DB
|
|
67
|
+
try:
|
|
68
|
+
from kctl_api.core.db import dispose_engine, execute_query
|
|
69
|
+
|
|
70
|
+
db_url = actx.database_url
|
|
71
|
+
if db_url:
|
|
72
|
+
await execute_query(db_url, "SELECT 1")
|
|
73
|
+
await dispose_engine()
|
|
74
|
+
checks["postgresql"] = "[green]ok[/green]"
|
|
75
|
+
else:
|
|
76
|
+
checks["postgresql"] = "[yellow]no url[/yellow]"
|
|
77
|
+
except Exception:
|
|
78
|
+
checks["postgresql"] = "[red]error[/red]"
|
|
79
|
+
|
|
80
|
+
# Redis
|
|
81
|
+
try:
|
|
82
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
83
|
+
|
|
84
|
+
redis_url = actx.redis_url
|
|
85
|
+
if redis_url:
|
|
86
|
+
client = get_redis(redis_url)
|
|
87
|
+
await client.ping()
|
|
88
|
+
await close_redis()
|
|
89
|
+
checks["redis"] = "[green]ok[/green]"
|
|
90
|
+
else:
|
|
91
|
+
checks["redis"] = "[yellow]no url[/yellow]"
|
|
92
|
+
except Exception:
|
|
93
|
+
checks["redis"] = "[red]error[/red]"
|
|
94
|
+
|
|
95
|
+
return checks
|
|
96
|
+
|
|
97
|
+
console = out.console # type: ignore[attr-defined]
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
while True:
|
|
101
|
+
console.clear()
|
|
102
|
+
now = datetime.datetime.now(tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
103
|
+
out.header(f"Live Monitor — {now} (refreshing every {interval}s)")
|
|
104
|
+
|
|
105
|
+
# Service checks
|
|
106
|
+
checks = asyncio.run(_check_services())
|
|
107
|
+
out.text("")
|
|
108
|
+
out.text(" [bold]Services:[/bold]")
|
|
109
|
+
for svc, status in checks.items():
|
|
110
|
+
out.text(f" {svc:<20} {status}")
|
|
111
|
+
|
|
112
|
+
# Container status
|
|
113
|
+
containers = _get_container_status()
|
|
114
|
+
if containers:
|
|
115
|
+
out.text("")
|
|
116
|
+
out.text(" [bold]Containers:[/bold]")
|
|
117
|
+
for c in containers:
|
|
118
|
+
name = c.get("Name", c.get("Service", ""))
|
|
119
|
+
state = c.get("State", c.get("Status", ""))
|
|
120
|
+
health = c.get("Health", "")
|
|
121
|
+
indicator = "[green]up[/green]" if "running" in state.lower() else "[red]down[/red]"
|
|
122
|
+
health_str = f" ({health})" if health else ""
|
|
123
|
+
out.text(f" {name:<30} {indicator}{health_str}")
|
|
124
|
+
|
|
125
|
+
out.text("")
|
|
126
|
+
out.text(" [dim]Press Ctrl+C to stop[/dim]")
|
|
127
|
+
|
|
128
|
+
time.sleep(interval)
|
|
129
|
+
except KeyboardInterrupt:
|
|
130
|
+
out.info("Monitoring stopped.")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# metrics
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
@app.command()
|
|
137
|
+
def metrics(ctx: typer.Context) -> None:
|
|
138
|
+
"""Fetch metrics from the API (Prometheus endpoint if available)."""
|
|
139
|
+
actx: AppContext = ctx.obj
|
|
140
|
+
out = actx.output
|
|
141
|
+
|
|
142
|
+
# Try /metrics endpoint (Prometheus format)
|
|
143
|
+
metrics_endpoints = ["/metrics", "/api/v1/metrics", "/api/v1/health"]
|
|
144
|
+
|
|
145
|
+
for endpoint in metrics_endpoints:
|
|
146
|
+
try:
|
|
147
|
+
response = actx.client.get_raw(endpoint)
|
|
148
|
+
if response.status_code == 200:
|
|
149
|
+
content_type = response.headers.get("content-type", "")
|
|
150
|
+
if "text/plain" in content_type or "prometheus" in content_type:
|
|
151
|
+
out.header(f"Metrics: {endpoint}")
|
|
152
|
+
# Show first 50 lines
|
|
153
|
+
lines = response.text.splitlines()[:50]
|
|
154
|
+
for line in lines:
|
|
155
|
+
if not line.startswith("#"):
|
|
156
|
+
out.text(f" {line}")
|
|
157
|
+
if len(response.text.splitlines()) > 50:
|
|
158
|
+
out.text(f" ... ({len(response.text.splitlines())} total lines)")
|
|
159
|
+
return
|
|
160
|
+
elif "json" in content_type:
|
|
161
|
+
import json
|
|
162
|
+
|
|
163
|
+
data = json.loads(response.text)
|
|
164
|
+
out.detail(
|
|
165
|
+
title=f"Metrics: {endpoint}",
|
|
166
|
+
sections=[("Data", [(k, str(v)) for k, v in data.items() if not isinstance(v, dict)])],
|
|
167
|
+
data_for_json=data,
|
|
168
|
+
)
|
|
169
|
+
return
|
|
170
|
+
except Exception:
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
out.warn("No metrics endpoint found. Consider adding prometheus-fastapi-instrumentator.")
|
|
174
|
+
out.info(" uv add prometheus-fastapi-instrumentator")
|
|
175
|
+
out.info(" Exposes: GET /metrics (Prometheus format)")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# uptime
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
@app.command()
|
|
182
|
+
def uptime(
|
|
183
|
+
ctx: typer.Context,
|
|
184
|
+
duration: Annotated[int, typer.Option("--duration", help="Monitor uptime for N seconds (0 = single check).")] = 0,
|
|
185
|
+
interval: Annotated[int, typer.Option("--interval", "-i", help="Check interval in seconds.")] = 10,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Monitor API uptime — single check or continuous with SLA tracking."""
|
|
188
|
+
actx: AppContext = ctx.obj
|
|
189
|
+
out = actx.output
|
|
190
|
+
|
|
191
|
+
def _check() -> tuple[bool, float]:
|
|
192
|
+
start = time.monotonic()
|
|
193
|
+
try:
|
|
194
|
+
healthy, _ = actx.client.health_check()
|
|
195
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
196
|
+
return healthy, round(elapsed_ms, 1)
|
|
197
|
+
except Exception:
|
|
198
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
199
|
+
return False, round(elapsed_ms, 1)
|
|
200
|
+
|
|
201
|
+
if duration == 0:
|
|
202
|
+
# Single check
|
|
203
|
+
healthy, latency_ms = _check()
|
|
204
|
+
status = "[green]UP[/green]" if healthy else "[red]DOWN[/red]"
|
|
205
|
+
out.text(f" Status: {status}")
|
|
206
|
+
out.text(f" Latency: {latency_ms}ms")
|
|
207
|
+
if actx.json_mode:
|
|
208
|
+
out.raw_json({"healthy": healthy, "latency_ms": latency_ms})
|
|
209
|
+
if not healthy:
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# Continuous monitoring
|
|
214
|
+
out.info(f"Monitoring uptime for {duration}s (interval={interval}s) ...")
|
|
215
|
+
checks: list[tuple[bool, float]] = []
|
|
216
|
+
deadline = time.monotonic() + duration
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
while time.monotonic() < deadline:
|
|
220
|
+
healthy, latency_ms = _check()
|
|
221
|
+
checks.append((healthy, latency_ms))
|
|
222
|
+
status = "[green]UP[/green]" if healthy else "[red]DOWN[/red]"
|
|
223
|
+
out.text(f" [{len(checks)}] {status} ({latency_ms}ms)")
|
|
224
|
+
if time.monotonic() < deadline:
|
|
225
|
+
time.sleep(min(interval, deadline - time.monotonic()))
|
|
226
|
+
except KeyboardInterrupt:
|
|
227
|
+
out.info("Stopped.")
|
|
228
|
+
|
|
229
|
+
if not checks:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
total = len(checks)
|
|
233
|
+
up_count = sum(1 for h, _ in checks if h)
|
|
234
|
+
sla = round(up_count / total * 100, 2)
|
|
235
|
+
avg_lat = round(sum(lat for _, lat in checks) / total, 1)
|
|
236
|
+
|
|
237
|
+
out.text("")
|
|
238
|
+
out.text(f" Checks: {total}")
|
|
239
|
+
out.text(f" Uptime: [{'green' if sla >= 99 else 'yellow'}]{sla}%[/{'green' if sla >= 99 else 'yellow'}]")
|
|
240
|
+
out.text(f" Avg latency: {avg_lat}ms")
|
|
241
|
+
|
|
242
|
+
if actx.json_mode:
|
|
243
|
+
out.raw_json({"total": total, "up": up_count, "sla_pct": sla, "avg_latency_ms": avg_lat})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Notification commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Send messages via Mattermost, Telegram, broadcast, and test.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_api.core.callbacks import AppContext
|
|
13
|
+
from kctl_api.core.exceptions import APIError, AuthenticationError
|
|
14
|
+
from kctl_api.core.exceptions import ConnectionError as KctlConnectionError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="notifications", help="Send notifications — Mattermost, Telegram, broadcast.", no_args_is_help=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# mattermost
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
@app.command()
|
|
25
|
+
def mattermost(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
message: Annotated[str, typer.Argument(help="Message text to send.")],
|
|
28
|
+
channel: Annotated[str, typer.Option("--channel", "-c", help="Channel ID or name.")] = "",
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Send a Mattermost notification via POST /api/v1/notifications/mattermost."""
|
|
31
|
+
actx: AppContext = ctx.obj
|
|
32
|
+
out = actx.output
|
|
33
|
+
|
|
34
|
+
payload: dict = {"message": message}
|
|
35
|
+
if channel:
|
|
36
|
+
payload["channel"] = channel
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
result = actx.client.post("/api/v1/notifications/mattermost", json=payload)
|
|
40
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
41
|
+
out.error(str(e))
|
|
42
|
+
raise typer.Exit(1) from None
|
|
43
|
+
|
|
44
|
+
out.success("Mattermost notification sent.")
|
|
45
|
+
if actx.json_mode:
|
|
46
|
+
out.raw_json(result)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# telegram
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
@app.command()
|
|
53
|
+
def telegram(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
message: Annotated[str, typer.Argument(help="Message text to send.")],
|
|
56
|
+
chat_id: Annotated[str, typer.Option("--chat-id", "-c", help="Telegram chat ID.")] = "",
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Send a Telegram notification via POST /api/v1/notifications/telegram."""
|
|
59
|
+
actx: AppContext = ctx.obj
|
|
60
|
+
out = actx.output
|
|
61
|
+
|
|
62
|
+
payload: dict = {"message": message}
|
|
63
|
+
if chat_id:
|
|
64
|
+
payload["chat_id"] = chat_id
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
result = actx.client.post("/api/v1/notifications/telegram", json=payload)
|
|
68
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
69
|
+
out.error(str(e))
|
|
70
|
+
raise typer.Exit(1) from None
|
|
71
|
+
|
|
72
|
+
out.success("Telegram notification sent.")
|
|
73
|
+
if actx.json_mode:
|
|
74
|
+
out.raw_json(result)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# broadcast
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
@app.command()
|
|
81
|
+
def broadcast(
|
|
82
|
+
ctx: typer.Context,
|
|
83
|
+
message: Annotated[str, typer.Argument(help="Broadcast message.")],
|
|
84
|
+
channel_type: Annotated[str, typer.Option("--type", "-t", help="Channel type: all, mattermost, telegram.")] = "all",
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Broadcast a message to all notification channels via POST /api/v1/notifications/broadcast."""
|
|
87
|
+
actx: AppContext = ctx.obj
|
|
88
|
+
out = actx.output
|
|
89
|
+
|
|
90
|
+
payload: dict = {"message": message, "channel_type": channel_type}
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
result = actx.client.post("/api/v1/notifications/broadcast", json=payload)
|
|
94
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
95
|
+
out.error(str(e))
|
|
96
|
+
raise typer.Exit(1) from None
|
|
97
|
+
|
|
98
|
+
out.success(f"Broadcast sent ({channel_type}).")
|
|
99
|
+
if actx.json_mode:
|
|
100
|
+
out.raw_json(result)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# test
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
@app.command(name="test")
|
|
107
|
+
def test_notification(
|
|
108
|
+
ctx: typer.Context,
|
|
109
|
+
channel_type: Annotated[
|
|
110
|
+
str, typer.Option("--type", "-t", help="Channel type: mattermost, telegram.")
|
|
111
|
+
] = "mattermost",
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Send a test notification to verify configuration.
|
|
114
|
+
|
|
115
|
+
Sends a test message via the broadcast endpoint.
|
|
116
|
+
"""
|
|
117
|
+
actx: AppContext = ctx.obj
|
|
118
|
+
out = actx.output
|
|
119
|
+
|
|
120
|
+
test_msg = f"[kctl-api] Test notification via {channel_type}"
|
|
121
|
+
try:
|
|
122
|
+
result = actx.client.post(
|
|
123
|
+
"/api/v1/notifications/broadcast",
|
|
124
|
+
json={"message": test_msg, "channel_type": channel_type},
|
|
125
|
+
)
|
|
126
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
127
|
+
out.error(str(e))
|
|
128
|
+
raise typer.Exit(1) from None
|
|
129
|
+
|
|
130
|
+
out.success(f"Test notification sent via {channel_type}.")
|
|
131
|
+
if actx.json_mode:
|
|
132
|
+
out.raw_json(result)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Odoo proxy commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Admin-only JSON-RPC proxy calls to Odoo backend.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_api.core.callbacks import AppContext
|
|
13
|
+
from kctl_api.core.exceptions import APIError, AuthenticationError
|
|
14
|
+
from kctl_api.core.exceptions import ConnectionError as KctlConnectionError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="odoo", help="Odoo proxy — search-read, call, create, write (admin-only).", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
_BASE = "/api/v1/odoo"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# search-read
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
@app.command(name="search-read")
|
|
25
|
+
def search_read(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
model: Annotated[str, typer.Argument(help="Odoo model name (e.g. res.partner).")],
|
|
28
|
+
domain: Annotated[str, typer.Option("--domain", "-d", help="JSON domain filter.")] = "[]",
|
|
29
|
+
fields: Annotated[str, typer.Option("--fields", "-f", help="Comma-separated field names.")] = "",
|
|
30
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max records to return.")] = 20,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Search and read Odoo records via POST /api/v1/odoo/search-read (admin-only)."""
|
|
33
|
+
actx: AppContext = ctx.obj
|
|
34
|
+
out = actx.output
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
domain_parsed = json.loads(domain)
|
|
40
|
+
except json.JSONDecodeError as e:
|
|
41
|
+
out.error(f"Invalid domain JSON: {e}")
|
|
42
|
+
raise typer.Exit(1) from None
|
|
43
|
+
|
|
44
|
+
payload: dict = {
|
|
45
|
+
"model": model,
|
|
46
|
+
"domain": domain_parsed,
|
|
47
|
+
"limit": limit,
|
|
48
|
+
}
|
|
49
|
+
if fields:
|
|
50
|
+
payload["fields"] = [f.strip() for f in fields.split(",")]
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
result = actx.client.post(f"{_BASE}/search-read", json=payload)
|
|
54
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
55
|
+
out.error(str(e))
|
|
56
|
+
raise typer.Exit(1) from None
|
|
57
|
+
|
|
58
|
+
records = result if isinstance(result, list) else result.get("records", []) if isinstance(result, dict) else []
|
|
59
|
+
out.info(f"Found {len(records)} record(s).")
|
|
60
|
+
|
|
61
|
+
if actx.json_mode:
|
|
62
|
+
out.raw_json(records)
|
|
63
|
+
elif records:
|
|
64
|
+
# Auto-detect columns from first record
|
|
65
|
+
cols = list(records[0].keys()) if records else []
|
|
66
|
+
rows = [[str(r.get(c, "")) for c in cols] for r in records]
|
|
67
|
+
out.table(
|
|
68
|
+
title=f"{model} ({len(records)} records)",
|
|
69
|
+
columns=[(c, "") for c in cols],
|
|
70
|
+
rows=rows,
|
|
71
|
+
data_for_json=records,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# call
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
@app.command()
|
|
79
|
+
def call(
|
|
80
|
+
ctx: typer.Context,
|
|
81
|
+
model: Annotated[str, typer.Argument(help="Odoo model name.")],
|
|
82
|
+
method: Annotated[str, typer.Argument(help="Method to call.")],
|
|
83
|
+
args_json: Annotated[str, typer.Option("--args", "-a", help="JSON args array.")] = "[]",
|
|
84
|
+
kwargs_json: Annotated[str, typer.Option("--kwargs", "-k", help="JSON kwargs object.")] = "{}",
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Call an Odoo model method via POST /api/v1/odoo/call (admin-only)."""
|
|
87
|
+
actx: AppContext = ctx.obj
|
|
88
|
+
out = actx.output
|
|
89
|
+
|
|
90
|
+
import json
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
args = json.loads(args_json)
|
|
94
|
+
kwargs = json.loads(kwargs_json)
|
|
95
|
+
except json.JSONDecodeError as e:
|
|
96
|
+
out.error(f"Invalid JSON: {e}")
|
|
97
|
+
raise typer.Exit(1) from None
|
|
98
|
+
|
|
99
|
+
payload: dict = {"model": model, "method": method, "args": args, "kwargs": kwargs}
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
result = actx.client.post(f"{_BASE}/call", json=payload)
|
|
103
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
104
|
+
out.error(str(e))
|
|
105
|
+
raise typer.Exit(1) from None
|
|
106
|
+
|
|
107
|
+
out.success(f"Called {model}.{method}")
|
|
108
|
+
if actx.json_mode:
|
|
109
|
+
out.raw_json(result)
|
|
110
|
+
elif result:
|
|
111
|
+
out.text(str(result))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# create
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
@app.command()
|
|
118
|
+
def create(
|
|
119
|
+
ctx: typer.Context,
|
|
120
|
+
model: Annotated[str, typer.Argument(help="Odoo model name.")],
|
|
121
|
+
values_json: Annotated[str, typer.Argument(help="JSON object with field values.")],
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Create an Odoo record via POST /api/v1/odoo/create (admin-only)."""
|
|
124
|
+
actx: AppContext = ctx.obj
|
|
125
|
+
out = actx.output
|
|
126
|
+
|
|
127
|
+
import json
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
values = json.loads(values_json)
|
|
131
|
+
except json.JSONDecodeError as e:
|
|
132
|
+
out.error(f"Invalid JSON: {e}")
|
|
133
|
+
raise typer.Exit(1) from None
|
|
134
|
+
|
|
135
|
+
payload: dict = {"model": model, "values": values}
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
result = actx.client.post(f"{_BASE}/create", json=payload)
|
|
139
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
140
|
+
out.error(str(e))
|
|
141
|
+
raise typer.Exit(1) from None
|
|
142
|
+
|
|
143
|
+
out.success(f"Created {model} record.")
|
|
144
|
+
if actx.json_mode:
|
|
145
|
+
out.raw_json(result)
|
|
146
|
+
elif result:
|
|
147
|
+
out.text(str(result))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# write
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
@app.command()
|
|
154
|
+
def write(
|
|
155
|
+
ctx: typer.Context,
|
|
156
|
+
model: Annotated[str, typer.Argument(help="Odoo model name.")],
|
|
157
|
+
record_id: Annotated[int, typer.Argument(help="Record ID to update.")],
|
|
158
|
+
values_json: Annotated[str, typer.Argument(help="JSON object with field values.")],
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Update an Odoo record via POST /api/v1/odoo/write (admin-only)."""
|
|
161
|
+
actx: AppContext = ctx.obj
|
|
162
|
+
out = actx.output
|
|
163
|
+
|
|
164
|
+
import json
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
values = json.loads(values_json)
|
|
168
|
+
except json.JSONDecodeError as e:
|
|
169
|
+
out.error(f"Invalid JSON: {e}")
|
|
170
|
+
raise typer.Exit(1) from None
|
|
171
|
+
|
|
172
|
+
payload: dict = {"model": model, "ids": [record_id], "values": values}
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
result = actx.client.post(f"{_BASE}/write", json=payload)
|
|
176
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
177
|
+
out.error(str(e))
|
|
178
|
+
raise typer.Exit(1) from None
|
|
179
|
+
|
|
180
|
+
out.success(f"Updated {model} record {record_id}.")
|
|
181
|
+
if actx.json_mode:
|
|
182
|
+
out.raw_json(result)
|