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.
Files changed (39) hide show
  1. operx_cli-0.1.0/PKG-INFO +136 -0
  2. operx_cli-0.1.0/README.md +123 -0
  3. operx_cli-0.1.0/operx_cli/__init__.py +5 -0
  4. operx_cli-0.1.0/operx_cli/app.py +87 -0
  5. operx_cli-0.1.0/operx_cli/client.py +134 -0
  6. operx_cli-0.1.0/operx_cli/commands/__init__.py +1 -0
  7. operx_cli-0.1.0/operx_cli/commands/account.py +68 -0
  8. operx_cli-0.1.0/operx_cli/commands/common.py +116 -0
  9. operx_cli-0.1.0/operx_cli/commands/inventario.py +105 -0
  10. operx_cli-0.1.0/operx_cli/commands/lecturas.py +57 -0
  11. operx_cli-0.1.0/operx_cli/commands/ordenes.py +187 -0
  12. operx_cli-0.1.0/operx_cli/commands/personas.py +107 -0
  13. operx_cli-0.1.0/operx_cli/commands/raw.py +114 -0
  14. operx_cli-0.1.0/operx_cli/commands/registros.py +192 -0
  15. operx_cli-0.1.0/operx_cli/commands/search.py +21 -0
  16. operx_cli-0.1.0/operx_cli/lookup.py +42 -0
  17. operx_cli-0.1.0/operx_cli/output.py +102 -0
  18. operx_cli-0.1.0/operx_cli/settings.py +90 -0
  19. operx_cli-0.1.0/operx_cli/spreadsheet.py +179 -0
  20. operx_cli-0.1.0/operx_cli/values.py +59 -0
  21. operx_cli-0.1.0/operx_cli.egg-info/PKG-INFO +136 -0
  22. operx_cli-0.1.0/operx_cli.egg-info/SOURCES.txt +37 -0
  23. operx_cli-0.1.0/operx_cli.egg-info/dependency_links.txt +1 -0
  24. operx_cli-0.1.0/operx_cli.egg-info/entry_points.txt +2 -0
  25. operx_cli-0.1.0/operx_cli.egg-info/requires.txt +7 -0
  26. operx_cli-0.1.0/operx_cli.egg-info/top_level.txt +1 -0
  27. operx_cli-0.1.0/pyproject.toml +33 -0
  28. operx_cli-0.1.0/setup.cfg +4 -0
  29. operx_cli-0.1.0/tests/test_app.py +171 -0
  30. operx_cli-0.1.0/tests/test_client.py +126 -0
  31. operx_cli-0.1.0/tests/test_inventario_lecturas.py +95 -0
  32. operx_cli-0.1.0/tests/test_lookup.py +50 -0
  33. operx_cli-0.1.0/tests/test_ordenes.py +102 -0
  34. operx_cli-0.1.0/tests/test_output.py +42 -0
  35. operx_cli-0.1.0/tests/test_personas.py +95 -0
  36. operx_cli-0.1.0/tests/test_registros.py +69 -0
  37. operx_cli-0.1.0/tests/test_settings.py +46 -0
  38. operx_cli-0.1.0/tests/test_spreadsheet.py +89 -0
  39. operx_cli-0.1.0/tests/test_values.py +43 -0
@@ -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,5 @@
1
+ """OperX's command line (PRODUCT_ROADMAP.md › Platform, step 3): the public
2
+ API (backend/users/api_allowlist.py) from a terminal, with a personal API
3
+ token."""
4
+
5
+ __version__ = "0.1.0"
@@ -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, "")}