operx-cli 0.1.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.1.0/PKG-INFO +136 -0
- operx_cli-0.1.0/README.md +123 -0
- operx_cli-0.1.0/operx_cli/__init__.py +5 -0
- operx_cli-0.1.0/operx_cli/app.py +87 -0
- operx_cli-0.1.0/operx_cli/client.py +134 -0
- operx_cli-0.1.0/operx_cli/commands/__init__.py +1 -0
- operx_cli-0.1.0/operx_cli/commands/account.py +68 -0
- operx_cli-0.1.0/operx_cli/commands/common.py +116 -0
- operx_cli-0.1.0/operx_cli/commands/inventario.py +105 -0
- operx_cli-0.1.0/operx_cli/commands/lecturas.py +57 -0
- operx_cli-0.1.0/operx_cli/commands/ordenes.py +187 -0
- operx_cli-0.1.0/operx_cli/commands/personas.py +107 -0
- operx_cli-0.1.0/operx_cli/commands/raw.py +114 -0
- operx_cli-0.1.0/operx_cli/commands/registros.py +192 -0
- operx_cli-0.1.0/operx_cli/commands/search.py +21 -0
- operx_cli-0.1.0/operx_cli/lookup.py +42 -0
- operx_cli-0.1.0/operx_cli/output.py +102 -0
- operx_cli-0.1.0/operx_cli/settings.py +90 -0
- operx_cli-0.1.0/operx_cli/spreadsheet.py +179 -0
- operx_cli-0.1.0/operx_cli/values.py +59 -0
- operx_cli-0.1.0/operx_cli.egg-info/PKG-INFO +136 -0
- operx_cli-0.1.0/operx_cli.egg-info/SOURCES.txt +37 -0
- operx_cli-0.1.0/operx_cli.egg-info/dependency_links.txt +1 -0
- operx_cli-0.1.0/operx_cli.egg-info/entry_points.txt +2 -0
- operx_cli-0.1.0/operx_cli.egg-info/requires.txt +7 -0
- operx_cli-0.1.0/operx_cli.egg-info/top_level.txt +1 -0
- operx_cli-0.1.0/pyproject.toml +33 -0
- operx_cli-0.1.0/setup.cfg +4 -0
- operx_cli-0.1.0/tests/test_app.py +171 -0
- operx_cli-0.1.0/tests/test_client.py +126 -0
- operx_cli-0.1.0/tests/test_inventario_lecturas.py +95 -0
- operx_cli-0.1.0/tests/test_lookup.py +50 -0
- operx_cli-0.1.0/tests/test_ordenes.py +102 -0
- operx_cli-0.1.0/tests/test_output.py +42 -0
- operx_cli-0.1.0/tests/test_personas.py +95 -0
- operx_cli-0.1.0/tests/test_registros.py +69 -0
- operx_cli-0.1.0/tests/test_settings.py +46 -0
- operx_cli-0.1.0/tests/test_spreadsheet.py +89 -0
- operx_cli-0.1.0/tests/test_values.py +43 -0
operx_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: operx-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: typer>=0.27
|
|
8
|
+
Requires-Dist: httpx>=0.28
|
|
9
|
+
Requires-Dist: keyring>=25
|
|
10
|
+
Requires-Dist: openpyxl>=3.1
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
13
|
+
|
|
14
|
+
# operx: OperX desde la terminal
|
|
15
|
+
|
|
16
|
+
`operx` usa la API de OperX con tu token personal: lo mismo que puedes hacer
|
|
17
|
+
en la app, desde una terminal, un script o una tarea programada. Actúa como
|
|
18
|
+
tú, con tu rol, y solo en tu empresa.
|
|
19
|
+
|
|
20
|
+
## Instalar
|
|
21
|
+
|
|
22
|
+
Necesitas Python 3.10 o más nuevo.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pipx install ./cli # desde esta carpeta del repositorio
|
|
26
|
+
operx --version
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Entrar
|
|
30
|
+
|
|
31
|
+
1. En la app, abre el menú de tu cuenta › **Acceso por API** y crea un token.
|
|
32
|
+
Elige *Lectura y escritura* si vas a crear o cambiar datos.
|
|
33
|
+
2. Guárdalo en tu equipo:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
operx entrar # pega el token; no se muestra
|
|
37
|
+
operx cuenta # quién eres, tu empresa y tu rol
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
El token queda en el llavero del sistema (en Windows, el Administrador de
|
|
41
|
+
credenciales), nunca en un archivo. En un servidor sin llavero, usa variables
|
|
42
|
+
de entorno: `OPERX_TOKEN` (el token) y, si no es producción, `OPERX_DIRECCION`.
|
|
43
|
+
|
|
44
|
+
Para otra dirección u otra empresa, usa un perfil:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
operx --perfil pruebas entrar --direccion https://…
|
|
48
|
+
operx --perfil pruebas ordenes listar
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`operx salir` borra el token de este equipo; para que deje de funcionar,
|
|
52
|
+
revócalo en Acceso por API.
|
|
53
|
+
|
|
54
|
+
## Comandos
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
operx buscar "bomba" # como la barra de búsqueda de la app
|
|
58
|
+
|
|
59
|
+
operx ordenes listar --estado en-progreso --asignada-a yo
|
|
60
|
+
operx ordenes ver 1234
|
|
61
|
+
operx ordenes crear --titulo "Cambio de rodamiento" --equipo BOM-04 --prioridad alta --vence 30/10/2026
|
|
62
|
+
operx ordenes estado 1234 completada
|
|
63
|
+
operx ordenes asignar 1234 "Pedro Pérez"
|
|
64
|
+
operx ordenes comentar 1234 "Llegó el repuesto"
|
|
65
|
+
|
|
66
|
+
operx equipos listar --buscar compresor
|
|
67
|
+
operx equipos ver BOM-04
|
|
68
|
+
operx equipos crear --nombre "Bomba 4" --codigo BOM-04 --lugar "Planta Norte"
|
|
69
|
+
operx clientes listar
|
|
70
|
+
operx clientes crear --nombre "Acme S.A.S." --nit 900123456
|
|
71
|
+
operx clientes crear --nombre "Filtros Ltda" --rol proveedor
|
|
72
|
+
operx lugares listar
|
|
73
|
+
|
|
74
|
+
operx inventario existencias --buscar filtro
|
|
75
|
+
operx inventario entrada --articulo FIL-010 --lugar "Bodega central" --cantidad 10 --costo-unitario 1.500,50
|
|
76
|
+
operx inventario salida --articulo FIL-010 --lugar "Bodega central" --cantidad 2 --motivo Consumo
|
|
77
|
+
|
|
78
|
+
operx lecturas puntos BOM-04
|
|
79
|
+
operx lecturas registrar --punto 12 --valor 73,4
|
|
80
|
+
|
|
81
|
+
operx personas importar empleados.xlsx # revisa el archivo, no guarda
|
|
82
|
+
operx personas importar empleados.xlsx --confirmar # guarda si ninguna fila tiene errores
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`operx --help` y `operx ordenes --help` muestran todas las opciones.
|
|
86
|
+
|
|
87
|
+
- Los equipos, lugares, clientes, artículos y personas se nombran por su
|
|
88
|
+
código o su nombre exacto. Si dos se llaman igual, `operx` lo dice y pide
|
|
89
|
+
el id.
|
|
90
|
+
- Los números y las fechas se escriben como en la app: `73,4`, `1.500,50`,
|
|
91
|
+
`30/10/2026`, `22/10/2026 08:00`.
|
|
92
|
+
- Las listas muestran la primera página; `--todas` las trae todas.
|
|
93
|
+
- `--json` da todo lo que responde la API; `--csv` da un CSV que Excel abre
|
|
94
|
+
bien (`operx equipos listar --todas --csv > equipos.csv`).
|
|
95
|
+
|
|
96
|
+
### Antes de escribir
|
|
97
|
+
|
|
98
|
+
Todo lo que crea o cambia algo pregunta antes. En un script o una tarea
|
|
99
|
+
programada no hay a quién preguntar: agrega `--si`. Cada escritura queda en
|
|
100
|
+
el historial del token (Acceso por API › el token › Actividad) marcada como
|
|
101
|
+
hecha desde la CLI.
|
|
102
|
+
|
|
103
|
+
### Importar personas
|
|
104
|
+
|
|
105
|
+
La primera columna con el nombre basta. `operx` reconoce las columnas
|
|
106
|
+
Nombres, Apellidos, Código, Cargo, Tipo de contrato, Fecha de ingreso, Fin de
|
|
107
|
+
contrato, Celular, Sede y las de contacto de emergencia, con o sin tildes.
|
|
108
|
+
Una columna con otro nombre se indica con `--mapa "Tu columna=campo"`.
|
|
109
|
+
Nadie recibe acceso a la app al importarlo: las invitaciones se envían en
|
|
110
|
+
Usuarios y acceso.
|
|
111
|
+
|
|
112
|
+
### Cualquier otra llamada
|
|
113
|
+
|
|
114
|
+
Lo que no tiene comando propio se hace con `operx api`, con las direcciones
|
|
115
|
+
de Acceso por API › Documentación (sin `/api/v1/`):
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
operx api GET pm/schedules/ -q search=compresor
|
|
119
|
+
operx api POST contacts/ -d @contacto.json
|
|
120
|
+
operx api GET work-orders/export/ -o ordenes.csv
|
|
121
|
+
operx api POST work-orders/1234/attachments/ -a file=foto.jpg -d '{"file_type": "photo"}'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
En Git Bash, una dirección que empieza por `/` se vuelve una ruta de Windows:
|
|
125
|
+
escríbela sin la barra inicial, como arriba.
|
|
126
|
+
|
|
127
|
+
## Para desarrollar
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
cd cli
|
|
131
|
+
python -m venv .venv && .venv/Scripts/python -m pip install -e ".[test]"
|
|
132
|
+
.venv/Scripts/python -m pytest
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`operx_cli/client.py` es lo único que habla con la red; el servidor MCP lo
|
|
136
|
+
usará igual.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# operx: OperX desde la terminal
|
|
2
|
+
|
|
3
|
+
`operx` usa la API de OperX con tu token personal: lo mismo que puedes hacer
|
|
4
|
+
en la app, desde una terminal, un script o una tarea programada. Actúa como
|
|
5
|
+
tú, con tu rol, y solo en tu empresa.
|
|
6
|
+
|
|
7
|
+
## Instalar
|
|
8
|
+
|
|
9
|
+
Necesitas Python 3.10 o más nuevo.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pipx install ./cli # desde esta carpeta del repositorio
|
|
13
|
+
operx --version
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Entrar
|
|
17
|
+
|
|
18
|
+
1. En la app, abre el menú de tu cuenta › **Acceso por API** y crea un token.
|
|
19
|
+
Elige *Lectura y escritura* si vas a crear o cambiar datos.
|
|
20
|
+
2. Guárdalo en tu equipo:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
operx entrar # pega el token; no se muestra
|
|
24
|
+
operx cuenta # quién eres, tu empresa y tu rol
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
El token queda en el llavero del sistema (en Windows, el Administrador de
|
|
28
|
+
credenciales), nunca en un archivo. En un servidor sin llavero, usa variables
|
|
29
|
+
de entorno: `OPERX_TOKEN` (el token) y, si no es producción, `OPERX_DIRECCION`.
|
|
30
|
+
|
|
31
|
+
Para otra dirección u otra empresa, usa un perfil:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
operx --perfil pruebas entrar --direccion https://…
|
|
35
|
+
operx --perfil pruebas ordenes listar
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`operx salir` borra el token de este equipo; para que deje de funcionar,
|
|
39
|
+
revócalo en Acceso por API.
|
|
40
|
+
|
|
41
|
+
## Comandos
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
operx buscar "bomba" # como la barra de búsqueda de la app
|
|
45
|
+
|
|
46
|
+
operx ordenes listar --estado en-progreso --asignada-a yo
|
|
47
|
+
operx ordenes ver 1234
|
|
48
|
+
operx ordenes crear --titulo "Cambio de rodamiento" --equipo BOM-04 --prioridad alta --vence 30/10/2026
|
|
49
|
+
operx ordenes estado 1234 completada
|
|
50
|
+
operx ordenes asignar 1234 "Pedro Pérez"
|
|
51
|
+
operx ordenes comentar 1234 "Llegó el repuesto"
|
|
52
|
+
|
|
53
|
+
operx equipos listar --buscar compresor
|
|
54
|
+
operx equipos ver BOM-04
|
|
55
|
+
operx equipos crear --nombre "Bomba 4" --codigo BOM-04 --lugar "Planta Norte"
|
|
56
|
+
operx clientes listar
|
|
57
|
+
operx clientes crear --nombre "Acme S.A.S." --nit 900123456
|
|
58
|
+
operx clientes crear --nombre "Filtros Ltda" --rol proveedor
|
|
59
|
+
operx lugares listar
|
|
60
|
+
|
|
61
|
+
operx inventario existencias --buscar filtro
|
|
62
|
+
operx inventario entrada --articulo FIL-010 --lugar "Bodega central" --cantidad 10 --costo-unitario 1.500,50
|
|
63
|
+
operx inventario salida --articulo FIL-010 --lugar "Bodega central" --cantidad 2 --motivo Consumo
|
|
64
|
+
|
|
65
|
+
operx lecturas puntos BOM-04
|
|
66
|
+
operx lecturas registrar --punto 12 --valor 73,4
|
|
67
|
+
|
|
68
|
+
operx personas importar empleados.xlsx # revisa el archivo, no guarda
|
|
69
|
+
operx personas importar empleados.xlsx --confirmar # guarda si ninguna fila tiene errores
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`operx --help` y `operx ordenes --help` muestran todas las opciones.
|
|
73
|
+
|
|
74
|
+
- Los equipos, lugares, clientes, artículos y personas se nombran por su
|
|
75
|
+
código o su nombre exacto. Si dos se llaman igual, `operx` lo dice y pide
|
|
76
|
+
el id.
|
|
77
|
+
- Los números y las fechas se escriben como en la app: `73,4`, `1.500,50`,
|
|
78
|
+
`30/10/2026`, `22/10/2026 08:00`.
|
|
79
|
+
- Las listas muestran la primera página; `--todas` las trae todas.
|
|
80
|
+
- `--json` da todo lo que responde la API; `--csv` da un CSV que Excel abre
|
|
81
|
+
bien (`operx equipos listar --todas --csv > equipos.csv`).
|
|
82
|
+
|
|
83
|
+
### Antes de escribir
|
|
84
|
+
|
|
85
|
+
Todo lo que crea o cambia algo pregunta antes. En un script o una tarea
|
|
86
|
+
programada no hay a quién preguntar: agrega `--si`. Cada escritura queda en
|
|
87
|
+
el historial del token (Acceso por API › el token › Actividad) marcada como
|
|
88
|
+
hecha desde la CLI.
|
|
89
|
+
|
|
90
|
+
### Importar personas
|
|
91
|
+
|
|
92
|
+
La primera columna con el nombre basta. `operx` reconoce las columnas
|
|
93
|
+
Nombres, Apellidos, Código, Cargo, Tipo de contrato, Fecha de ingreso, Fin de
|
|
94
|
+
contrato, Celular, Sede y las de contacto de emergencia, con o sin tildes.
|
|
95
|
+
Una columna con otro nombre se indica con `--mapa "Tu columna=campo"`.
|
|
96
|
+
Nadie recibe acceso a la app al importarlo: las invitaciones se envían en
|
|
97
|
+
Usuarios y acceso.
|
|
98
|
+
|
|
99
|
+
### Cualquier otra llamada
|
|
100
|
+
|
|
101
|
+
Lo que no tiene comando propio se hace con `operx api`, con las direcciones
|
|
102
|
+
de Acceso por API › Documentación (sin `/api/v1/`):
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
operx api GET pm/schedules/ -q search=compresor
|
|
106
|
+
operx api POST contacts/ -d @contacto.json
|
|
107
|
+
operx api GET work-orders/export/ -o ordenes.csv
|
|
108
|
+
operx api POST work-orders/1234/attachments/ -a file=foto.jpg -d '{"file_type": "photo"}'
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
En Git Bash, una dirección que empieza por `/` se vuelve una ruta de Windows:
|
|
112
|
+
escríbela sin la barra inicial, como arriba.
|
|
113
|
+
|
|
114
|
+
## Para desarrollar
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
cd cli
|
|
118
|
+
python -m venv .venv && .venv/Scripts/python -m pip install -e ".[test]"
|
|
119
|
+
.venv/Scripts/python -m pytest
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`operx_cli/client.py` es lo único que habla con la red; el servidor MCP lo
|
|
123
|
+
usará igual.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""`operx`: the root of the commands, and what turns a refused call into
|
|
2
|
+
one line instead of a traceback."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from typer.core import TyperGroup
|
|
9
|
+
|
|
10
|
+
from . import __version__, settings
|
|
11
|
+
from .client import ApiError
|
|
12
|
+
from .commands import account, inventario, lecturas, ordenes, personas, raw, registros, search
|
|
13
|
+
from .commands.common import CliError, Session
|
|
14
|
+
from .lookup import NoSingleMatch
|
|
15
|
+
from .spreadsheet import SpreadsheetError
|
|
16
|
+
|
|
17
|
+
# What a person can fix: shown as "Error: …", exit code 1.
|
|
18
|
+
EXPECTED_ERRORS = (ApiError, CliError, NoSingleMatch, SpreadsheetError, settings.SettingsError)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReportedErrors(TyperGroup):
|
|
22
|
+
def invoke(self, ctx):
|
|
23
|
+
try:
|
|
24
|
+
return super().invoke(ctx)
|
|
25
|
+
except EXPECTED_ERRORS as error:
|
|
26
|
+
typer.echo(f"Error: {error}", err=True)
|
|
27
|
+
raise typer.Exit(1) from None
|
|
28
|
+
except typer.Abort:
|
|
29
|
+
# Ctrl+C, or a question whose answer never came.
|
|
30
|
+
typer.echo("Se canceló.", err=True)
|
|
31
|
+
raise typer.Exit(1) from None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
cls=ReportedErrors,
|
|
36
|
+
help="OperX desde la terminal: tu empresa con tu token de API.",
|
|
37
|
+
no_args_is_help=True,
|
|
38
|
+
add_completion=False,
|
|
39
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
40
|
+
# A crash must never print the token, which lives in the client's locals.
|
|
41
|
+
pretty_exceptions_show_locals=False,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _show_version(value):
|
|
46
|
+
if value:
|
|
47
|
+
typer.echo(f"operx {__version__}")
|
|
48
|
+
raise typer.Exit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.callback()
|
|
52
|
+
def root(
|
|
53
|
+
ctx: typer.Context,
|
|
54
|
+
perfil: Annotated[
|
|
55
|
+
Optional[str],
|
|
56
|
+
typer.Option("--perfil", "-p", help="El perfil: otra API u otra empresa. Sin él, «principal» (o OPERX_PERFIL)."),
|
|
57
|
+
] = None,
|
|
58
|
+
version: Annotated[
|
|
59
|
+
bool, typer.Option("--version", callback=_show_version, is_eager=True, help="La versión instalada.")
|
|
60
|
+
] = False,
|
|
61
|
+
):
|
|
62
|
+
if ctx.obj is None:
|
|
63
|
+
ctx.obj = Session()
|
|
64
|
+
ctx.obj.profile = settings.profile_name(perfil)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
app.command()(account.entrar)
|
|
68
|
+
app.command()(account.salir)
|
|
69
|
+
app.command()(account.cuenta)
|
|
70
|
+
app.command()(search.buscar)
|
|
71
|
+
app.command()(raw.api)
|
|
72
|
+
app.add_typer(ordenes.app, name="ordenes")
|
|
73
|
+
app.add_typer(registros.equipos, name="equipos")
|
|
74
|
+
app.add_typer(registros.clientes, name="clientes")
|
|
75
|
+
app.add_typer(registros.lugares, name="lugares")
|
|
76
|
+
app.add_typer(inventario.app, name="inventario")
|
|
77
|
+
app.add_typer(lecturas.app, name="lecturas")
|
|
78
|
+
app.add_typer(personas.app, name="personas")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
# Redirected to a file, Windows would write the results in its own code
|
|
83
|
+
# page; the CSV and the JSON are UTF-8.
|
|
84
|
+
for stream in (sys.stdout, sys.stderr):
|
|
85
|
+
if hasattr(stream, "reconfigure"):
|
|
86
|
+
stream.reconfigure(encoding="utf-8")
|
|
87
|
+
app()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The calls to OperX's API, with a personal API token: the only module that
|
|
2
|
+
talks to the network. The MCP server can use it as it is.
|
|
3
|
+
|
|
4
|
+
A write carries an Idempotency-Key, so the views that honour it (stock
|
|
5
|
+
moves, a work order's status, Ambiental's records) never do it twice. Only a
|
|
6
|
+
429 is repeated by itself: it changed nothing. A write that timed out or
|
|
7
|
+
failed on the server is never repeated, because on a view without the key a
|
|
8
|
+
second try could create the record twice.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
|
|
18
|
+
USER_AGENT = f"operx-cli/{__version__}"
|
|
19
|
+
WRITES = ("POST", "PUT", "PATCH", "DELETE")
|
|
20
|
+
# A list asks for this many rows a page: the API's cap (utils/pagination.py).
|
|
21
|
+
PAGE_SIZE = 200
|
|
22
|
+
# How many times a 429 is waited out before giving up, and the longest wait.
|
|
23
|
+
RETRIES_ON_429 = 3
|
|
24
|
+
LONGEST_WAIT = 60
|
|
25
|
+
|
|
26
|
+
SIGN_IN_AGAIN = "Entra de nuevo con `operx entrar`."
|
|
27
|
+
TIMED_OUT_WRITE = (
|
|
28
|
+
"La API no respondió a tiempo. Revisa en la app si el cambio quedó hecho antes de repetirlo."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApiError(Exception):
|
|
33
|
+
"""An answer the API refused, or no answer at all (status 0)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, status, message):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.status = status
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def api_path(path):
|
|
41
|
+
"""'/api/v1/work-orders/12' → 'work-orders/12/': the part after /api/v1/,
|
|
42
|
+
with the trailing slash Django's addresses need."""
|
|
43
|
+
path = path.strip().lstrip("/")
|
|
44
|
+
if path.startswith("api/v1/"):
|
|
45
|
+
path = path[len("api/v1/"):]
|
|
46
|
+
address, separator, query = path.partition("?")
|
|
47
|
+
if not address.endswith("/"):
|
|
48
|
+
address += "/"
|
|
49
|
+
return address + separator + query
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def error_message(response):
|
|
53
|
+
"""The API's own words: its "detail", or each field with its errors."""
|
|
54
|
+
try:
|
|
55
|
+
body = response.json()
|
|
56
|
+
except ValueError:
|
|
57
|
+
return f"La API respondió {response.status_code}."
|
|
58
|
+
if isinstance(body, dict) and isinstance(body.get("detail"), str):
|
|
59
|
+
return body["detail"]
|
|
60
|
+
if isinstance(body, dict) and body:
|
|
61
|
+
lines = []
|
|
62
|
+
for field, errors in body.items():
|
|
63
|
+
text = " ".join(str(error) for error in errors) if isinstance(errors, list) else str(errors)
|
|
64
|
+
lines.append(f"{field}: {text}")
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
return f"La API respondió {response.status_code}."
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def seconds_to_wait(response):
|
|
70
|
+
try:
|
|
71
|
+
return min(int(response.headers.get("Retry-After", "5")), LONGEST_WAIT)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return 5
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ApiClient:
|
|
77
|
+
def __init__(self, address, token, transport=None, sleep=time.sleep):
|
|
78
|
+
self.address = address.rstrip("/")
|
|
79
|
+
self.sleep = sleep
|
|
80
|
+
self.http = httpx.Client(
|
|
81
|
+
base_url=self.address + "/api/v1/",
|
|
82
|
+
headers={"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT, "Accept": "application/json"},
|
|
83
|
+
timeout=60,
|
|
84
|
+
transport=transport,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def send(self, method, path, params=None, json=None, files=None, data=None):
|
|
88
|
+
"""One call; its answer, or ApiError when the API refused it."""
|
|
89
|
+
method = method.upper()
|
|
90
|
+
headers = {"Idempotency-Key": str(uuid.uuid4())} if method in WRITES else {}
|
|
91
|
+
waits = 0
|
|
92
|
+
while True:
|
|
93
|
+
response = self._request(method, path, params, json, files, data, headers)
|
|
94
|
+
if response.status_code != 429 or waits == RETRIES_ON_429:
|
|
95
|
+
break
|
|
96
|
+
self.sleep(seconds_to_wait(response))
|
|
97
|
+
waits += 1
|
|
98
|
+
if response.status_code >= 400:
|
|
99
|
+
message = error_message(response)
|
|
100
|
+
if response.status_code == 401:
|
|
101
|
+
message = f"{message}\n{SIGN_IN_AGAIN}"
|
|
102
|
+
raise ApiError(response.status_code, message)
|
|
103
|
+
return response
|
|
104
|
+
|
|
105
|
+
def _request(self, method, path, params, json, files, data, headers):
|
|
106
|
+
try:
|
|
107
|
+
return self.http.request(
|
|
108
|
+
method, api_path(path), params=params, json=json, files=files, data=data, headers=headers,
|
|
109
|
+
)
|
|
110
|
+
except httpx.TimeoutException:
|
|
111
|
+
if method in WRITES:
|
|
112
|
+
raise ApiError(0, TIMED_OUT_WRITE)
|
|
113
|
+
raise ApiError(0, "La API no respondió a tiempo. Intenta de nuevo.")
|
|
114
|
+
except httpx.HTTPError as error:
|
|
115
|
+
raise ApiError(0, f"No se pudo conectar con {self.address}: {error}")
|
|
116
|
+
|
|
117
|
+
def get(self, path, params=None):
|
|
118
|
+
"""A read's answer, as JSON."""
|
|
119
|
+
return self.send("GET", path, params=params).json()
|
|
120
|
+
|
|
121
|
+
def all_pages(self, path, params):
|
|
122
|
+
"""Every row of a list, page by page. An address that isn't paginated
|
|
123
|
+
answers a plain list, taken as it comes. The pages are asked by number:
|
|
124
|
+
the "next" the API sends can name the wrong host behind Cloudflare."""
|
|
125
|
+
page = 1
|
|
126
|
+
rows = []
|
|
127
|
+
while True:
|
|
128
|
+
answer = self.get(path, {**params, "page": page, "page_size": PAGE_SIZE})
|
|
129
|
+
if isinstance(answer, list):
|
|
130
|
+
return answer
|
|
131
|
+
rows.extend(answer.get("results", []))
|
|
132
|
+
if not answer.get("next"):
|
|
133
|
+
return rows
|
|
134
|
+
page += 1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One module per group of commands: `operx ordenes …`, `operx equipos …`."""
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""`operx entrar`, `operx salir` and `operx cuenta`: the token of a profile,
|
|
2
|
+
and who it acts as."""
|
|
3
|
+
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from .. import settings
|
|
9
|
+
from ..client import ApiClient
|
|
10
|
+
from ..output import print_record
|
|
11
|
+
from .common import CliError, JsonOption, Session, client_of
|
|
12
|
+
|
|
13
|
+
TOKEN_PREFIX = "opx_"
|
|
14
|
+
PRODUCT_LABELS = {
|
|
15
|
+
"maintenance": "Trabajos", "people": "Personas", "inventory": "Inventario",
|
|
16
|
+
"equipment": "Equipos", "sst": "SG-SST", "environment": "Ambiental",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def full_name(me):
|
|
21
|
+
return " ".join(f"{me.get('first_name', '')} {me.get('last_name', '')}".split()) or me.get("email", "")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def entrar(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
direccion: Annotated[
|
|
27
|
+
Optional[str], typer.Option("--direccion", help="La dirección de la API. Sin ella, la de producción.")
|
|
28
|
+
] = None,
|
|
29
|
+
):
|
|
30
|
+
"""Guarda tu token de API en el llavero del sistema. Créalo en la app: menú de tu cuenta › Acceso por API."""
|
|
31
|
+
session = ctx.find_object(Session)
|
|
32
|
+
token = typer.prompt("Pega tu token (no se mostrará)", hide_input=True).strip()
|
|
33
|
+
if not token.startswith(TOKEN_PREFIX):
|
|
34
|
+
raise CliError("Eso no es un token de OperX: empieza por «opx_». Créalo en la app, en Acceso por API.")
|
|
35
|
+
address = direccion.rstrip("/") if direccion else session.address()
|
|
36
|
+
# Checked before it's kept: a token the API refuses is never saved.
|
|
37
|
+
me = ApiClient(address, token, transport=session.transport).get("auth/me")
|
|
38
|
+
settings.save_token(session.profile, token)
|
|
39
|
+
if direccion:
|
|
40
|
+
settings.save_address(session.profile, address)
|
|
41
|
+
typer.echo(f"Listo: entraste como {full_name(me)} en {me.get('client_name')}.")
|
|
42
|
+
typer.echo(f"Perfil «{session.profile}», {address}.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def salir(ctx: typer.Context):
|
|
46
|
+
"""Borra el token de este perfil del llavero. El token sigue vivo: revócalo en Acceso por API si ya no lo usas."""
|
|
47
|
+
session = ctx.find_object(Session)
|
|
48
|
+
settings.forget_token(session.profile)
|
|
49
|
+
typer.echo(f"Se borró el token del perfil «{session.profile}».")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cuenta(ctx: typer.Context, as_json: JsonOption = False):
|
|
53
|
+
"""Quién eres para la API: tu nombre, tu empresa y tu rol."""
|
|
54
|
+
session = ctx.find_object(Session)
|
|
55
|
+
me = client_of(ctx).get("auth/me")
|
|
56
|
+
if as_json:
|
|
57
|
+
print_record(me, "json")
|
|
58
|
+
return
|
|
59
|
+
roles = [me.get("role")] + list(me.get("extra_roles") or [])
|
|
60
|
+
print_record({
|
|
61
|
+
"Nombre": full_name(me),
|
|
62
|
+
"Correo": me.get("email"),
|
|
63
|
+
"Empresa": me.get("client_name"),
|
|
64
|
+
"Rol": [role for role in roles if role],
|
|
65
|
+
"Productos": [PRODUCT_LABELS.get(product, product) for product in me.get("products") or []],
|
|
66
|
+
"Perfil": session.profile,
|
|
67
|
+
"Dirección": session.address(),
|
|
68
|
+
}, "tabla")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""What every command shares: the session (which profile, which API), the
|
|
2
|
+
options that choose how results are shown, and the question before a
|
|
3
|
+
write."""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Annotated, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from .. import settings
|
|
12
|
+
from ..client import ApiClient
|
|
13
|
+
from ..output import note
|
|
14
|
+
from ..values import decimal_text, iso_date, iso_datetime
|
|
15
|
+
|
|
16
|
+
NOT_SIGNED_IN = "No has entrado en este perfil. Usa `operx entrar` con un token de Acceso por API."
|
|
17
|
+
NEEDS_SI = "Esto cambia datos de tu empresa. Fuera de una terminal agrega --si para confirmarlo."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CliError(Exception):
|
|
21
|
+
"""Something the person typed that can't be done: shown as one line."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Session:
|
|
26
|
+
profile: str = settings.DEFAULT_PROFILE
|
|
27
|
+
# httpx's transport: None goes to the network; the tests pass a fake API.
|
|
28
|
+
transport: object = None
|
|
29
|
+
|
|
30
|
+
def address(self):
|
|
31
|
+
return settings.address_of(self.profile)
|
|
32
|
+
|
|
33
|
+
def client(self):
|
|
34
|
+
token = settings.token_of(self.profile)
|
|
35
|
+
if not token:
|
|
36
|
+
raise CliError(NOT_SIGNED_IN)
|
|
37
|
+
return ApiClient(self.address(), token, transport=self.transport)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def client_of(ctx):
|
|
41
|
+
return ctx.find_object(Session).client()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Todo lo que responde la API, en JSON.")]
|
|
45
|
+
CsvOption = Annotated[bool, typer.Option("--csv", help="En CSV para Excel (UTF-8, separado por punto y coma).")]
|
|
46
|
+
AllOption = Annotated[bool, typer.Option("--todas", help="Todas las páginas, no solo la primera.")]
|
|
47
|
+
YesOption = Annotated[bool, typer.Option("--si", help="Escribe sin preguntar (para scripts y tareas programadas).")]
|
|
48
|
+
SearchOption = Annotated[Optional[str], typer.Option("--buscar", help="Solo lo que contenga este texto.")]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def output_format(json_flag, csv_flag):
|
|
52
|
+
if json_flag and csv_flag:
|
|
53
|
+
raise CliError("Usa --json o --csv, no los dos.")
|
|
54
|
+
if json_flag:
|
|
55
|
+
return "json"
|
|
56
|
+
return "csv" if csv_flag else "tabla"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def list_rows(client, path, params, all_pages):
|
|
60
|
+
"""The first page of a list, or every page with --todas."""
|
|
61
|
+
params = {key: value for key, value in params.items() if value not in (None, "")}
|
|
62
|
+
if all_pages:
|
|
63
|
+
return client.all_pages(path, params)
|
|
64
|
+
answer = client.get(path, params)
|
|
65
|
+
if isinstance(answer, list):
|
|
66
|
+
return answer
|
|
67
|
+
rows = answer.get("results", [])
|
|
68
|
+
if answer.get("next"):
|
|
69
|
+
note(f"Mostrando {len(rows)} de {answer.get('count')}. Usa --todas para verlos todos.")
|
|
70
|
+
return rows
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
YES_ANSWERS = ("s", "si", "sí", "y", "yes")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def can_ask():
|
|
77
|
+
return sys.stdin.isatty()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def confirm_write(what, yes):
|
|
81
|
+
"""Ask before changing anything, unless --si. A script can't answer, so
|
|
82
|
+
it needs --si: without a terminal, or when the answer never comes
|
|
83
|
+
(Windows' NUL device passes for a terminal)."""
|
|
84
|
+
if yes:
|
|
85
|
+
return
|
|
86
|
+
if not can_ask():
|
|
87
|
+
raise CliError(NEEDS_SI)
|
|
88
|
+
try:
|
|
89
|
+
answer = typer.prompt(f"{what} ¿Confirmas? (s/n)", default="n", show_default=False)
|
|
90
|
+
except typer.Abort:
|
|
91
|
+
raise CliError(NEEDS_SI) from None
|
|
92
|
+
if answer.strip().casefold() not in YES_ANSWERS:
|
|
93
|
+
raise CliError("No se cambió nada.")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def number(text):
|
|
97
|
+
try:
|
|
98
|
+
return decimal_text(text)
|
|
99
|
+
except ValueError as error:
|
|
100
|
+
raise CliError(str(error)) from None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def moment(text):
|
|
104
|
+
try:
|
|
105
|
+
return iso_datetime(text)
|
|
106
|
+
except ValueError as error:
|
|
107
|
+
raise CliError(str(error)) from None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def day(text):
|
|
111
|
+
return iso_date(text) if text else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def without_empty(fields):
|
|
115
|
+
"""The fields someone gave: an option left out isn't sent."""
|
|
116
|
+
return {key: value for key, value in fields.items() if value not in (None, "")}
|