operx-cli 0.5.0__tar.gz → 0.6.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.
- {operx_cli-0.5.0 → operx_cli-0.6.0}/PKG-INFO +13 -7
- {operx_cli-0.5.0 → operx_cli-0.6.0}/README.md +12 -6
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/AGENTES.md +60 -13
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/__init__.py +1 -1
- operx_cli-0.6.0/operx_cli/api_fields.py +232 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/ambiental.py +12 -6
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/articulos.py +5 -2
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/avisos.py +4 -2
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/common.py +22 -2
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/empresas.py +6 -6
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/inventario.py +19 -12
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/lecturas.py +9 -5
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/ordenes.py +20 -12
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/personas.py +13 -4
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/planta.py +14 -4
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/preventivos.py +7 -3
- operx_cli-0.6.0/operx_cli/commands/raw.py +288 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/registros.py +14 -9
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/sst.py +4 -2
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/PKG-INFO +13 -7
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/SOURCES.txt +3 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/pyproject.toml +1 -1
- operx_cli-0.6.0/tests/test_api_campos.py +166 -0
- operx_cli-0.6.0/tests/test_json_writes.py +184 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_logout_and_routes.py +25 -2
- operx_cli-0.5.0/operx_cli/commands/raw.py +0 -151
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/agent_guide.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/app.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/browser_login.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/client.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/__init__.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/account.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/completar.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/reportes.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/search.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/update.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/lookup.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/output.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/record_completion.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/self_update.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/settings.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/shell.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/spreadsheet.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/updates.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/values.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/welcome.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/dependency_links.txt +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/entry_points.txt +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/requires.txt +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/top_level.txt +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/setup.cfg +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_agentes.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_app.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_articulos.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_cards.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_client.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_completar.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_inventario_lecturas.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_login.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_lookup.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_more_of_the_api.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_ordenes.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_output.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_personas.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_preventivos.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_record_completion.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_registros.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_self_update.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_settings.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_shell.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_spreadsheet.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_sst_ambiental.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_updates.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_values.py +0 -0
- {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_welcome.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: operx-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -196,7 +196,9 @@ llevas el SG-SST o lo ambiental de tus clientes, `operx sst empresas` y
|
|
|
196
196
|
### Antes de escribir
|
|
197
197
|
|
|
198
198
|
Todo lo que crea o cambia algo pregunta antes. En un script o una tarea
|
|
199
|
-
programada no hay a quién preguntar: agrega `--si`.
|
|
199
|
+
programada no hay a quién preguntar: agrega `--si`. Con `--json`, lo que
|
|
200
|
+
escribe responde el registro creado o cambiado (con su `id`) en vez de una
|
|
201
|
+
frase. Cada escritura queda en
|
|
200
202
|
el historial del token (Acceso por API › el token › Actividad) marcada como
|
|
201
203
|
hecha desde la CLI.
|
|
202
204
|
|
|
@@ -211,13 +213,17 @@ Usuarios y acceso.
|
|
|
211
213
|
|
|
212
214
|
### Cualquier otra llamada
|
|
213
215
|
|
|
214
|
-
Lo que no tiene comando propio se hace con `operx api
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
Lo que no tiene comando propio se hace con `operx api`: todo lo que tu
|
|
217
|
+
token puede hacer, más de 400 llamadas. `operx api rutas` las lista
|
|
218
|
+
(`operx api rutas sst inspections` deja las que tienen esas palabras), y
|
|
219
|
+
`operx api campos RUTA` dice qué lleva una: sus filtros, cada campo con su
|
|
220
|
+
tipo, si es obligatorio y los valores permitidos. También están en Acceso
|
|
221
|
+
por API › Documentación:
|
|
218
222
|
|
|
219
223
|
```sh
|
|
220
|
-
operx api rutas
|
|
224
|
+
operx api rutas repuestos
|
|
225
|
+
operx api campos inventory/work-orders/1234/parts/ --metodo POST
|
|
226
|
+
operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}'
|
|
221
227
|
operx api GET pm/schedules/ -q search=compresor
|
|
222
228
|
operx api POST contacts/ -d @contacto.json
|
|
223
229
|
operx api GET work-orders/export/ -o ordenes.csv
|
|
@@ -182,7 +182,9 @@ llevas el SG-SST o lo ambiental de tus clientes, `operx sst empresas` y
|
|
|
182
182
|
### Antes de escribir
|
|
183
183
|
|
|
184
184
|
Todo lo que crea o cambia algo pregunta antes. En un script o una tarea
|
|
185
|
-
programada no hay a quién preguntar: agrega `--si`.
|
|
185
|
+
programada no hay a quién preguntar: agrega `--si`. Con `--json`, lo que
|
|
186
|
+
escribe responde el registro creado o cambiado (con su `id`) en vez de una
|
|
187
|
+
frase. Cada escritura queda en
|
|
186
188
|
el historial del token (Acceso por API › el token › Actividad) marcada como
|
|
187
189
|
hecha desde la CLI.
|
|
188
190
|
|
|
@@ -197,13 +199,17 @@ Usuarios y acceso.
|
|
|
197
199
|
|
|
198
200
|
### Cualquier otra llamada
|
|
199
201
|
|
|
200
|
-
Lo que no tiene comando propio se hace con `operx api
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
Lo que no tiene comando propio se hace con `operx api`: todo lo que tu
|
|
203
|
+
token puede hacer, más de 400 llamadas. `operx api rutas` las lista
|
|
204
|
+
(`operx api rutas sst inspections` deja las que tienen esas palabras), y
|
|
205
|
+
`operx api campos RUTA` dice qué lleva una: sus filtros, cada campo con su
|
|
206
|
+
tipo, si es obligatorio y los valores permitidos. También están en Acceso
|
|
207
|
+
por API › Documentación:
|
|
204
208
|
|
|
205
209
|
```sh
|
|
206
|
-
operx api rutas
|
|
210
|
+
operx api rutas repuestos
|
|
211
|
+
operx api campos inventory/work-orders/1234/parts/ --metodo POST
|
|
212
|
+
operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}'
|
|
207
213
|
operx api GET pm/schedules/ -q search=compresor
|
|
208
214
|
operx api POST contacts/ -d @contacto.json
|
|
209
215
|
operx api GET work-orders/export/ -o ordenes.csv
|
|
@@ -9,8 +9,9 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
|
|
|
9
9
|
|
|
10
10
|
1. Escribe siempre el comando completo (`operx ordenes listar …`). No abras el
|
|
11
11
|
modo interactivo: `operx` solo, fuera de una terminal, solo muestra la ayuda.
|
|
12
|
-
2.
|
|
13
|
-
|
|
12
|
+
2. Usa `--json` siempre: al leer es lo que responde la API, completo; al
|
|
13
|
+
escribir es el registro creado o cambiado, con su `id`. Las tablas y las
|
|
14
|
+
frases son para personas.
|
|
14
15
|
3. Antes de crear o cambiar algo, dile a la persona qué vas a hacer y espera
|
|
15
16
|
su sí. Con su sí, agrega `--si`: sin él, toda escritura falla fuera de una
|
|
16
17
|
terminal.
|
|
@@ -20,7 +21,9 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
|
|
|
20
21
|
consulta primero si quedó hecha.
|
|
21
22
|
6. Si un nombre coincide con varios registros, `operx` lo dice con sus ids:
|
|
22
23
|
usa el id, no adivines.
|
|
23
|
-
7. Usa un comando propio cuando exista
|
|
24
|
+
7. Usa un comando propio cuando exista: acepta nombres y fechas como los
|
|
25
|
+
escribe la persona. Para todo lo demás está `operx api` (abajo): con él
|
|
26
|
+
puedes hacer todo lo que el token permite.
|
|
24
27
|
8. `operx update` y `operx completar` cambian el computador de la persona, no
|
|
25
28
|
los datos de su empresa: son para ella, no los ejecutes.
|
|
26
29
|
|
|
@@ -54,6 +57,8 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
|
|
|
54
57
|
|
|
55
58
|
## Escribir
|
|
56
59
|
|
|
60
|
+
- Todos los comandos que escriben aceptan `--json`: la salida es solo la
|
|
61
|
+
respuesta de la API (el registro, con su `id`). Úsalo para encadenar pasos.
|
|
57
62
|
- Escriben: `ordenes crear`, `ordenes editar`, `ordenes estado`,
|
|
58
63
|
`ordenes asignar`, `ordenes comentar`, `ordenes adjuntar`,
|
|
59
64
|
`preventivos crear`, `preventivos nota`, `equipos crear`, `equipos editar`,
|
|
@@ -110,13 +115,46 @@ Lo que responde la API:
|
|
|
110
115
|
- **429**: demasiadas llamadas (120 por minuto por token, 600 por empresa).
|
|
111
116
|
`operx` espera y reintenta solo, hasta 3 veces.
|
|
112
117
|
|
|
113
|
-
##
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
## Todo lo demás: `operx api`
|
|
119
|
+
|
|
120
|
+
El token puede hacer más de 400 llamadas; los comandos propios cubren las
|
|
121
|
+
del día a día. Todas las demás se hacen con `operx api MÉTODO RUTA`, en
|
|
122
|
+
cinco pasos:
|
|
123
|
+
|
|
124
|
+
1. **Busca la ruta.** `operx api rutas TEXTO --json` deja las que tienen
|
|
125
|
+
cada palabra del texto en la ruta, en su `producto` (SG-SST, Inventario…)
|
|
126
|
+
o en lo que hace. Las rutas están en inglés: busca también así
|
|
127
|
+
(`operx api rutas sst inspections`, `operx api rutas repuestos`).
|
|
128
|
+
2. **Lee lo que lleva.** `operx api campos RUTA --json` (con `--metodo POST`
|
|
129
|
+
para uno solo) da, por método:
|
|
130
|
+
- `en_la_ruta`: los valores de la dirección, como `{pk}`.
|
|
131
|
+
- `filtros`: lo que va en `-q nombre=valor`.
|
|
132
|
+
- `datos`: cada campo del cuerpo, con su `tipo`, su `formato`, si es
|
|
133
|
+
`obligatorio`, y en `valores` los permitidos con su etiqueta en español
|
|
134
|
+
(para entender lo que dice la persona). `lines[].item` es un campo de
|
|
135
|
+
cada elemento de la lista `lines`.
|
|
136
|
+
- `envio`: `json` (todo en `-d`) o `archivos` (el archivo con
|
|
137
|
+
`-a campo=ruta`, lo demás en `-d`).
|
|
138
|
+
- `responde`: los campos de la respuesta.
|
|
139
|
+
3. **Busca los ids.** La API toma ids, no nombres: búscalos con un comando
|
|
140
|
+
(`operx equipos listar --buscar bomba --json`) o con
|
|
141
|
+
`operx api GET RUTA -q search=TEXTO`.
|
|
142
|
+
4. **Escribe**, después del sí de la persona: `-d '{"campo": valor}'` y `--si`.
|
|
143
|
+
5. **Lee la respuesta**: es el registro creado o cambiado.
|
|
144
|
+
|
|
145
|
+
En `-d` los datos van como los toma la API, no como en los comandos propios:
|
|
146
|
+
|
|
147
|
+
- Decimales como texto con punto: `"2.5"`, nunca `2,5`.
|
|
148
|
+
- Fechas `"2026-10-30"`; fecha y hora con su zona:
|
|
149
|
+
`"2026-10-30T08:00:00-05:00"`.
|
|
150
|
+
- Ids, no nombres: `"item": 3`, no `"item": "FIL-010"`.
|
|
151
|
+
- Los valores de una lista, en inglés, como salen en `valores`:
|
|
152
|
+
`"priority": "high"`.
|
|
153
|
+
|
|
154
|
+
Si la API responde 400, el mensaje dice qué campo falló y por qué; corrige
|
|
155
|
+
eso, no lo demás.
|
|
156
|
+
|
|
157
|
+
Más opciones de `operx api`:
|
|
120
158
|
|
|
121
159
|
- `-q nombre=valor`: un filtro. Se repite.
|
|
122
160
|
- `-d '{"campo": "valor"}'` o `-d @archivo.json`: el cuerpo de la llamada.
|
|
@@ -125,9 +163,9 @@ sin barra inicial.
|
|
|
125
163
|
- `-o archivo`: guarda una respuesta que es un archivo (CSV, PDF).
|
|
126
164
|
|
|
127
165
|
```sh
|
|
128
|
-
operx api rutas
|
|
129
|
-
operx api
|
|
130
|
-
operx api POST
|
|
166
|
+
operx api rutas repuestos --json
|
|
167
|
+
operx api campos inventory/work-orders/1234/parts/ --metodo POST --json
|
|
168
|
+
operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}' --si
|
|
131
169
|
operx api GET work-orders/export/ -o ordenes.csv
|
|
132
170
|
```
|
|
133
171
|
|
|
@@ -155,6 +193,15 @@ operx inventario entrada --articulo CON-0001 --lugar "Bodega central" --cantidad
|
|
|
155
193
|
# Un equipo que entró a mantenimiento
|
|
156
194
|
operx equipos editar BOM-04 --estado en-mantenimiento --si
|
|
157
195
|
|
|
196
|
+
# Un repuesto usado en una orden (no tiene comando propio): ruta, campos, ids, escribir
|
|
197
|
+
operx api rutas repuestos --json
|
|
198
|
+
operx api campos inventory/work-orders/1234/parts/ --metodo POST --json
|
|
199
|
+
operx inventario existencias --buscar filtro --json
|
|
200
|
+
operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}' --si
|
|
201
|
+
|
|
202
|
+
# Crear y seguir con el id que responde
|
|
203
|
+
operx ordenes crear --titulo "Cambio de rodamiento" --equipo BOM-04 --si --json
|
|
204
|
+
|
|
158
205
|
# Los equipos en un CSV para Excel
|
|
159
206
|
operx equipos listar --todas --csv > equipos.csv
|
|
160
207
|
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""What a route takes, read from the OpenAPI schema the API serves a token
|
|
2
|
+
(auth/api-tokens/schema/, drf-spectacular): the values in its address, its
|
|
3
|
+
filters, the fields of its body and what it answers. `operx api campos`
|
|
4
|
+
prints it, so an AI agent can call any route with `operx api` without
|
|
5
|
+
guessing; no network here, so the MCP server can use it as it is.
|
|
6
|
+
|
|
7
|
+
The schema's shapes this follows: a field's type behind a $ref; an allOf
|
|
8
|
+
of one $ref (a nested record); a oneOf of an enum with BlankEnum and
|
|
9
|
+
NullEnum (a choice that may be "" or null); each enum's labels in its
|
|
10
|
+
description, one "* `value` - Label" a line.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
METHODS = ("get", "post", "put", "patch", "delete")
|
|
16
|
+
# A field inside a list inside a list… is shown this many levels deep.
|
|
17
|
+
MAX_DEPTH = 3
|
|
18
|
+
LABEL_LINE = re.compile(r"^\* `([^`]*)` - (.+)$", re.MULTILINE)
|
|
19
|
+
DECIMALS = re.compile(r"\\\.\\d\{0,(\d+)\}")
|
|
20
|
+
# Django's integer field bounds: every integer has them, they say nothing.
|
|
21
|
+
INT32 = (-2147483648, 2147483647)
|
|
22
|
+
PAGE_FIELDS = {"count", "next", "previous", "results"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_route(schema, route):
|
|
26
|
+
"""The schema's path for a route as a person or an agent types it: with
|
|
27
|
+
real ids (work-orders/1234/) or the placeholders `operx api rutas` prints
|
|
28
|
+
({pk}). A fixed word beats a placeholder, so items/siguiente-codigo/
|
|
29
|
+
isn't items/{pk}/. LookupError when no route fits."""
|
|
30
|
+
wanted = _segments(route)
|
|
31
|
+
best = None
|
|
32
|
+
for path in schema.get("paths", {}):
|
|
33
|
+
segments = _segments(path)
|
|
34
|
+
if len(segments) != len(wanted):
|
|
35
|
+
continue
|
|
36
|
+
fixed = 0
|
|
37
|
+
for template, given in zip(segments, wanted):
|
|
38
|
+
if template.startswith("{"):
|
|
39
|
+
continue
|
|
40
|
+
if template != given:
|
|
41
|
+
break
|
|
42
|
+
fixed += 1
|
|
43
|
+
else:
|
|
44
|
+
if best is None or fixed > best[0]:
|
|
45
|
+
best = (fixed, path)
|
|
46
|
+
if best is None:
|
|
47
|
+
raise LookupError(route)
|
|
48
|
+
return best[1]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _segments(route):
|
|
52
|
+
route = route.strip().split("?")[0].strip("/")
|
|
53
|
+
route = route.removeprefix("api/v1/")
|
|
54
|
+
return route.split("/")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def describe(schema, path, method=None):
|
|
58
|
+
"""Each operation of the route (or only `method`): a dict an agent reads
|
|
59
|
+
as JSON and `operx api campos` prints as tables."""
|
|
60
|
+
components = schema.get("components", {}).get("schemas", {})
|
|
61
|
+
operations = schema["paths"][path]
|
|
62
|
+
described = []
|
|
63
|
+
for name in METHODS:
|
|
64
|
+
if name not in operations or (method and method.lower() != name):
|
|
65
|
+
continue
|
|
66
|
+
operation = operations[name]
|
|
67
|
+
parameters = operation.get("parameters", [])
|
|
68
|
+
body_schema, sending = _body(operation)
|
|
69
|
+
described.append({
|
|
70
|
+
"metodo": name.upper(),
|
|
71
|
+
"ruta": path.removeprefix("/api/v1/"),
|
|
72
|
+
"descripcion": (operation.get("description") or "").strip(),
|
|
73
|
+
"en_la_ruta": [_parameter(components, parameter) for parameter in parameters if parameter.get("in") == "path"],
|
|
74
|
+
"filtros": [_parameter(components, parameter) for parameter in parameters if parameter.get("in") == "query"],
|
|
75
|
+
"datos": _fields(components, body_schema) if body_schema else [],
|
|
76
|
+
"envio": sending if body_schema else None,
|
|
77
|
+
"responde": _answer(components, operation),
|
|
78
|
+
})
|
|
79
|
+
return described
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def methods_of(schema, path):
|
|
83
|
+
return [name.upper() for name in METHODS if name in schema["paths"][path]]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _body(operation):
|
|
87
|
+
"""The body's schema and how it goes: JSON (-d), or a form with a file
|
|
88
|
+
(-a for the file, -d for the rest)."""
|
|
89
|
+
content = operation.get("requestBody", {}).get("content", {})
|
|
90
|
+
if "application/json" in content:
|
|
91
|
+
return content["application/json"].get("schema"), "json"
|
|
92
|
+
for kind in ("multipart/form-data", "application/x-www-form-urlencoded"):
|
|
93
|
+
if kind in content:
|
|
94
|
+
return content[kind].get("schema"), "archivos"
|
|
95
|
+
return None, None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve(components, node):
|
|
99
|
+
"""A field's schema with its $ref, its one-ref allOf and its
|
|
100
|
+
enum-or-blank-or-null oneOf followed. What the wrapper says (nullable,
|
|
101
|
+
readOnly, its own description) stays; an enum's description becomes
|
|
102
|
+
its labels."""
|
|
103
|
+
node = dict(node or {})
|
|
104
|
+
if "$ref" in node:
|
|
105
|
+
name = node.pop("$ref").split("/")[-1]
|
|
106
|
+
target = _labelled(components.get(name, {}))
|
|
107
|
+
node = {**target, **node, "_name": name}
|
|
108
|
+
if len(node.get("allOf", [])) == 1:
|
|
109
|
+
inner = _resolve(components, node.pop("allOf")[0])
|
|
110
|
+
node = {**inner, **node}
|
|
111
|
+
if "oneOf" in node:
|
|
112
|
+
choices = [_resolve(components, choice) for choice in node.pop("oneOf")]
|
|
113
|
+
kept = []
|
|
114
|
+
for choice in choices:
|
|
115
|
+
if choice.get("enum") == [""] or choice.get("maxLength") == 0:
|
|
116
|
+
node["_blank"] = True
|
|
117
|
+
elif choice.get("enum") == [None]:
|
|
118
|
+
node["nullable"] = True
|
|
119
|
+
else:
|
|
120
|
+
kept.append(choice)
|
|
121
|
+
if kept:
|
|
122
|
+
node = {**kept[0], **node}
|
|
123
|
+
if node.get("type") == "array" and "items" in node:
|
|
124
|
+
# One level only: a record's own fields are followed by _fields.
|
|
125
|
+
node["items"] = _resolve(components, node["items"]) if "$ref" in node["items"] else node["items"]
|
|
126
|
+
return node
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _labelled(node):
|
|
130
|
+
"""An enum's "* `low` - Baja" description as its labels."""
|
|
131
|
+
if "enum" not in node:
|
|
132
|
+
return node
|
|
133
|
+
node = dict(node)
|
|
134
|
+
labels = dict(LABEL_LINE.findall(node.pop("description", "") or ""))
|
|
135
|
+
node["_labels"] = labels
|
|
136
|
+
return node
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _fields(components, body_schema, prefix="", depth=0, seen=frozenset()):
|
|
140
|
+
record = _resolve(components, body_schema)
|
|
141
|
+
required = set(record.get("required", []))
|
|
142
|
+
rows = []
|
|
143
|
+
for name, value in record.get("properties", {}).items():
|
|
144
|
+
field = _resolve(components, value)
|
|
145
|
+
if field.get("readOnly"):
|
|
146
|
+
continue
|
|
147
|
+
path = prefix + name
|
|
148
|
+
rows.append(_row(path, field, name in required))
|
|
149
|
+
if depth >= MAX_DEPTH:
|
|
150
|
+
continue
|
|
151
|
+
if field.get("type") == "array":
|
|
152
|
+
items = _resolve(components, field.get("items"))
|
|
153
|
+
if "properties" in items and items.get("_name") not in seen:
|
|
154
|
+
rows.extend(_fields(components, items, f"{path}[].", depth + 1, seen | {items.get("_name")}))
|
|
155
|
+
elif "properties" in field and field.get("_name") not in seen:
|
|
156
|
+
rows.extend(_fields(components, field, f"{path}.", depth + 1, seen | {field.get("_name")}))
|
|
157
|
+
return rows
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _row(name, field, required):
|
|
161
|
+
"""One field: its JSON type and format, and only what applies to it."""
|
|
162
|
+
row = {"campo": name, "tipo": field.get("type") or ("string" if "enum" in field else None), "obligatorio": required}
|
|
163
|
+
if field.get("format"):
|
|
164
|
+
row["formato"] = field["format"]
|
|
165
|
+
if field.get("type") == "array":
|
|
166
|
+
items = field.get("items") or {}
|
|
167
|
+
row["de"] = "object" if "properties" in items else items.get("type") or ("string" if "enum" in items else None)
|
|
168
|
+
values = _values(field)
|
|
169
|
+
if field.get("type") == "array" and not values:
|
|
170
|
+
values = _values(field.get("items") or {})
|
|
171
|
+
if values:
|
|
172
|
+
row["valores"] = values
|
|
173
|
+
if field.get("nullable"):
|
|
174
|
+
row["nulo"] = True
|
|
175
|
+
if field.get("_blank"):
|
|
176
|
+
row["vacio"] = True
|
|
177
|
+
if "default" in field:
|
|
178
|
+
row["por_defecto"] = field["default"]
|
|
179
|
+
if field.get("maxLength"):
|
|
180
|
+
row["maximo_largo"] = field["maxLength"]
|
|
181
|
+
for key, label in (("minimum", "minimo"), ("maximum", "maximo")):
|
|
182
|
+
if key in field and field[key] not in INT32:
|
|
183
|
+
row[label] = field[key]
|
|
184
|
+
decimals = DECIMALS.search(field.get("pattern", "")) if field.get("format") == "decimal" else None
|
|
185
|
+
if decimals:
|
|
186
|
+
row["decimales"] = int(decimals.group(1))
|
|
187
|
+
if field.get("description"):
|
|
188
|
+
row["nota"] = field["description"].strip()
|
|
189
|
+
return row
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _values(field):
|
|
193
|
+
labels = field.get("_labels") or {}
|
|
194
|
+
values = [value for value in field.get("enum", []) if value not in (None, "")]
|
|
195
|
+
return [{"valor": value, "etiqueta": labels.get(value, value)} for value in values]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _parameter(components, parameter):
|
|
199
|
+
"""A filter or a value in the address. A filter's labels come in its own
|
|
200
|
+
description, not in its schema's."""
|
|
201
|
+
field = _resolve(components, parameter.get("schema"))
|
|
202
|
+
description = (parameter.get("description") or "").strip()
|
|
203
|
+
labels = dict(LABEL_LINE.findall(description))
|
|
204
|
+
if "enum" in field and labels:
|
|
205
|
+
field["_labels"] = labels
|
|
206
|
+
description = ""
|
|
207
|
+
if description:
|
|
208
|
+
field["description"] = description
|
|
209
|
+
row = _row(parameter["name"], field, bool(parameter.get("required")))
|
|
210
|
+
if parameter.get("in") == "path":
|
|
211
|
+
# Only the name and type: it's written in the address itself.
|
|
212
|
+
return {key: row[key] for key in ("campo", "tipo", "obligatorio")}
|
|
213
|
+
return row
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _answer(components, operation):
|
|
217
|
+
"""What a call answers: its status, whether it comes by pages, and the
|
|
218
|
+
names of a record's fields."""
|
|
219
|
+
for status, response in operation.get("responses", {}).items():
|
|
220
|
+
if not status.startswith("2"):
|
|
221
|
+
continue
|
|
222
|
+
schema = (response.get("content", {}).get("application/json") or {}).get("schema")
|
|
223
|
+
if schema is None:
|
|
224
|
+
return {"estado": int(status), "pagina": False, "campos": []}
|
|
225
|
+
record = _resolve(components, schema)
|
|
226
|
+
paged = PAGE_FIELDS <= set(record.get("properties", {}))
|
|
227
|
+
if paged:
|
|
228
|
+
record = _resolve(components, record["properties"]["results"])
|
|
229
|
+
if record.get("type") == "array":
|
|
230
|
+
record = _resolve(components, record.get("items"))
|
|
231
|
+
return {"estado": int(status), "pagina": paged, "campos": list(record.get("properties", {}))}
|
|
232
|
+
return {"estado": None, "pagina": False, "campos": []}
|
|
@@ -15,12 +15,14 @@ from .common import (
|
|
|
15
15
|
CsvOption,
|
|
16
16
|
JsonOption,
|
|
17
17
|
YesOption,
|
|
18
|
+
answer_of,
|
|
18
19
|
client_of,
|
|
19
20
|
confirm_write,
|
|
20
21
|
day,
|
|
21
22
|
month,
|
|
22
23
|
number,
|
|
23
24
|
output_format,
|
|
25
|
+
print_result,
|
|
24
26
|
)
|
|
25
27
|
from .empresas import (
|
|
26
28
|
ENVIRONMENT,
|
|
@@ -71,9 +73,10 @@ def nueva_accion(
|
|
|
71
73
|
tipo: Annotated[TipoAccion, typer.Option("--tipo")] = TipoAccion.correctiva,
|
|
72
74
|
empresa: EmpresaOption = None,
|
|
73
75
|
si: YesOption = False,
|
|
76
|
+
as_json: JsonOption = False,
|
|
74
77
|
):
|
|
75
78
|
"""Agrega una acción al plan de acción ambiental."""
|
|
76
|
-
create_action(ctx, ENVIRONMENT, titulo, para, responsable, tipo, empresa, si)
|
|
79
|
+
create_action(ctx, ENVIRONMENT, titulo, para, responsable, tipo, empresa, si, as_json)
|
|
77
80
|
|
|
78
81
|
|
|
79
82
|
@app.command("accion-hecha")
|
|
@@ -83,9 +86,10 @@ def accion_hecha(
|
|
|
83
86
|
evidencia: Annotated[str, typer.Option("--evidencia", help="Qué se hizo.")],
|
|
84
87
|
empresa: EmpresaOption = None,
|
|
85
88
|
si: YesOption = False,
|
|
89
|
+
as_json: JsonOption = False,
|
|
86
90
|
):
|
|
87
91
|
"""Marca hecha una acción, con lo que se hizo. Verificarla se hace en la app."""
|
|
88
|
-
mark_done(ctx, ENVIRONMENT, accion, evidencia, empresa, si)
|
|
92
|
+
mark_done(ctx, ENVIRONMENT, accion, evidencia, empresa, si, as_json)
|
|
89
93
|
|
|
90
94
|
|
|
91
95
|
@app.command()
|
|
@@ -126,6 +130,7 @@ def residuo(
|
|
|
126
130
|
sede: Annotated[Optional[str], typer.Option("--sede", help="La sede, si dos tienen una corriente con ese nombre.")] = None,
|
|
127
131
|
empresa: EmpresaOption = None,
|
|
128
132
|
si: YesOption = False,
|
|
133
|
+
as_json: JsonOption = False,
|
|
129
134
|
):
|
|
130
135
|
"""Registra los kilos de residuos generados (pesados)."""
|
|
131
136
|
client = client_of(ctx)
|
|
@@ -133,8 +138,8 @@ def residuo(
|
|
|
133
138
|
stream = _stream(client, path, corriente, sede)
|
|
134
139
|
entry = {"stream": stream["id"], "date": day(fecha) or date.today().isoformat(), "kg": number(kg)}
|
|
135
140
|
confirm_write(f"Registrar {kg} kg de «{stream['name']}» el {shown_moment(entry['date'])}.", si)
|
|
136
|
-
client.send("POST", f"{path}/waste/entries", json=entry)
|
|
137
|
-
|
|
141
|
+
recorded = answer_of(client.send("POST", f"{path}/waste/entries", json=entry))
|
|
142
|
+
print_result(recorded, as_json, f"Registrados {kg} kg de {stream['name']}.")
|
|
138
143
|
|
|
139
144
|
|
|
140
145
|
def _stream(client, path, corriente, sede):
|
|
@@ -163,6 +168,7 @@ def lectura(
|
|
|
163
168
|
fecha: FechaOption = None,
|
|
164
169
|
empresa: EmpresaOption = None,
|
|
165
170
|
si: YesOption = False,
|
|
171
|
+
as_json: JsonOption = False,
|
|
166
172
|
):
|
|
167
173
|
"""Registra la lectura de un medidor (agua, energía, gas)."""
|
|
168
174
|
client = client_of(ctx)
|
|
@@ -170,5 +176,5 @@ def lectura(
|
|
|
170
176
|
meter = find_in(client.get(f"{path}/consumption/meters"), medidor, ["name", "meter_name"], "medidor")
|
|
171
177
|
reading = {"source": meter["id"], "read_on": day(fecha) or date.today().isoformat(), "value": number(valor)}
|
|
172
178
|
confirm_write(f"Lectura {valor} en «{meter['name']}» el {shown_moment(reading['read_on'])}.", si)
|
|
173
|
-
client.send("POST", f"{path}/consumption/readings", json=reading)
|
|
174
|
-
|
|
179
|
+
recorded = answer_of(client.send("POST", f"{path}/consumption/readings", json=reading))
|
|
180
|
+
print_result(recorded, as_json, f"Lectura registrada: {valor} en {meter['name']}.")
|
|
@@ -10,7 +10,7 @@ from ..lookup import NoSingleMatch, find_in, find_one
|
|
|
10
10
|
from ..output import cell_text, print_card, print_record, print_rows
|
|
11
11
|
from .common import (
|
|
12
12
|
AllOption, CliError, CsvOption, JsonOption, SearchOption, YesOption, client_of, confirm_write, list_rows,
|
|
13
|
-
output_format, without_empty,
|
|
13
|
+
output_format, print_result, without_empty,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
app = typer.Typer(help="Artículos (Inventario): los repuestos, consumibles y herramientas que manejas.", no_args_is_help=True)
|
|
@@ -98,6 +98,7 @@ def crear(
|
|
|
98
98
|
fabricante: Annotated[Optional[str], typer.Option("--fabricante")] = None,
|
|
99
99
|
referencia: Annotated[Optional[str], typer.Option("--referencia", help="La referencia del fabricante.")] = None,
|
|
100
100
|
si: YesOption = False,
|
|
101
|
+
as_json: JsonOption = False,
|
|
101
102
|
):
|
|
102
103
|
"""Crea un artículo en el catálogo. Lo que hay se carga después con `inventario entrada`."""
|
|
103
104
|
client = client_of(ctx)
|
|
@@ -119,7 +120,9 @@ def crear(
|
|
|
119
120
|
})
|
|
120
121
|
confirm_write(f"Crear el artículo «{nombre}».", si)
|
|
121
122
|
created = client.send("POST", "inventory/items", json=item).json()
|
|
122
|
-
|
|
123
|
+
print_result(created, as_json, f"Artículo {created['code']} creado: {created['name']}.")
|
|
124
|
+
if as_json:
|
|
125
|
+
return
|
|
123
126
|
typer.echo(
|
|
124
127
|
f"Para cargar lo que hay: operx inventario entrada --articulo {created['code']} --lugar … --cantidad … --costo-unitario …"
|
|
125
128
|
)
|
|
@@ -14,11 +14,13 @@ from .common import (
|
|
|
14
14
|
CsvOption,
|
|
15
15
|
JsonOption,
|
|
16
16
|
YesOption,
|
|
17
|
+
answer_of,
|
|
17
18
|
client_of,
|
|
18
19
|
confirm_write,
|
|
19
20
|
day,
|
|
20
21
|
list_rows,
|
|
21
22
|
output_format,
|
|
23
|
+
print_result,
|
|
22
24
|
)
|
|
23
25
|
|
|
24
26
|
NOTICE_COLUMNS = [("when", "Cuándo"), ("title", "Aviso"), ("body", "Detalle"), ("read", "Leído")]
|
|
@@ -40,8 +42,8 @@ def avisos(
|
|
|
40
42
|
client = client_of(ctx)
|
|
41
43
|
if marcar_leidos:
|
|
42
44
|
confirm_write("Marcar todos tus avisos como leídos.", si)
|
|
43
|
-
client.send("POST", "notifications/mark-read")
|
|
44
|
-
|
|
45
|
+
marked = answer_of(client.send("POST", "notifications/mark-read"))
|
|
46
|
+
print_result(marked, as_json, "Listo: no te quedan avisos sin leer.")
|
|
45
47
|
return
|
|
46
48
|
output = output_format(as_json, as_csv)
|
|
47
49
|
rows = list_rows(client, "notifications", {"is_read": None if todos else "false"}, todas)
|
|
@@ -12,7 +12,7 @@ import typer
|
|
|
12
12
|
|
|
13
13
|
from .. import settings
|
|
14
14
|
from ..client import ApiClient
|
|
15
|
-
from ..output import note
|
|
15
|
+
from ..output import json_text, note
|
|
16
16
|
from ..values import decimal_text, iso_date, iso_datetime, month_text
|
|
17
17
|
|
|
18
18
|
NOT_SIGNED_IN = "No has entrado en este perfil. Escribe `operx login`."
|
|
@@ -103,7 +103,8 @@ def confirm_write(what, yes):
|
|
|
103
103
|
if not can_ask():
|
|
104
104
|
raise CliError(NEEDS_SI)
|
|
105
105
|
try:
|
|
106
|
-
|
|
106
|
+
# On stderr: `operx … --json | jq` must get only the API's answer.
|
|
107
|
+
answer = typer.prompt(f"{what} ¿Confirmas? (s/n)", default="n", show_default=False, err=True)
|
|
107
108
|
except typer.Abort:
|
|
108
109
|
raise CliError(NEEDS_SI) from None
|
|
109
110
|
if answer.strip().casefold() not in YES_ANSWERS:
|
|
@@ -147,3 +148,22 @@ def month(text):
|
|
|
147
148
|
def without_empty(fields):
|
|
148
149
|
"""The fields someone gave: an option left out isn't sent."""
|
|
149
150
|
return {key: value for key, value in fields.items() if value not in (None, "")}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def answer_of(response):
|
|
154
|
+
"""A write's answer as JSON; {} when the API sent nothing back."""
|
|
155
|
+
if not response.content:
|
|
156
|
+
return {}
|
|
157
|
+
try:
|
|
158
|
+
return response.json()
|
|
159
|
+
except ValueError:
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def print_result(answer, as_json, message):
|
|
164
|
+
"""What a write did. With --json, only the API's answer (the new record,
|
|
165
|
+
its id), for a script or an AI agent to read; else a line for a person."""
|
|
166
|
+
if as_json:
|
|
167
|
+
typer.echo(json_text(answer))
|
|
168
|
+
return
|
|
169
|
+
typer.echo(message)
|
|
@@ -12,7 +12,7 @@ import typer
|
|
|
12
12
|
from ..lookup import find_in
|
|
13
13
|
from ..output import note, print_rows
|
|
14
14
|
from ..values import shown_moment
|
|
15
|
-
from .common import CliError, client_of, confirm_write, day
|
|
15
|
+
from .common import CliError, answer_of, client_of, confirm_write, day, print_result
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@dataclass(frozen=True)
|
|
@@ -115,7 +115,7 @@ def print_actions(client, product, empresa, estado, output):
|
|
|
115
115
|
print_rows(ACTION_COLUMNS, actions, output)
|
|
116
116
|
|
|
117
117
|
|
|
118
|
-
def create_action(ctx, product, titulo, para, responsable, tipo, empresa, si):
|
|
118
|
+
def create_action(ctx, product, titulo, para, responsable, tipo, empresa, si, as_json=False):
|
|
119
119
|
client = client_of(ctx)
|
|
120
120
|
path = company_path(client, product, empresa)
|
|
121
121
|
owner_id, owner_name = owner_of(client, responsable)
|
|
@@ -126,12 +126,12 @@ def create_action(ctx, product, titulo, para, responsable, tipo, empresa, si):
|
|
|
126
126
|
action["owner_name"] = owner_name
|
|
127
127
|
confirm_write(f"Crear la acción «{titulo}», para el {shown_moment(action['due_on'])}, a cargo de {responsable}.", si)
|
|
128
128
|
created = client.send("POST", f"{path}/actions", json=action).json()
|
|
129
|
-
|
|
129
|
+
print_result(created, as_json, f"Acción {created['id']} creada: {created.get(product.title_field, titulo)}.")
|
|
130
130
|
|
|
131
131
|
|
|
132
|
-
def mark_done(ctx, product, accion, evidencia, empresa, si):
|
|
132
|
+
def mark_done(ctx, product, accion, evidencia, empresa, si, as_json=False):
|
|
133
133
|
client = client_of(ctx)
|
|
134
134
|
path = company_path(client, product, empresa)
|
|
135
135
|
confirm_write(f"Marcar hecha la acción {accion}.", si)
|
|
136
|
-
client.send("POST", f"{path}/actions/{accion}/done", json={"evidence": evidencia})
|
|
137
|
-
|
|
136
|
+
done = answer_of(client.send("POST", f"{path}/actions/{accion}/done", json={"evidence": evidencia}))
|
|
137
|
+
print_result(done, as_json, f"Acción {accion} hecha.")
|