operx-cli 0.7.1__tar.gz → 0.7.3__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 (77) hide show
  1. {operx_cli-0.7.1 → operx_cli-0.7.3}/PKG-INFO +7 -1
  2. {operx_cli-0.7.1 → operx_cli-0.7.3}/README.md +6 -0
  3. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/AGENTES.md +8 -2
  4. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/__init__.py +1 -1
  5. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/api_fields.py +44 -1
  6. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/app.py +2 -1
  7. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/raw.py +3 -39
  8. operx_cli-0.7.3/operx_cli/commands/rutinas.py +169 -0
  9. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/lookup.py +3 -0
  10. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/shell.py +31 -2
  11. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli.egg-info/PKG-INFO +7 -1
  12. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli.egg-info/SOURCES.txt +2 -0
  13. {operx_cli-0.7.1 → operx_cli-0.7.3}/pyproject.toml +1 -1
  14. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_completar.py +2 -0
  15. operx_cli-0.7.3/tests/test_rutinas.py +128 -0
  16. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_shell.py +18 -1
  17. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/agent_guide.py +0 -0
  18. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/browser_login.py +0 -0
  19. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/client.py +0 -0
  20. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/__init__.py +0 -0
  21. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/account.py +0 -0
  22. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/ambiental.py +0 -0
  23. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/articulos.py +0 -0
  24. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/avisos.py +0 -0
  25. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/common.py +0 -0
  26. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/completar.py +0 -0
  27. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/empresas.py +0 -0
  28. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/inventario.py +0 -0
  29. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/lecturas.py +0 -0
  30. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/ordenes.py +0 -0
  31. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/personas.py +0 -0
  32. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/planta.py +0 -0
  33. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/preventivos.py +0 -0
  34. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/registros.py +0 -0
  35. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/reportes.py +0 -0
  36. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/search.py +0 -0
  37. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/sst.py +0 -0
  38. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/commands/update.py +0 -0
  39. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/output.py +0 -0
  40. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/record_completion.py +0 -0
  41. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/self_update.py +0 -0
  42. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/settings.py +0 -0
  43. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/spreadsheet.py +0 -0
  44. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/updates.py +0 -0
  45. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/values.py +0 -0
  46. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli/welcome.py +0 -0
  47. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli.egg-info/dependency_links.txt +0 -0
  48. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli.egg-info/entry_points.txt +0 -0
  49. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli.egg-info/requires.txt +0 -0
  50. {operx_cli-0.7.1 → operx_cli-0.7.3}/operx_cli.egg-info/top_level.txt +0 -0
  51. {operx_cli-0.7.1 → operx_cli-0.7.3}/setup.cfg +0 -0
  52. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_agentes.py +0 -0
  53. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_api_campos.py +0 -0
  54. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_app.py +0 -0
  55. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_articulos.py +0 -0
  56. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_cards.py +0 -0
  57. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_client.py +0 -0
  58. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_daily_flows.py +0 -0
  59. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_inventario_lecturas.py +0 -0
  60. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_json_writes.py +0 -0
  61. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_login.py +0 -0
  62. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_logout_and_routes.py +0 -0
  63. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_lookup.py +0 -0
  64. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_more_of_the_api.py +0 -0
  65. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_ordenes.py +0 -0
  66. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_output.py +0 -0
  67. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_personas.py +0 -0
  68. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_preventivos.py +0 -0
  69. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_record_completion.py +0 -0
  70. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_registros.py +0 -0
  71. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_self_update.py +0 -0
  72. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_settings.py +0 -0
  73. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_spreadsheet.py +0 -0
  74. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_sst_ambiental.py +0 -0
  75. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_updates.py +0 -0
  76. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_values.py +0 -0
  77. {operx_cli-0.7.1 → operx_cli-0.7.3}/tests/test_welcome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: operx-cli
3
- Version: 0.7.1
3
+ Version: 0.7.3
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
@@ -172,6 +172,12 @@ operx personas importar empleados.xlsx --confirmar # guarda si ninguna fila ti
172
172
  operx personas ausencia "Ana Ruiz" --tipo vacaciones --desde 02/11/2026 --hasta 06/11/2026
173
173
  operx personas ausencia "Ana Ruiz" --tipo permiso --desde 09/11/2026 --horas 08:00-10:00
174
174
 
175
+ operx rutinas listar
176
+ operx rutinas crear "Contar la bodega" --rol Bodeguero # sin --rol ni --persona, es para todos
177
+ operx rutinas cambiar "Contar la bodega" --persona "Ana Ruiz"
178
+ operx rutinas quitar "Contar la bodega" # sale de los planes desde ahora
179
+ operx rutinas cumplimiento --mes 10/2026 # quién hizo sus rutinas (administradores)
180
+
175
181
  operx sst acciones --estado abiertas
176
182
  operx sst nueva-accion --titulo "Señalizar la salida" --para 15/11/2026 --responsable "Ana Ruiz"
177
183
  operx sst accion-hecha 7 --evidencia "Se instalaron dos señales"
@@ -158,6 +158,12 @@ operx personas importar empleados.xlsx --confirmar # guarda si ninguna fila ti
158
158
  operx personas ausencia "Ana Ruiz" --tipo vacaciones --desde 02/11/2026 --hasta 06/11/2026
159
159
  operx personas ausencia "Ana Ruiz" --tipo permiso --desde 09/11/2026 --horas 08:00-10:00
160
160
 
161
+ operx rutinas listar
162
+ operx rutinas crear "Contar la bodega" --rol Bodeguero # sin --rol ni --persona, es para todos
163
+ operx rutinas cambiar "Contar la bodega" --persona "Ana Ruiz"
164
+ operx rutinas quitar "Contar la bodega" # sale de los planes desde ahora
165
+ operx rutinas cumplimiento --mes 10/2026 # quién hizo sus rutinas (administradores)
166
+
161
167
  operx sst acciones --estado abiertas
162
168
  operx sst nueva-accion --titulo "Señalizar la salida" --para 15/11/2026 --responsable "Ana Ruiz"
163
169
  operx sst accion-hecha 7 --evidencia "Se instalaron dos señales"
@@ -68,12 +68,18 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
68
68
  `traslado` y `revertir`.
69
69
  - Equipos: `equipos parar`, `reanudar`, `hallazgo` y `hallazgo-a-orden`;
70
70
  `lecturas registrar` y `anular`.
71
- - Personas: `personas importar --confirmar` y `personas ausencia`.
71
+ - Personas: `personas importar --confirmar` y `personas ausencia`;
72
+ `rutinas crear`, `cambiar` y `quitar`.
72
73
  - SG-SST y Ambiental: `sst nueva-accion` y `accion-hecha`;
73
74
  `ambiental nueva-accion`, `accion-hecha`, `residuo` y `lectura`.
74
75
  - `avisos --marcar-leidos`, y `operx api` con POST, PUT, PATCH o DELETE.
75
76
  - Un error se corrige con su contrario, nunca borrando: `inventario revertir`
76
- deshace un movimiento, `lecturas anular` una lectura.
77
+ deshace un movimiento, `lecturas anular` una lectura. La excepción es
78
+ `rutinas quitar`, que saca una rutina de los planes desde ahora; los días
79
+ que ya la tenían la siguen contando.
80
+ - Una rutina es para todos, para algunos roles (`--rol`, por su nombre) o
81
+ para algunas personas (`--persona`); `rutinas cambiar --rol` reemplaza la
82
+ lista entera de roles.
77
83
  - SG-SST y Ambiental van por empresa: sin `--empresa`, la de la cuenta. Una
78
84
  cuenta que lleva el SG-SST o lo ambiental de sus clientes ve las demás con
79
85
  `operx sst empresas` o `operx ambiental empresas`.
@@ -2,4 +2,4 @@
2
2
  API (backend/users/api_allowlist.py) from a terminal, with a personal API
3
3
  token."""
4
4
 
5
- __version__ = "0.7.1"
5
+ __version__ = "0.7.3"
@@ -2,15 +2,21 @@
2
2
  (auth/api-tokens/schema/, drf-spectacular): the values in its address, its
3
3
  filters, the fields of its body and what it answers. `operx api campos`
4
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.
5
+ guessing; no network here, so the MCP server can use it as it is: its
6
+ image copies this one file (mcp/Dockerfile), so keep it free of imports
7
+ beyond the standard library.
6
8
 
7
9
  The schema's shapes this follows: a field's type behind a $ref; an allOf
8
10
  of one $ref (a nested record); a oneOf of an enum with BlankEnum and
9
11
  NullEnum (a choice that may be "" or null); each enum's labels in its
10
12
  description, one "* `value` - Label" a line.
13
+
14
+ It also lists the routes and finds them by words (`operx api rutas`, the
15
+ MCP's operx_rutas).
11
16
  """
12
17
 
13
18
  import re
19
+ import unicodedata
14
20
 
15
21
  METHODS = ("get", "post", "put", "patch", "delete")
16
22
  # A field inside a list inside a list… is shown this many levels deep.
@@ -230,3 +236,40 @@ def _answer(components, operation):
230
236
  record = _resolve(components, record.get("items"))
231
237
  return {"estado": int(status), "pagina": paged, "campos": list(record.get("properties", {}))}
232
238
  return {"estado": None, "pagina": False, "campos": []}
239
+
240
+
241
+ def routes_of(schema):
242
+ """Every route of the schema, one row per method: its address, its
243
+ product (the docs' section, in Spanish) and its description."""
244
+ routes = []
245
+ for path, operations in schema.get("paths", {}).items():
246
+ for method, operation in operations.items():
247
+ if method not in METHODS:
248
+ continue
249
+ # The first paragraph: the docs add a note on the token a write needs.
250
+ description = (operation.get("description") or "").split("\n\n")[0].strip()
251
+ if description.startswith("Necesita un token"):
252
+ description = ""
253
+ routes.append({
254
+ "metodo": method.upper(), "ruta": path.removeprefix("/api/v1/"),
255
+ # The docs' section: the product, in Spanish ("SG-SST", "Inventario").
256
+ "producto": ", ".join(operation.get("tags", [])), "descripcion": description,
257
+ })
258
+ return routes
259
+
260
+
261
+ def plain(text):
262
+ """'Órdenes' → 'ordenes': no case, no accents."""
263
+ return "".join(char for char in unicodedata.normalize("NFKD", text.casefold()) if not unicodedata.combining(char))
264
+
265
+
266
+ def mentions(where, text):
267
+ """Whether every word of `text` is in `where`, a plural also as its
268
+ singular: an agent asks for "repuestos" where the docs say "repuesto",
269
+ and "ordenes" for "órdenes"."""
270
+ where = plain(where)
271
+ for word in plain(text).split():
272
+ singular = word[:-2] if word.endswith("es") and len(word) > 5 else word.rstrip("s") if len(word) > 4 else word
273
+ if word not in where and singular not in where:
274
+ return False
275
+ return True
@@ -15,7 +15,7 @@ from .agent_guide import guide_text
15
15
  from .client import ApiError
16
16
  from .commands import (
17
17
  account, ambiental, articulos, avisos, completar, inventario, lecturas, ordenes, personas, planta, preventivos, raw,
18
- registros, reportes, search, sst, update,
18
+ registros, reportes, rutinas, search, sst, update,
19
19
  )
20
20
  from .commands.common import CliError, Session
21
21
  from .lookup import NoSingleMatch
@@ -155,6 +155,7 @@ app.add_typer(inventario.app, name="inventario")
155
155
  app.add_typer(articulos.app, name="articulos")
156
156
  app.add_typer(lecturas.app, name="lecturas")
157
157
  app.add_typer(personas.app, name="personas")
158
+ app.add_typer(rutinas.app, name="rutinas")
158
159
  app.add_typer(reportes.app, name="reportes")
159
160
  app.add_typer(sst.app, name="sst")
160
161
  app.add_typer(ambiental.app, name="ambiental")
@@ -2,7 +2,6 @@
2
2
  lists them), for what has no command of its own yet."""
3
3
 
4
4
  import json
5
- import unicodedata
6
5
  from contextlib import ExitStack
7
6
  from pathlib import Path
8
7
  from typing import Annotated, Optional
@@ -10,7 +9,7 @@ from typing import Annotated, Optional
10
9
  import typer
11
10
  from rich.text import Text
12
11
 
13
- from ..api_fields import describe, find_route, methods_of
12
+ from ..api_fields import describe, find_route, mentions, methods_of, routes_of
14
13
  from ..client import ApiError, api_path
15
14
  from ..output import json_text, note, print_rows, screen_console
16
15
  from .common import AllOption, CliError, YesOption, client_of, confirm_write
@@ -60,9 +59,9 @@ def _print_routes(client, text, as_json):
60
59
  """Every call the token may make, from the API's own docs
61
60
  (users/api_docs.py): the method, the address as `operx api` takes it,
62
61
  and what the docs say. `text` keeps the ones that mention it."""
63
- routes = _routes(client.get("auth/api-tokens/schema"))
62
+ routes = routes_of(client.get("auth/api-tokens/schema"))
64
63
  if text:
65
- routes = [route for route in routes if _mentions(f"{route['ruta']} {route['producto']} {route['descripcion']}", text)]
64
+ routes = [route for route in routes if mentions(f"{route['ruta']} {route['producto']} {route['descripcion']}", text)]
66
65
  print_rows(ROUTE_COLUMNS, routes, "json" if as_json else "tabla")
67
66
  if not as_json and routes:
68
67
  note("Lo que lleva cada una: operx api campos RUTA")
@@ -169,41 +168,6 @@ def _print_fields(client, ruta, method, as_json):
169
168
  console.print(Text(f"Responde ({answer['estado']}) {what}: {', '.join(answer['campos'])}".replace(" :", ":"), style="dim"))
170
169
 
171
170
 
172
- def _plain(text):
173
- """'Órdenes' → 'ordenes': no case, no accents."""
174
- return "".join(char for char in unicodedata.normalize("NFKD", text.casefold()) if not unicodedata.combining(char))
175
-
176
-
177
- def _mentions(where, text):
178
- """Whether every word of `text` is in `where`, a plural also as its
179
- singular: an agent asks for "repuestos" where the docs say "repuesto",
180
- and "ordenes" for "órdenes"."""
181
- where = _plain(where)
182
- for word in _plain(text).split():
183
- singular = word[:-2] if word.endswith("es") and len(word) > 5 else word.rstrip("s") if len(word) > 4 else word
184
- if word not in where and singular not in where:
185
- return False
186
- return True
187
-
188
-
189
- def _routes(schema):
190
- routes = []
191
- for path, operations in schema.get("paths", {}).items():
192
- for method, operation in operations.items():
193
- if method.upper() not in METHODS:
194
- continue
195
- # The first paragraph: the docs add a note on the token a write needs.
196
- description = (operation.get("description") or "").split("\n\n")[0].strip()
197
- if description.startswith("Necesita un token"):
198
- description = ""
199
- routes.append({
200
- "metodo": method.upper(), "ruta": path.removeprefix("/api/v1/"),
201
- # The docs' section: the product, in Spanish ("SG-SST", "Inventario").
202
- "producto": ", ".join(operation.get("tags", [])), "descripcion": description,
203
- })
204
- return routes
205
-
206
-
207
171
  def _show(response, salida):
208
172
  if salida:
209
173
  salida.write_bytes(response.content)
@@ -0,0 +1,169 @@
1
+ """`operx rutinas`: Personas › Rutinas, what goes on the day's plan of
2
+ everyone, of some roles or of some people, and whether each person did it
3
+ (backend/people/routine_views.py). A routine with no roles and no people is
4
+ for everyone."""
5
+
6
+ from datetime import date
7
+ from typing import Annotated, Optional
8
+
9
+ import typer
10
+
11
+ from ..lookup import NoSingleMatch, find_in, find_one
12
+ from ..output import note, print_record, print_rows
13
+ from ..values import shown_moment
14
+ from .common import (
15
+ CliError, CsvOption, JsonOption, YesOption, client_of, confirm_write, month, output_format, print_result,
16
+ without_empty,
17
+ )
18
+
19
+ app = typer.Typer(
20
+ help="Rutinas (Personas): lo que va en el plan del día de todos, de algunos roles o de algunas personas.",
21
+ no_args_is_help=True,
22
+ )
23
+
24
+ ROUTINE_COLUMNS = [
25
+ ("id", "Id"), ("text", "Rutina"), ("roles", "Roles"), ("employees", "Personas"), ("people_count", "Les llega a"),
26
+ ]
27
+ COMPLIANCE_COLUMNS = [
28
+ ("name", "Persona"), ("position", "Cargo"), ("done", "Hechas"), ("due", "Debidas"), ("days_without_plan", "Días sin plan"),
29
+ ]
30
+
31
+ RolOption = Annotated[
32
+ Optional[list[str]], typer.Option("--rol", help="El nombre de un rol que la recibe; repítelo para varios.")
33
+ ]
34
+ PersonaOption = Annotated[
35
+ Optional[list[str]], typer.Option("--persona", help="El nombre o código de una persona que la recibe; repítelo para varias.")
36
+ ]
37
+
38
+
39
+ def _role_ids(client, names):
40
+ if not names:
41
+ return []
42
+ roles = client.get("people/routines/roles")
43
+ ids = []
44
+ for name in names:
45
+ try:
46
+ ids.append(find_in(roles, name, ["name"], "rol")["id"])
47
+ except NoSingleMatch as error:
48
+ # A short list: saying which there are saves a trip to the settings.
49
+ raise CliError(f"{error} Hay: {', '.join(role['name'] for role in roles)}.") from None
50
+ return ids
51
+
52
+
53
+ def _employee_ids(client, names):
54
+ return [find_one(client, "employees", name, ["user_name", "employee_code"], "persona")["id"] for name in names or []]
55
+
56
+
57
+ def _routine(client, text):
58
+ """A routine by its id or its exact text."""
59
+ return find_in(client.get("people/routines"), text, ["id", "text"], "rutina")
60
+
61
+
62
+ def _who(roles, people):
63
+ if not roles and not people:
64
+ return "para todos"
65
+ parts = []
66
+ if roles:
67
+ parts.append(f"los roles {', '.join(roles)}")
68
+ if people:
69
+ parts.append(f"las personas {', '.join(people)}")
70
+ return "para " + " y ".join(parts)
71
+
72
+
73
+ @app.command()
74
+ def listar(
75
+ ctx: typer.Context,
76
+ as_json: JsonOption = False,
77
+ as_csv: CsvOption = False,
78
+ ):
79
+ """Las rutinas de la empresa y a cuántas personas les llega cada una."""
80
+ output = output_format(as_json, as_csv)
81
+ print_rows(ROUTINE_COLUMNS, client_of(ctx).get("people/routines"), output)
82
+
83
+
84
+ @app.command()
85
+ def crear(
86
+ ctx: typer.Context,
87
+ texto: Annotated[str, typer.Argument(help="Lo que va en el plan: «Llenar la bitácora».")],
88
+ rol: RolOption = None,
89
+ persona: PersonaOption = None,
90
+ si: YesOption = False,
91
+ as_json: JsonOption = False,
92
+ ):
93
+ """Crea una rutina. Sin --rol ni --persona, es para todos."""
94
+ client = client_of(ctx)
95
+ body = {"text": texto, "role_ids": _role_ids(client, rol), "employee_ids": _employee_ids(client, persona)}
96
+ confirm_write(f"Crear la rutina «{texto}» {_who(rol, persona)}.", si)
97
+ created = client.send("POST", "people/routines", json=body).json()
98
+ print_result(
99
+ created, as_json,
100
+ f"Rutina {created['id']} creada: {created['text']}. Les llega a {created['people_count']} personas.",
101
+ )
102
+
103
+
104
+ @app.command()
105
+ def cambiar(
106
+ ctx: typer.Context,
107
+ rutina: Annotated[str, typer.Argument(help="El id o el texto exacto de la rutina.")],
108
+ texto: Annotated[Optional[str], typer.Option("--texto", help="El texto nuevo.")] = None,
109
+ rol: RolOption = None,
110
+ persona: PersonaOption = None,
111
+ para_todos: Annotated[bool, typer.Option("--para-todos", help="La deja para todos, sin roles ni personas.")] = False,
112
+ si: YesOption = False,
113
+ as_json: JsonOption = False,
114
+ ):
115
+ """Cambia una rutina. --rol y --persona reemplazan la lista entera; lo que no digas queda como estaba."""
116
+ if para_todos and (rol or persona):
117
+ raise CliError("--para-todos no va con --rol ni --persona.")
118
+ if not (texto or rol or persona or para_todos):
119
+ raise CliError("Di qué cambiar: --texto, --rol, --persona o --para-todos.")
120
+ client = client_of(ctx)
121
+ found = _routine(client, rutina)
122
+ body = without_empty({"text": texto})
123
+ if para_todos:
124
+ body["role_ids"] = []
125
+ body["employee_ids"] = []
126
+ if rol:
127
+ body["role_ids"] = _role_ids(client, rol)
128
+ if persona:
129
+ body["employee_ids"] = _employee_ids(client, persona)
130
+ confirm_write(f"Cambiar la rutina «{found['text']}».", si)
131
+ changed = client.send("PATCH", f"people/routines/{found['id']}", json=body).json()
132
+ print_result(
133
+ changed, as_json,
134
+ f"Rutina {changed['id']} cambiada: {changed['text']}. Les llega a {changed['people_count']} personas.",
135
+ )
136
+
137
+
138
+ @app.command()
139
+ def quitar(
140
+ ctx: typer.Context,
141
+ rutina: Annotated[str, typer.Argument(help="El id o el texto exacto de la rutina.")],
142
+ si: YesOption = False,
143
+ as_json: JsonOption = False,
144
+ ):
145
+ """Quita una rutina de los planes desde ahora; los días que ya la tenían la siguen contando."""
146
+ client = client_of(ctx)
147
+ found = _routine(client, rutina)
148
+ confirm_write(f"Quitar la rutina «{found['text']}» de los planes.", si)
149
+ client.send("DELETE", f"people/routines/{found['id']}")
150
+ print_result({"id": found["id"], "removed": True}, as_json, f"Rutina {found['id']} quitada: {found['text']}.")
151
+
152
+
153
+ @app.command()
154
+ def cumplimiento(
155
+ ctx: typer.Context,
156
+ mes: Annotated[Optional[str], typer.Option("--mes", help="El mes: 11/2026; sin él, el actual.")] = None,
157
+ as_json: JsonOption = False,
158
+ as_csv: CsvOption = False,
159
+ ):
160
+ """Quién hizo sus rutinas en un mes: por persona, las que traía su plan y las que marcó. Solo administradores."""
161
+ output = output_format(as_json, as_csv)
162
+ month_text = month(mes) if mes else date.today().strftime("%Y-%m")
163
+ answer = client_of(ctx).get("people/routines/compliance", {"month": month_text})
164
+ if output == "json":
165
+ print_record(answer, "json")
166
+ return
167
+ if answer.get("through"):
168
+ note(f"Cuenta hasta el {shown_moment(answer['through'])}: hoy no cuenta.")
169
+ print_rows(COMPLIANCE_COLUMNS, answer.get("people", []), output)
@@ -3,6 +3,9 @@
3
3
  Only an exact match counts, ignoring case and extra spaces: a search's first
4
4
  hit is never taken, so "Bodega" can't move stock into "Bodega central norte".
5
5
  A number nobody is called is taken as the record's id.
6
+
7
+ The MCP server uses find_in as it is: its image copies this one file
8
+ (mcp/Dockerfile), so keep it free of imports.
6
9
  """
7
10
 
8
11
 
@@ -10,16 +10,19 @@ terminal (Git Bash, a pipe)."""
10
10
  import os
11
11
  import re
12
12
  import shlex
13
+ import traceback
13
14
 
14
15
  import typer
15
16
  from rich.console import Console
16
17
  from rich.text import Text
17
18
 
18
- from . import settings
19
+ from . import __version__, settings
19
20
  from .output import print_aligned
20
21
  from .record_completion import RecordNames, completion_text
21
22
 
22
23
  PROMPT = "operx › "
24
+ # Where the CLI's own files are: a crash names its line closest to the error.
25
+ PACKAGE_FOLDER = os.path.normcase(os.path.dirname(os.path.abspath(__file__)))
23
26
  STOP_WORDS = ("salir", "exit", "quit")
24
27
  HELP_WORDS = ("/ayuda", "ayuda", "/help", "help", "?")
25
28
  CLEAR_WORDS = ("limpiar", "/limpiar", "clear", "cls")
@@ -54,6 +57,7 @@ COMMAND_PRODUCTS = {
54
57
  "lecturas": ("equipment",),
55
58
  "personas importar": ("people",),
56
59
  "personas ausencia": ("people",),
60
+ "rutinas": ("people",),
57
61
  "sst": ("sst",),
58
62
  "ambiental": ("environment",),
59
63
  }
@@ -169,6 +173,31 @@ def print_help(root, console, products=None):
169
173
  console.print(Text("Escribe un comando con --help para ver sus opciones.", style="dim"))
170
174
 
171
175
 
176
+ def where_it_failed(error):
177
+ """The CLI's own line closest to the error, as "commands/search.py:22".
178
+ run_command, which caught it, is always one of them."""
179
+ frames = traceback.extract_tb(error.__traceback__)
180
+ ours = [frame for frame in frames if os.path.normcase(frame.filename).startswith(PACKAGE_FOLDER)]
181
+ if not ours:
182
+ return "?"
183
+ relative = os.path.relpath(ours[-1].filename, PACKAGE_FOLDER)
184
+ return f"{relative.replace(os.sep, '/')}:{ours[-1].lineno}"
185
+
186
+
187
+ def crash_message(error):
188
+ """What a crash says inside the session: a line a person can send to
189
+ support (the error, where in the CLI, and the version those line numbers
190
+ belong to), never what was typed, which can hold anything."""
191
+ what = type(error).__name__
192
+ lines = str(error).strip().splitlines()
193
+ if lines:
194
+ what = f"{what}: {lines[0]}"
195
+ return (
196
+ f"Error: algo falló ({what}; en {where_it_failed(error)}, operx {__version__}).\n"
197
+ "Para ver todo el detalle, corre el mismo comando con operx delante, fuera de esta sesión."
198
+ )
199
+
200
+
172
201
  def run_command(session, root, argv):
173
202
  """One command, as `operx` would run it, but an error never ends the session."""
174
203
  try:
@@ -181,7 +210,7 @@ def run_command(session, root, argv):
181
210
  if hasattr(error, "format_message"):
182
211
  typer.echo(f"Error: {spanish_usage_error(error)}", err=True)
183
212
  else:
184
- typer.echo(f"Error: algo falló ({type(error).__name__}).", err=True)
213
+ typer.echo(crash_message(error), err=True)
185
214
 
186
215
 
187
216
  def handle_line(session, root, line, console, products=None):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: operx-cli
3
- Version: 0.7.1
3
+ Version: 0.7.3
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
@@ -172,6 +172,12 @@ operx personas importar empleados.xlsx --confirmar # guarda si ninguna fila ti
172
172
  operx personas ausencia "Ana Ruiz" --tipo vacaciones --desde 02/11/2026 --hasta 06/11/2026
173
173
  operx personas ausencia "Ana Ruiz" --tipo permiso --desde 09/11/2026 --horas 08:00-10:00
174
174
 
175
+ operx rutinas listar
176
+ operx rutinas crear "Contar la bodega" --rol Bodeguero # sin --rol ni --persona, es para todos
177
+ operx rutinas cambiar "Contar la bodega" --persona "Ana Ruiz"
178
+ operx rutinas quitar "Contar la bodega" # sale de los planes desde ahora
179
+ operx rutinas cumplimiento --mes 10/2026 # quién hizo sus rutinas (administradores)
180
+
175
181
  operx sst acciones --estado abiertas
176
182
  operx sst nueva-accion --titulo "Señalizar la salida" --para 15/11/2026 --responsable "Ana Ruiz"
177
183
  operx sst accion-hecha 7 --evidencia "Se instalaron dos señales"
@@ -40,6 +40,7 @@ operx_cli/commands/preventivos.py
40
40
  operx_cli/commands/raw.py
41
41
  operx_cli/commands/registros.py
42
42
  operx_cli/commands/reportes.py
43
+ operx_cli/commands/rutinas.py
43
44
  operx_cli/commands/search.py
44
45
  operx_cli/commands/sst.py
45
46
  operx_cli/commands/update.py
@@ -63,6 +64,7 @@ tests/test_personas.py
63
64
  tests/test_preventivos.py
64
65
  tests/test_record_completion.py
65
66
  tests/test_registros.py
67
+ tests/test_rutinas.py
66
68
  tests/test_self_update.py
67
69
  tests/test_settings.py
68
70
  tests/test_shell.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "operx-cli"
7
- version = "0.7.1"
7
+ version = "0.7.3"
8
8
  description = "La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -3,6 +3,7 @@ writes a small script to operx's folder and, after asking, one line in the
3
3
  PowerShell profile that loads it; `--quitar` takes both away."""
4
4
 
5
5
  import os
6
+ import sys
6
7
  from pathlib import Path
7
8
 
8
9
  import pytest
@@ -31,6 +32,7 @@ def test_the_script_completes_operx_and_leaves_tab_alone_elsewhere():
31
32
  script.encode("ascii")
32
33
 
33
34
 
35
+ @pytest.mark.skipif(sys.platform != "win32", reason="PowerShell's profile is a Windows path")
34
36
  def test_the_profile_line_names_the_folder_by_appdata(tmp_path):
35
37
  """No user name in the line: a profile saved in another encoding can't
36
38
  garble it."""
@@ -0,0 +1,128 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from .fake_api import FakeApi, page, run
6
+
7
+ # people/routine_views.py routines_payload
8
+ BITACORA = {"id": 3, "text": "Llenar la bitácora", "roles": [], "employees": [], "people_count": 12}
9
+ HERRAMIENTAS = {
10
+ "id": 4, "text": "Recoger herramientas", "roles": [{"id": 8, "name": "Bodeguero"}],
11
+ "employees": [{"id": 7, "name": "María Torres"}], "people_count": 2,
12
+ }
13
+ # people/routine_views.py RoutineRolesView
14
+ ROLES = [{"id": 8, "name": "Bodeguero"}, {"id": 9, "name": "Técnico"}]
15
+ # employees/serializers.py: the staff list
16
+ MARIA = {"id": 7, "user_name": "María Torres", "employee_code": "E-007"}
17
+ # people/routine_views.py RoutineComplianceView
18
+ COMPLIANCE = {
19
+ "month": "2026-10", "previous_month": "2026-09", "next_month": None, "is_current_month": True,
20
+ "through": "2026-10-25", "days": ["2026-10-24", "2026-10-25"],
21
+ "people": [
22
+ {"employee_id": 7, "name": "María Torres", "position": "Técnico", "done": 3, "due": 4, "days_without_plan": 1,
23
+ "days": [{"date": "2026-10-24", "plan": True, "done": 3, "due": 4}]},
24
+ ],
25
+ }
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def signed_in(monkeypatch):
30
+ monkeypatch.setenv("OPERX_TOKEN", "opx_test")
31
+
32
+
33
+ def test_listar_shows_who_gets_each_routine():
34
+ fake = FakeApi({("GET", "people/routines/"): [BITACORA, HERRAMIENTAS]})
35
+ result = run(fake, "rutinas", "listar")
36
+ assert result.exit_code == 0, result.output
37
+ assert "Llenar la bitácora" in result.output and "Recoger herramientas" in result.output
38
+ assert "Bodeguero" in result.output and "María Torres" in result.output
39
+
40
+
41
+ def test_crear_names_the_roles_and_people_and_sends_their_ids():
42
+ created = {**HERRAMIENTAS, "id": 5, "text": "Contar la bodega"}
43
+ fake = FakeApi({
44
+ ("GET", "people/routines/roles/"): ROLES,
45
+ ("GET", "employees/"): page(MARIA),
46
+ ("POST", "people/routines/"): (201, created),
47
+ })
48
+ result = run(fake, "rutinas", "crear", "Contar la bodega", "--rol", "bodeguero", "--persona", "María Torres", "--si")
49
+ assert result.exit_code == 0, result.output
50
+ assert fake.sent("POST", "people/routines/") == {"text": "Contar la bodega", "role_ids": [8], "employee_ids": [7]}
51
+ assert "Rutina 5 creada" in result.output
52
+
53
+
54
+ def test_crear_without_roles_or_people_is_for_everyone():
55
+ fake = FakeApi({("POST", "people/routines/"): (201, BITACORA)})
56
+ result = run(fake, "rutinas", "crear", "Llenar la bitácora", "--si")
57
+ assert result.exit_code == 0, result.output
58
+ assert fake.sent("POST", "people/routines/") == {"text": "Llenar la bitácora", "role_ids": [], "employee_ids": []}
59
+
60
+
61
+ def test_crear_with_a_role_nobody_has_says_which_there_are():
62
+ fake = FakeApi({("GET", "people/routines/roles/"): ROLES})
63
+ result = run(fake, "rutinas", "crear", "Contar la bodega", "--rol", "Gerente", "--si")
64
+ assert result.exit_code == 1
65
+ assert "Gerente" in result.output and "Bodeguero, Técnico" in result.output
66
+ assert not fake.called("POST", "people/routines/")
67
+
68
+
69
+ def test_crear_json_gives_the_record_with_its_id():
70
+ fake = FakeApi({("POST", "people/routines/"): (201, BITACORA)})
71
+ result = run(fake, "rutinas", "crear", "Llenar la bitácora", "--si", "--json")
72
+ assert json.loads(result.output)["id"] == 3
73
+
74
+
75
+ def test_cambiar_finds_the_routine_by_its_text_and_replaces_the_roles():
76
+ changed = {**HERRAMIENTAS, "roles": [{"id": 9, "name": "Técnico"}]}
77
+ fake = FakeApi({
78
+ ("GET", "people/routines/"): [BITACORA, HERRAMIENTAS],
79
+ ("GET", "people/routines/roles/"): ROLES,
80
+ ("PATCH", "people/routines/4/"): changed,
81
+ })
82
+ result = run(fake, "rutinas", "cambiar", "recoger herramientas", "--rol", "Técnico", "--si")
83
+ assert result.exit_code == 0, result.output
84
+ assert fake.sent("PATCH", "people/routines/4/") == {"role_ids": [9]}
85
+
86
+
87
+ def test_cambiar_para_todos_empties_both_lists():
88
+ fake = FakeApi({("GET", "people/routines/"): [HERRAMIENTAS], ("PATCH", "people/routines/4/"): HERRAMIENTAS})
89
+ result = run(fake, "rutinas", "cambiar", "4", "--para-todos", "--si")
90
+ assert result.exit_code == 0, result.output
91
+ assert fake.sent("PATCH", "people/routines/4/") == {"role_ids": [], "employee_ids": []}
92
+
93
+
94
+ def test_cambiar_needs_something_to_change():
95
+ fake = FakeApi({})
96
+ result = run(fake, "rutinas", "cambiar", "4", "--si")
97
+ assert result.exit_code == 1
98
+ assert "Di qué cambiar" in result.output
99
+ assert fake.requests == []
100
+
101
+
102
+ def test_quitar_takes_the_routine_off_the_plans():
103
+ fake = FakeApi({("GET", "people/routines/"): [BITACORA], ("DELETE", "people/routines/3/"): (204, None)})
104
+ result = run(fake, "rutinas", "quitar", "Llenar la bitácora", "--si")
105
+ assert result.exit_code == 0, result.output
106
+ assert fake.called("DELETE", "people/routines/3/")
107
+ assert "quitada" in result.output
108
+
109
+
110
+ def test_quitar_with_an_unknown_routine_changes_nothing():
111
+ fake = FakeApi({("GET", "people/routines/"): [BITACORA]})
112
+ result = run(fake, "rutinas", "quitar", "Otra", "--si")
113
+ assert result.exit_code == 1
114
+ assert not fake.called("DELETE", "people/routines/3/")
115
+
116
+
117
+ def test_cumplimiento_asks_for_the_month_and_shows_each_person():
118
+ fake = FakeApi({("GET", "people/routines/compliance/"): COMPLIANCE})
119
+ result = run(fake, "rutinas", "cumplimiento", "--mes", "10/2026")
120
+ assert result.exit_code == 0, result.output
121
+ assert fake.requests[0].url.params["month"] == "2026-10"
122
+ assert "María Torres" in result.output and "Técnico" in result.output
123
+
124
+
125
+ def test_cumplimiento_json_is_the_whole_answer():
126
+ fake = FakeApi({("GET", "people/routines/compliance/"): COMPLIANCE})
127
+ result = run(fake, "rutinas", "cumplimiento", "--mes", "2026-10", "--json")
128
+ assert json.loads(result.output) == COMPLIANCE
@@ -3,7 +3,7 @@ import pytest
3
3
  import typer.main
4
4
  from typer.testing import CliRunner
5
5
 
6
- from operx_cli import settings
6
+ from operx_cli import __version__, settings
7
7
  from operx_cli.app import app
8
8
  from operx_cli.commands.common import Session
9
9
  from operx_cli.shell import STOP_WORDS, run_shell, split_line, suggestions
@@ -183,6 +183,23 @@ def test_a_refused_call_shows_the_apis_words_and_the_session_goes_on(capsys):
183
183
  assert "Ana Ruiz" in out.out
184
184
 
185
185
 
186
+ def test_a_crash_says_what_failed_and_where_and_the_session_goes_on(capsys):
187
+ # A list where the CLI expects an object: an answer it doesn't know.
188
+ fake = FakeApi({("GET", "search/"): [{"kind": "employee", "id": 4, "title": "Ana Ríos"}], ("GET", "auth/me/"): ME})
189
+
190
+ shell(fake, "buscar prensa-zeta-77", "cuenta")
191
+
192
+ out = capsys.readouterr()
193
+ # The line a person sends to support: the error, the CLI's own line where
194
+ # it happened and the version, whose line numbers those are.
195
+ assert "AttributeError: 'list' object has no attribute 'get'" in out.err
196
+ assert "commands/search.py:" in out.err
197
+ assert f"operx {__version__}" in out.err
198
+ # Not what was typed: it can hold anything, a token included.
199
+ assert "prensa-zeta-77" not in out.err
200
+ assert "Ana Ruiz" in out.out
201
+
202
+
186
203
  def test_help_inside_the_shell_does_not_end_it(capsys):
187
204
  shell(FakeApi({("GET", "auth/me/"): ME}), "ordenes --help", "cuenta")
188
205
  assert "Ana Ruiz" in capsys.readouterr().out
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes