operx-cli 0.7.2__tar.gz → 0.7.4__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.7.2 → operx_cli-0.7.4}/PKG-INFO +9 -1
- {operx_cli-0.7.2 → operx_cli-0.7.4}/README.md +8 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/AGENTES.md +20 -2
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/__init__.py +1 -1
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/app.py +4 -2
- operx_cli-0.7.4/operx_cli/commands/campos.py +249 -0
- operx_cli-0.7.4/operx_cli/commands/rutinas.py +221 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/shell.py +1 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli.egg-info/PKG-INFO +9 -1
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli.egg-info/SOURCES.txt +4 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/pyproject.toml +1 -1
- operx_cli-0.7.4/tests/test_campos.py +165 -0
- operx_cli-0.7.4/tests/test_rutinas.py +177 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/agent_guide.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/api_fields.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/browser_login.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/client.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/__init__.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/account.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/ambiental.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/articulos.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/avisos.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/common.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/completar.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/empresas.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/inventario.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/lecturas.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/ordenes.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/personas.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/planta.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/preventivos.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/raw.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/registros.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/reportes.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/search.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/sst.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/commands/update.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/lookup.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/output.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/record_completion.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/self_update.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/settings.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/spreadsheet.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/updates.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/values.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli/welcome.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli.egg-info/dependency_links.txt +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli.egg-info/entry_points.txt +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli.egg-info/requires.txt +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/operx_cli.egg-info/top_level.txt +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/setup.cfg +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_agentes.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_api_campos.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_app.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_articulos.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_cards.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_client.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_completar.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_daily_flows.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_inventario_lecturas.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_json_writes.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_login.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_logout_and_routes.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_lookup.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_more_of_the_api.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_ordenes.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_output.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_personas.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_preventivos.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_record_completion.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_registros.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_self_update.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_settings.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_shell.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_spreadsheet.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_sst_ambiental.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_updates.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/tests/test_values.py +0 -0
- {operx_cli-0.7.2 → operx_cli-0.7.4}/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.
|
|
3
|
+
Version: 0.7.4
|
|
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,14 @@ 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 crear "Revisar el taller" --dia lunes --dia jueves # sin --dia, va todos los días
|
|
178
|
+
operx rutinas cambiar "Contar la bodega" --persona "Ana Ruiz"
|
|
179
|
+
operx rutinas cambiar "Revisar el taller" --todos-los-dias
|
|
180
|
+
operx rutinas quitar "Contar la bodega" # sale de los planes desde ahora
|
|
181
|
+
operx rutinas cumplimiento --mes 10/2026 # quién hizo sus rutinas (administradores)
|
|
182
|
+
|
|
175
183
|
operx sst acciones --estado abiertas
|
|
176
184
|
operx sst nueva-accion --titulo "Señalizar la salida" --para 15/11/2026 --responsable "Ana Ruiz"
|
|
177
185
|
operx sst accion-hecha 7 --evidencia "Se instalaron dos señales"
|
|
@@ -158,6 +158,14 @@ 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 crear "Revisar el taller" --dia lunes --dia jueves # sin --dia, va todos los días
|
|
164
|
+
operx rutinas cambiar "Contar la bodega" --persona "Ana Ruiz"
|
|
165
|
+
operx rutinas cambiar "Revisar el taller" --todos-los-dias
|
|
166
|
+
operx rutinas quitar "Contar la bodega" # sale de los planes desde ahora
|
|
167
|
+
operx rutinas cumplimiento --mes 10/2026 # quién hizo sus rutinas (administradores)
|
|
168
|
+
|
|
161
169
|
operx sst acciones --estado abiertas
|
|
162
170
|
operx sst nueva-accion --titulo "Señalizar la salida" --para 15/11/2026 --responsable "Ana Ruiz"
|
|
163
171
|
operx sst accion-hecha 7 --evidencia "Se instalaron dos señales"
|
|
@@ -68,12 +68,30 @@ 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`; `campos crear`, `cambiar`,
|
|
73
|
+
`quitar` y `guardar`.
|
|
72
74
|
- SG-SST y Ambiental: `sst nueva-accion` y `accion-hecha`;
|
|
73
75
|
`ambiental nueva-accion`, `accion-hecha`, `residuo` y `lectura`.
|
|
74
76
|
- `avisos --marcar-leidos`, y `operx api` con POST, PUT, PATCH o DELETE.
|
|
75
77
|
- Un error se corrige con su contrario, nunca borrando: `inventario revertir`
|
|
76
|
-
deshace un movimiento, `lecturas anular` una lectura.
|
|
78
|
+
deshace un movimiento, `lecturas anular` una lectura. La excepción es
|
|
79
|
+
`rutinas quitar`, que saca una rutina de los planes desde ahora; los días
|
|
80
|
+
que ya la tenían la siguen contando.
|
|
81
|
+
- Una rutina es para todos, para algunos roles (`--rol`, por su nombre) o
|
|
82
|
+
para algunas personas (`--persona`); `rutinas cambiar --rol` reemplaza la
|
|
83
|
+
lista entera de roles. Va todos los días o solo algunos de la semana
|
|
84
|
+
(`--dia lunes --dia jueves`); `rutinas cambiar --dia` reemplaza la lista
|
|
85
|
+
entera de días y `--todos-los-dias` la deja yendo todos los días.
|
|
86
|
+
- Lo que la ficha de una persona no tiene (fecha de nacimiento, hijos, estado
|
|
87
|
+
civil…) va en un campo de información extra: `operx campos listar` dice
|
|
88
|
+
cuáles hay y, si falta, `campos crear "Hijos" --tipo numero` lo agrega para
|
|
89
|
+
todas las personas (tipos: texto, texto-largo, numero, fecha, si-no y lista,
|
|
90
|
+
con `--opcion` por cada opción). El tipo no se cambia después. Los valores
|
|
91
|
+
van por persona: `campos guardar "Ana Ruiz" --valor "Hijos=2"` (solo cambian
|
|
92
|
+
los campos que digas; `--vaciar Hijos` deja uno vacío) y `campos ver`. Antes
|
|
93
|
+
de crear un campo, mira que no exista ya. `campos quitar` lo oculta de todas
|
|
94
|
+
las personas y deja los valores guardados.
|
|
77
95
|
- SG-SST y Ambiental van por empresa: sin `--empresa`, la de la cuenta. Una
|
|
78
96
|
cuenta que lleva el SG-SST o lo ambiental de sus clientes ve las demás con
|
|
79
97
|
`operx sst empresas` o `operx ambiental empresas`.
|
|
@@ -14,8 +14,8 @@ from . import __version__, settings, shell
|
|
|
14
14
|
from .agent_guide import guide_text
|
|
15
15
|
from .client import ApiError
|
|
16
16
|
from .commands import (
|
|
17
|
-
account, ambiental, articulos, avisos, completar, inventario, lecturas, ordenes, personas, planta,
|
|
18
|
-
registros, reportes, search, sst, update,
|
|
17
|
+
account, ambiental, articulos, avisos, campos, completar, inventario, lecturas, ordenes, personas, planta,
|
|
18
|
+
preventivos, raw, 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,8 @@ 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")
|
|
159
|
+
app.add_typer(campos.app, name="campos")
|
|
158
160
|
app.add_typer(reportes.app, name="reportes")
|
|
159
161
|
app.add_typer(sst.app, name="sst")
|
|
160
162
|
app.add_typer(ambiental.app, name="ambiental")
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""`operx campos`: Información extra, the fields a company adds to the record
|
|
2
|
+
of its people (a birthday, the children, the marital status...) for what the
|
|
3
|
+
record doesn't have, and their values on each person
|
|
4
|
+
(backend/extra_fields, backend/people/extra_info.py). The fields are
|
|
5
|
+
company-wide; each person has a value or none for every one."""
|
|
6
|
+
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Annotated, Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from ..lookup import NoSingleMatch, find_in, find_one
|
|
14
|
+
from ..output import print_record, print_rows
|
|
15
|
+
from ..values import decimal_text, iso_date
|
|
16
|
+
from .common import CliError, CsvOption, JsonOption, YesOption, client_of, confirm_write, output_format, print_result
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
help="Información extra: los campos que tu empresa agrega a la ficha de las personas, y sus valores.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# The kinds of record that can have extra fields; for now, people.
|
|
24
|
+
ENTITY = "employee"
|
|
25
|
+
|
|
26
|
+
FIELD_COLUMNS = [("id", "Id"), ("name", "Campo"), ("type", "Tipo"), ("options", "Opciones")]
|
|
27
|
+
VALUE_COLUMNS = [("name", "Campo"), ("value", "Valor")]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Tipo(str, Enum):
|
|
31
|
+
texto = "texto"
|
|
32
|
+
texto_largo = "texto-largo"
|
|
33
|
+
numero = "numero"
|
|
34
|
+
fecha = "fecha"
|
|
35
|
+
si_no = "si-no"
|
|
36
|
+
lista = "lista"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# backend/extra_fields/models.py TYPE_CHOICES
|
|
40
|
+
TYPE_KEYS = {
|
|
41
|
+
"texto": "text", "texto-largo": "long_text", "numero": "number", "fecha": "date", "si-no": "yes_no", "lista": "choice",
|
|
42
|
+
}
|
|
43
|
+
TYPE_LABELS = {
|
|
44
|
+
"text": "Texto", "long_text": "Texto largo", "number": "Número", "date": "Fecha", "yes_no": "Sí / No", "choice": "Lista",
|
|
45
|
+
}
|
|
46
|
+
YES_WORDS = {"si", "sí", "s", "true", "1"}
|
|
47
|
+
NO_WORDS = {"no", "n", "false", "0"}
|
|
48
|
+
|
|
49
|
+
OpcionOption = Annotated[
|
|
50
|
+
Optional[list[str]],
|
|
51
|
+
typer.Option("--opcion", help="Una opción de la lista; repítela para varias. Solo para el tipo lista."),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fields(client):
|
|
56
|
+
return client.get("extra-fields", {"entity": ENTITY})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _find_field(fields, text):
|
|
60
|
+
"""A field by its id or its exact name."""
|
|
61
|
+
try:
|
|
62
|
+
return find_in(fields, text, ["id", "name"], "campo")
|
|
63
|
+
except NoSingleMatch as error:
|
|
64
|
+
raise CliError(str(error)) from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _person(client, text):
|
|
68
|
+
return find_one(client, "employees", text, ["user_name", "employee_code"], "persona")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _split_pair(pair):
|
|
72
|
+
name, separator, value = pair.partition("=")
|
|
73
|
+
if not separator or not name.strip():
|
|
74
|
+
raise CliError(f"«{pair}» no se entiende: escríbelo como Campo=valor, por ejemplo Hijos=2.")
|
|
75
|
+
return name.strip(), value.strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _number(text):
|
|
79
|
+
"""The API takes a JSON number: 2, 2.5 ("2,5" and "1.234,5" work too)."""
|
|
80
|
+
try:
|
|
81
|
+
value = Decimal(decimal_text(text))
|
|
82
|
+
except ValueError as error:
|
|
83
|
+
raise CliError(str(error)) from None
|
|
84
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _value_for(field, text):
|
|
88
|
+
"""What the API stores for the words typed, by the field's type."""
|
|
89
|
+
kind = field["field_type"]
|
|
90
|
+
if kind == "number":
|
|
91
|
+
return _number(text)
|
|
92
|
+
if kind == "date":
|
|
93
|
+
return iso_date(text)
|
|
94
|
+
if kind == "yes_no":
|
|
95
|
+
word = text.strip().casefold()
|
|
96
|
+
if word in YES_WORDS:
|
|
97
|
+
return True
|
|
98
|
+
if word in NO_WORDS:
|
|
99
|
+
return False
|
|
100
|
+
raise CliError(f"«{text}» no es sí ni no, en «{field['name']}».")
|
|
101
|
+
if kind == "choice":
|
|
102
|
+
wanted = " ".join(text.split()).casefold()
|
|
103
|
+
for option in field["options"]:
|
|
104
|
+
if " ".join(option.split()).casefold() == wanted:
|
|
105
|
+
return option
|
|
106
|
+
raise CliError(f"«{text}» no es una opción de «{field['name']}». Hay: {', '.join(field['options'])}.")
|
|
107
|
+
return text
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _shown(field):
|
|
111
|
+
"""A value as a person reads it."""
|
|
112
|
+
value = field["value"]
|
|
113
|
+
if value is None:
|
|
114
|
+
return "Sin registrar"
|
|
115
|
+
if field["field_type"] == "yes_no":
|
|
116
|
+
return "Sí" if value else "No"
|
|
117
|
+
if field["field_type"] == "date":
|
|
118
|
+
year, month, day = str(value).split("-")
|
|
119
|
+
return f"{day}/{month}/{year}"
|
|
120
|
+
return str(value)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def listar(
|
|
125
|
+
ctx: typer.Context,
|
|
126
|
+
as_json: JsonOption = False,
|
|
127
|
+
as_csv: CsvOption = False,
|
|
128
|
+
):
|
|
129
|
+
"""Los campos de información extra de las personas: su nombre, su tipo y las opciones de las listas."""
|
|
130
|
+
output = output_format(as_json, as_csv)
|
|
131
|
+
fields = _fields(client_of(ctx))
|
|
132
|
+
if output != "json":
|
|
133
|
+
fields = [
|
|
134
|
+
{**field, "type": TYPE_LABELS.get(field["field_type"], field["field_type"]), "options": ", ".join(field["options"])}
|
|
135
|
+
for field in fields
|
|
136
|
+
]
|
|
137
|
+
print_rows(FIELD_COLUMNS, fields, output)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def crear(
|
|
142
|
+
ctx: typer.Context,
|
|
143
|
+
nombre: Annotated[str, typer.Argument(help="El nombre del campo: «Fecha de nacimiento».")],
|
|
144
|
+
tipo: Annotated[Tipo, typer.Option("--tipo", help="Qué guarda el campo; no se cambia después.")] = Tipo.texto,
|
|
145
|
+
opcion: OpcionOption = None,
|
|
146
|
+
si: YesOption = False,
|
|
147
|
+
as_json: JsonOption = False,
|
|
148
|
+
):
|
|
149
|
+
"""Agrega un campo a la ficha de las personas. Todas las personas lo tienen, vacío hasta que se llene."""
|
|
150
|
+
if tipo != Tipo.lista and opcion:
|
|
151
|
+
raise CliError("--opcion solo va con --tipo lista.")
|
|
152
|
+
if tipo == Tipo.lista and not opcion:
|
|
153
|
+
raise CliError("Una lista necesita sus opciones: --opcion «Soltero/a» --opcion «Casado/a».")
|
|
154
|
+
body = {"entity": ENTITY, "name": nombre, "field_type": TYPE_KEYS[tipo.value], "options": opcion or []}
|
|
155
|
+
confirm_write(f"Agregar a las personas el campo «{nombre}» ({TYPE_LABELS[body['field_type']]}).", si)
|
|
156
|
+
created = client_of(ctx).send("POST", "extra-fields", json=body).json()
|
|
157
|
+
print_result(created, as_json, f"Campo {created['id']} creado: {created['name']}.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command()
|
|
161
|
+
def cambiar(
|
|
162
|
+
ctx: typer.Context,
|
|
163
|
+
campo: Annotated[str, typer.Argument(help="El id o el nombre exacto del campo.")],
|
|
164
|
+
nombre: Annotated[Optional[str], typer.Option("--nombre", help="El nombre nuevo.")] = None,
|
|
165
|
+
opcion: OpcionOption = None,
|
|
166
|
+
si: YesOption = False,
|
|
167
|
+
as_json: JsonOption = False,
|
|
168
|
+
):
|
|
169
|
+
"""Cambia el nombre de un campo o las opciones de una lista. --opcion reemplaza la lista entera."""
|
|
170
|
+
if not (nombre or opcion):
|
|
171
|
+
raise CliError("Di qué cambiar: --nombre o --opcion.")
|
|
172
|
+
client = client_of(ctx)
|
|
173
|
+
found = _find_field(_fields(client), campo)
|
|
174
|
+
body = {}
|
|
175
|
+
if nombre:
|
|
176
|
+
body["name"] = nombre
|
|
177
|
+
if opcion:
|
|
178
|
+
body["options"] = opcion
|
|
179
|
+
confirm_write(f"Cambiar el campo «{found['name']}».", si)
|
|
180
|
+
changed = client.send("PATCH", f"extra-fields/{found['id']}", json=body).json()
|
|
181
|
+
print_result(changed, as_json, f"Campo {changed['id']} cambiado: {changed['name']}.")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command()
|
|
185
|
+
def quitar(
|
|
186
|
+
ctx: typer.Context,
|
|
187
|
+
campo: Annotated[str, typer.Argument(help="El id o el nombre exacto del campo.")],
|
|
188
|
+
si: YesOption = False,
|
|
189
|
+
as_json: JsonOption = False,
|
|
190
|
+
):
|
|
191
|
+
"""Quita un campo de la ficha de todas las personas. Los valores ya guardados quedan en la base de datos."""
|
|
192
|
+
client = client_of(ctx)
|
|
193
|
+
found = _find_field(_fields(client), campo)
|
|
194
|
+
confirm_write(f"Quitar el campo «{found['name']}» de todas las personas.", si)
|
|
195
|
+
client.send("DELETE", f"extra-fields/{found['id']}")
|
|
196
|
+
print_result({"id": found["id"], "removed": True}, as_json, f"Campo {found['id']} quitado: {found['name']}.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command()
|
|
200
|
+
def ver(
|
|
201
|
+
ctx: typer.Context,
|
|
202
|
+
persona: Annotated[str, typer.Argument(help="El nombre exacto, el código o el id de la persona.")],
|
|
203
|
+
as_json: JsonOption = False,
|
|
204
|
+
as_csv: CsvOption = False,
|
|
205
|
+
):
|
|
206
|
+
"""La información extra de una persona: cada campo con su valor."""
|
|
207
|
+
output = output_format(as_json, as_csv)
|
|
208
|
+
client = client_of(ctx)
|
|
209
|
+
person = _person(client, persona)
|
|
210
|
+
answer = client.get(f"people/employees/{person['id']}/extra-fields")
|
|
211
|
+
if output == "json":
|
|
212
|
+
print_record(answer, "json")
|
|
213
|
+
return
|
|
214
|
+
rows = [{"name": field["name"], "value": _shown(field)} for field in answer["fields"]]
|
|
215
|
+
print_rows(VALUE_COLUMNS, rows, output)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.command()
|
|
219
|
+
def guardar(
|
|
220
|
+
ctx: typer.Context,
|
|
221
|
+
persona: Annotated[str, typer.Argument(help="El nombre exacto, el código o el id de la persona.")],
|
|
222
|
+
valor: Annotated[
|
|
223
|
+
Optional[list[str]],
|
|
224
|
+
typer.Option("--valor", help="Campo=valor, por ejemplo «Hijos=2» o «Fecha de nacimiento=17/05/1990»; repítelo para varios."),
|
|
225
|
+
] = None,
|
|
226
|
+
vaciar: Annotated[
|
|
227
|
+
Optional[list[str]], typer.Option("--vaciar", help="El campo que se deja vacío; repítelo para varios."),
|
|
228
|
+
] = None,
|
|
229
|
+
si: YesOption = False,
|
|
230
|
+
as_json: JsonOption = False,
|
|
231
|
+
):
|
|
232
|
+
"""Guarda valores en la información extra de una persona. Solo cambian los campos que digas."""
|
|
233
|
+
if not (valor or vaciar):
|
|
234
|
+
raise CliError("Di qué guardar: --valor Campo=valor o --vaciar Campo.")
|
|
235
|
+
client = client_of(ctx)
|
|
236
|
+
fields = _fields(client)
|
|
237
|
+
values = {}
|
|
238
|
+
for pair in valor or []:
|
|
239
|
+
name, text = _split_pair(pair)
|
|
240
|
+
field = _find_field(fields, name)
|
|
241
|
+
values[str(field["id"])] = _value_for(field, text)
|
|
242
|
+
for name in vaciar or []:
|
|
243
|
+
values[str(_find_field(fields, name)["id"])] = None
|
|
244
|
+
person = _person(client, persona)
|
|
245
|
+
who = person.get("user_name") or persona
|
|
246
|
+
changes = list(valor or []) + [f"vaciar {name}" for name in vaciar or []]
|
|
247
|
+
confirm_write(f"Guardar en la información extra de {who}: {'; '.join(changes)}.", si)
|
|
248
|
+
saved = client.send("PATCH", f"people/employees/{person['id']}/extra-fields", json={"values": values}).json()
|
|
249
|
+
print_result(saved, as_json, f"Información extra de {who} guardada.")
|
|
@@ -0,0 +1,221 @@
|
|
|
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; one with no days goes every day."""
|
|
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"), ("days", "Días"),
|
|
26
|
+
("people_count", "Les llega a"),
|
|
27
|
+
]
|
|
28
|
+
# The API numbers the days 0 Monday ... 6 Sunday.
|
|
29
|
+
WEEKDAY_NAMES = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
|
|
30
|
+
WEEKDAY_ALIASES = {"miercoles": 2, "sabado": 5}
|
|
31
|
+
COMPLIANCE_COLUMNS = [
|
|
32
|
+
("name", "Persona"), ("position", "Cargo"), ("done", "Hechas"), ("due", "Debidas"), ("days_without_plan", "Días sin plan"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
RolOption = Annotated[
|
|
36
|
+
Optional[list[str]], typer.Option("--rol", help="El nombre de un rol que la recibe; repítelo para varios.")
|
|
37
|
+
]
|
|
38
|
+
PersonaOption = Annotated[
|
|
39
|
+
Optional[list[str]], typer.Option("--persona", help="El nombre o código de una persona que la recibe; repítelo para varias.")
|
|
40
|
+
]
|
|
41
|
+
DiaOption = Annotated[
|
|
42
|
+
Optional[list[str]],
|
|
43
|
+
typer.Option("--dia", help="Un día de la semana en que va (lunes ... domingo); repítelo para varios. Sin --dia, va todos los días."),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _weekdays(names):
|
|
48
|
+
"""The API's day numbers for the names typed ("lunes", "Sábado"), sorted."""
|
|
49
|
+
numbers = set()
|
|
50
|
+
for name in names or []:
|
|
51
|
+
wanted = " ".join(name.split()).casefold()
|
|
52
|
+
if wanted in WEEKDAY_NAMES:
|
|
53
|
+
numbers.add(WEEKDAY_NAMES.index(wanted))
|
|
54
|
+
elif wanted in WEEKDAY_ALIASES:
|
|
55
|
+
numbers.add(WEEKDAY_ALIASES[wanted])
|
|
56
|
+
else:
|
|
57
|
+
raise CliError(f"«{name}» no es un día de la semana. Usa: {', '.join(WEEKDAY_NAMES)}.")
|
|
58
|
+
return sorted(numbers)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _days_text(weekdays):
|
|
62
|
+
"""The days a routine goes, for a person: "todos los días" or "lunes, jueves"."""
|
|
63
|
+
if not weekdays or len(weekdays) == len(WEEKDAY_NAMES):
|
|
64
|
+
return "todos los días"
|
|
65
|
+
return ", ".join(WEEKDAY_NAMES[number] for number in sorted(weekdays))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _role_ids(client, names):
|
|
69
|
+
if not names:
|
|
70
|
+
return []
|
|
71
|
+
roles = client.get("people/routines/roles")
|
|
72
|
+
ids = []
|
|
73
|
+
for name in names:
|
|
74
|
+
try:
|
|
75
|
+
ids.append(find_in(roles, name, ["name"], "rol")["id"])
|
|
76
|
+
except NoSingleMatch as error:
|
|
77
|
+
# A short list: saying which there are saves a trip to the settings.
|
|
78
|
+
raise CliError(f"{error} Hay: {', '.join(role['name'] for role in roles)}.") from None
|
|
79
|
+
return ids
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _employee_ids(client, names):
|
|
83
|
+
return [find_one(client, "employees", name, ["user_name", "employee_code"], "persona")["id"] for name in names or []]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _routine(client, text):
|
|
87
|
+
"""A routine by its id or its exact text."""
|
|
88
|
+
return find_in(client.get("people/routines"), text, ["id", "text"], "rutina")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _who(roles, people):
|
|
92
|
+
if not roles and not people:
|
|
93
|
+
return "para todos"
|
|
94
|
+
parts = []
|
|
95
|
+
if roles:
|
|
96
|
+
parts.append(f"los roles {', '.join(roles)}")
|
|
97
|
+
if people:
|
|
98
|
+
parts.append(f"las personas {', '.join(people)}")
|
|
99
|
+
return "para " + " y ".join(parts)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _only_days(weekdays):
|
|
103
|
+
"""What a confirmation adds when the routine goes on some days only."""
|
|
104
|
+
if not weekdays or len(weekdays) == len(WEEKDAY_NAMES):
|
|
105
|
+
return ""
|
|
106
|
+
return f" (solo: {_days_text(weekdays)})"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command()
|
|
110
|
+
def listar(
|
|
111
|
+
ctx: typer.Context,
|
|
112
|
+
as_json: JsonOption = False,
|
|
113
|
+
as_csv: CsvOption = False,
|
|
114
|
+
):
|
|
115
|
+
"""Las rutinas de la empresa, los días en que van y a cuántas personas les llega cada una."""
|
|
116
|
+
output = output_format(as_json, as_csv)
|
|
117
|
+
routines = client_of(ctx).get("people/routines")
|
|
118
|
+
if output != "json":
|
|
119
|
+
routines = [{**routine, "days": _days_text(routine.get("weekdays"))} for routine in routines]
|
|
120
|
+
print_rows(ROUTINE_COLUMNS, routines, output)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def crear(
|
|
125
|
+
ctx: typer.Context,
|
|
126
|
+
texto: Annotated[str, typer.Argument(help="Lo que va en el plan: «Llenar la bitácora».")],
|
|
127
|
+
rol: RolOption = None,
|
|
128
|
+
persona: PersonaOption = None,
|
|
129
|
+
dia: DiaOption = None,
|
|
130
|
+
si: YesOption = False,
|
|
131
|
+
as_json: JsonOption = False,
|
|
132
|
+
):
|
|
133
|
+
"""Crea una rutina. Sin --rol ni --persona, es para todos; sin --dia, va todos los días."""
|
|
134
|
+
client = client_of(ctx)
|
|
135
|
+
weekdays = _weekdays(dia)
|
|
136
|
+
body = {"text": texto, "role_ids": _role_ids(client, rol), "employee_ids": _employee_ids(client, persona)}
|
|
137
|
+
if weekdays:
|
|
138
|
+
body["weekdays"] = weekdays
|
|
139
|
+
confirm_write(f"Crear la rutina «{texto}» {_who(rol, persona)}{_only_days(weekdays)}.", si)
|
|
140
|
+
created = client.send("POST", "people/routines", json=body).json()
|
|
141
|
+
print_result(
|
|
142
|
+
created, as_json,
|
|
143
|
+
f"Rutina {created['id']} creada: {created['text']}. Les llega a {created['people_count']} personas.",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command()
|
|
148
|
+
def cambiar(
|
|
149
|
+
ctx: typer.Context,
|
|
150
|
+
rutina: Annotated[str, typer.Argument(help="El id o el texto exacto de la rutina.")],
|
|
151
|
+
texto: Annotated[Optional[str], typer.Option("--texto", help="El texto nuevo.")] = None,
|
|
152
|
+
rol: RolOption = None,
|
|
153
|
+
persona: PersonaOption = None,
|
|
154
|
+
para_todos: Annotated[bool, typer.Option("--para-todos", help="La deja para todos, sin roles ni personas.")] = False,
|
|
155
|
+
dia: DiaOption = None,
|
|
156
|
+
todos_los_dias: Annotated[bool, typer.Option("--todos-los-dias", help="La deja yendo todos los días.")] = False,
|
|
157
|
+
si: YesOption = False,
|
|
158
|
+
as_json: JsonOption = False,
|
|
159
|
+
):
|
|
160
|
+
"""Cambia una rutina. --rol, --persona y --dia reemplazan la lista entera; lo que no digas queda como estaba."""
|
|
161
|
+
if para_todos and (rol or persona):
|
|
162
|
+
raise CliError("--para-todos no va con --rol ni --persona.")
|
|
163
|
+
if todos_los_dias and dia:
|
|
164
|
+
raise CliError("--todos-los-dias no va con --dia.")
|
|
165
|
+
if not (texto or rol or persona or para_todos or dia or todos_los_dias):
|
|
166
|
+
raise CliError("Di qué cambiar: --texto, --rol, --persona, --para-todos, --dia o --todos-los-dias.")
|
|
167
|
+
weekdays = _weekdays(dia)
|
|
168
|
+
client = client_of(ctx)
|
|
169
|
+
found = _routine(client, rutina)
|
|
170
|
+
body = without_empty({"text": texto})
|
|
171
|
+
if todos_los_dias:
|
|
172
|
+
body["weekdays"] = []
|
|
173
|
+
if weekdays:
|
|
174
|
+
body["weekdays"] = weekdays
|
|
175
|
+
if para_todos:
|
|
176
|
+
body["role_ids"] = []
|
|
177
|
+
body["employee_ids"] = []
|
|
178
|
+
if rol:
|
|
179
|
+
body["role_ids"] = _role_ids(client, rol)
|
|
180
|
+
if persona:
|
|
181
|
+
body["employee_ids"] = _employee_ids(client, persona)
|
|
182
|
+
confirm_write(f"Cambiar la rutina «{found['text']}».", si)
|
|
183
|
+
changed = client.send("PATCH", f"people/routines/{found['id']}", json=body).json()
|
|
184
|
+
print_result(
|
|
185
|
+
changed, as_json,
|
|
186
|
+
f"Rutina {changed['id']} cambiada: {changed['text']}. Les llega a {changed['people_count']} personas.",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.command()
|
|
191
|
+
def quitar(
|
|
192
|
+
ctx: typer.Context,
|
|
193
|
+
rutina: Annotated[str, typer.Argument(help="El id o el texto exacto de la rutina.")],
|
|
194
|
+
si: YesOption = False,
|
|
195
|
+
as_json: JsonOption = False,
|
|
196
|
+
):
|
|
197
|
+
"""Quita una rutina de los planes desde ahora; los días que ya la tenían la siguen contando."""
|
|
198
|
+
client = client_of(ctx)
|
|
199
|
+
found = _routine(client, rutina)
|
|
200
|
+
confirm_write(f"Quitar la rutina «{found['text']}» de los planes.", si)
|
|
201
|
+
client.send("DELETE", f"people/routines/{found['id']}")
|
|
202
|
+
print_result({"id": found["id"], "removed": True}, as_json, f"Rutina {found['id']} quitada: {found['text']}.")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@app.command()
|
|
206
|
+
def cumplimiento(
|
|
207
|
+
ctx: typer.Context,
|
|
208
|
+
mes: Annotated[Optional[str], typer.Option("--mes", help="El mes: 11/2026; sin él, el actual.")] = None,
|
|
209
|
+
as_json: JsonOption = False,
|
|
210
|
+
as_csv: CsvOption = False,
|
|
211
|
+
):
|
|
212
|
+
"""Quién hizo sus rutinas en un mes: por persona, las que traía su plan y las que marcó. Solo administradores."""
|
|
213
|
+
output = output_format(as_json, as_csv)
|
|
214
|
+
month_text = month(mes) if mes else date.today().strftime("%Y-%m")
|
|
215
|
+
answer = client_of(ctx).get("people/routines/compliance", {"month": month_text})
|
|
216
|
+
if output == "json":
|
|
217
|
+
print_record(answer, "json")
|
|
218
|
+
return
|
|
219
|
+
if answer.get("through"):
|
|
220
|
+
note(f"Cuenta hasta el {shown_moment(answer['through'])}: hoy no cuenta.")
|
|
221
|
+
print_rows(COMPLIANCE_COLUMNS, answer.get("people", []), output)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: operx-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.4
|
|
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,14 @@ 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 crear "Revisar el taller" --dia lunes --dia jueves # sin --dia, va todos los días
|
|
178
|
+
operx rutinas cambiar "Contar la bodega" --persona "Ana Ruiz"
|
|
179
|
+
operx rutinas cambiar "Revisar el taller" --todos-los-dias
|
|
180
|
+
operx rutinas quitar "Contar la bodega" # sale de los planes desde ahora
|
|
181
|
+
operx rutinas cumplimiento --mes 10/2026 # quién hizo sus rutinas (administradores)
|
|
182
|
+
|
|
175
183
|
operx sst acciones --estado abiertas
|
|
176
184
|
operx sst nueva-accion --titulo "Señalizar la salida" --para 15/11/2026 --responsable "Ana Ruiz"
|
|
177
185
|
operx sst accion-hecha 7 --evidencia "Se instalaron dos señales"
|
|
@@ -28,6 +28,7 @@ operx_cli/commands/account.py
|
|
|
28
28
|
operx_cli/commands/ambiental.py
|
|
29
29
|
operx_cli/commands/articulos.py
|
|
30
30
|
operx_cli/commands/avisos.py
|
|
31
|
+
operx_cli/commands/campos.py
|
|
31
32
|
operx_cli/commands/common.py
|
|
32
33
|
operx_cli/commands/completar.py
|
|
33
34
|
operx_cli/commands/empresas.py
|
|
@@ -40,6 +41,7 @@ operx_cli/commands/preventivos.py
|
|
|
40
41
|
operx_cli/commands/raw.py
|
|
41
42
|
operx_cli/commands/registros.py
|
|
42
43
|
operx_cli/commands/reportes.py
|
|
44
|
+
operx_cli/commands/rutinas.py
|
|
43
45
|
operx_cli/commands/search.py
|
|
44
46
|
operx_cli/commands/sst.py
|
|
45
47
|
operx_cli/commands/update.py
|
|
@@ -47,6 +49,7 @@ tests/test_agentes.py
|
|
|
47
49
|
tests/test_api_campos.py
|
|
48
50
|
tests/test_app.py
|
|
49
51
|
tests/test_articulos.py
|
|
52
|
+
tests/test_campos.py
|
|
50
53
|
tests/test_cards.py
|
|
51
54
|
tests/test_client.py
|
|
52
55
|
tests/test_completar.py
|
|
@@ -63,6 +66,7 @@ tests/test_personas.py
|
|
|
63
66
|
tests/test_preventivos.py
|
|
64
67
|
tests/test_record_completion.py
|
|
65
68
|
tests/test_registros.py
|
|
69
|
+
tests/test_rutinas.py
|
|
66
70
|
tests/test_self_update.py
|
|
67
71
|
tests/test_settings.py
|
|
68
72
|
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.
|
|
7
|
+
version = "0.7.4"
|
|
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"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from .fake_api import FakeApi, page, run
|
|
6
|
+
|
|
7
|
+
# extra_fields/serializers.py ExtraFieldSerializer
|
|
8
|
+
HIJOS = {"id": 1, "entity": "employee", "name": "Hijos", "field_type": "number", "options": [], "position": 0}
|
|
9
|
+
NACIMIENTO = {"id": 2, "entity": "employee", "name": "Fecha de nacimiento", "field_type": "date", "options": [], "position": 1}
|
|
10
|
+
ESTADO = {
|
|
11
|
+
"id": 3, "entity": "employee", "name": "Estado civil", "field_type": "choice",
|
|
12
|
+
"options": ["Soltero/a", "Casado/a"], "position": 2,
|
|
13
|
+
}
|
|
14
|
+
CONDUCE = {"id": 4, "entity": "employee", "name": "Conduce", "field_type": "yes_no", "options": [], "position": 3}
|
|
15
|
+
FIELDS = [HIJOS, NACIMIENTO, ESTADO, CONDUCE]
|
|
16
|
+
# employees/serializers.py: the staff list
|
|
17
|
+
MARIA = {"id": 7, "user_name": "María Torres", "employee_code": "E-007"}
|
|
18
|
+
# people/extra_info.py extra_payload
|
|
19
|
+
MARIA_VALUES = {"fields": [
|
|
20
|
+
{"id": 1, "name": "Hijos", "field_type": "number", "options": [], "value": 2},
|
|
21
|
+
{"id": 2, "name": "Fecha de nacimiento", "field_type": "date", "options": [], "value": "1990-05-17"},
|
|
22
|
+
{"id": 3, "name": "Estado civil", "field_type": "choice", "options": ["Soltero/a", "Casado/a"], "value": None},
|
|
23
|
+
]}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(autouse=True)
|
|
27
|
+
def signed_in(monkeypatch):
|
|
28
|
+
monkeypatch.setenv("OPERX_TOKEN", "opx_test")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def saving_fake(answer=None):
|
|
32
|
+
return FakeApi({
|
|
33
|
+
("GET", "extra-fields/"): FIELDS,
|
|
34
|
+
("GET", "employees/"): page(MARIA),
|
|
35
|
+
("PATCH", "people/employees/7/extra-fields/"): answer or MARIA_VALUES,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_listar_shows_each_field_with_its_type_and_options():
|
|
40
|
+
fake = FakeApi({("GET", "extra-fields/"): FIELDS})
|
|
41
|
+
result = run(fake, "campos", "listar")
|
|
42
|
+
assert result.exit_code == 0, result.output
|
|
43
|
+
assert "Fecha de nacimiento" in result.output and "Número" in result.output
|
|
44
|
+
assert "Soltero/a, Casado/a" in result.output
|
|
45
|
+
assert fake.requests[0].url.params["entity"] == "employee"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_listar_json_is_the_api_s_answer_as_it_is():
|
|
49
|
+
fake = FakeApi({("GET", "extra-fields/"): FIELDS})
|
|
50
|
+
result = run(fake, "campos", "listar", "--json")
|
|
51
|
+
assert json.loads(result.output) == FIELDS
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_crear_sends_the_type_the_api_knows():
|
|
55
|
+
fake = FakeApi({("POST", "extra-fields/"): (201, HIJOS)})
|
|
56
|
+
result = run(fake, "campos", "crear", "Hijos", "--tipo", "numero", "--si")
|
|
57
|
+
assert result.exit_code == 0, result.output
|
|
58
|
+
assert fake.sent("POST", "extra-fields/") == {"entity": "employee", "name": "Hijos", "field_type": "number", "options": []}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_crear_a_list_sends_its_options_in_order():
|
|
62
|
+
fake = FakeApi({("POST", "extra-fields/"): (201, ESTADO)})
|
|
63
|
+
run(fake, "campos", "crear", "Estado civil", "--tipo", "lista", "--opcion", "Soltero/a", "--opcion", "Casado/a", "--si")
|
|
64
|
+
assert fake.sent("POST", "extra-fields/")["options"] == ["Soltero/a", "Casado/a"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_crear_refuses_a_list_without_options_and_options_without_a_list():
|
|
68
|
+
fake = FakeApi({})
|
|
69
|
+
without = run(fake, "campos", "crear", "Estado civil", "--tipo", "lista", "--si")
|
|
70
|
+
stray = run(fake, "campos", "crear", "Hijos", "--tipo", "numero", "--opcion", "1", "--si")
|
|
71
|
+
assert without.exit_code == 1 and "necesita sus opciones" in without.output
|
|
72
|
+
assert stray.exit_code == 1 and "solo va con --tipo lista" in stray.output
|
|
73
|
+
assert fake.requests == []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_crear_without_si_outside_a_terminal_changes_nothing():
|
|
77
|
+
fake = FakeApi({("POST", "extra-fields/"): (201, HIJOS)})
|
|
78
|
+
result = run(fake, "campos", "crear", "Hijos")
|
|
79
|
+
assert result.exit_code == 1
|
|
80
|
+
assert not fake.called("POST", "extra-fields/")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_cambiar_renames_a_field_found_by_its_name():
|
|
84
|
+
fake = FakeApi({("GET", "extra-fields/"): FIELDS, ("PATCH", "extra-fields/1/"): HIJOS})
|
|
85
|
+
result = run(fake, "campos", "cambiar", "hijos", "--nombre", "Número de hijos", "--si")
|
|
86
|
+
assert result.exit_code == 0, result.output
|
|
87
|
+
assert fake.sent("PATCH", "extra-fields/1/") == {"name": "Número de hijos"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_cambiar_with_nothing_to_change_says_so():
|
|
91
|
+
fake = FakeApi({})
|
|
92
|
+
result = run(fake, "campos", "cambiar", "Hijos", "--si")
|
|
93
|
+
assert result.exit_code == 1 and "Di qué cambiar" in result.output
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_quitar_hides_the_field():
|
|
97
|
+
fake = FakeApi({("GET", "extra-fields/"): FIELDS, ("DELETE", "extra-fields/1/"): (204, None)})
|
|
98
|
+
result = run(fake, "campos", "quitar", "Hijos", "--si")
|
|
99
|
+
assert result.exit_code == 0, result.output
|
|
100
|
+
assert fake.called("DELETE", "extra-fields/1/")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_a_name_that_is_no_field_lists_nothing_to_guess_from():
|
|
104
|
+
fake = FakeApi({("GET", "extra-fields/"): FIELDS})
|
|
105
|
+
result = run(fake, "campos", "quitar", "Mascotas", "--si")
|
|
106
|
+
assert result.exit_code == 1 and "Mascotas" in result.output
|
|
107
|
+
assert not fake.called("DELETE", "extra-fields/1/")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_ver_shows_each_field_with_its_value_as_a_person_reads_it():
|
|
111
|
+
fake = FakeApi({("GET", "employees/"): page(MARIA), ("GET", "people/employees/7/extra-fields/"): MARIA_VALUES})
|
|
112
|
+
result = run(fake, "campos", "ver", "María Torres")
|
|
113
|
+
assert result.exit_code == 0, result.output
|
|
114
|
+
assert "17/05/1990" in result.output and "Sin registrar" in result.output
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_ver_json_is_the_api_s_answer_as_it_is():
|
|
118
|
+
fake = FakeApi({("GET", "employees/"): page(MARIA), ("GET", "people/employees/7/extra-fields/"): MARIA_VALUES})
|
|
119
|
+
result = run(fake, "campos", "ver", "E-007", "--json")
|
|
120
|
+
assert json.loads(result.output) == MARIA_VALUES
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_guardar_turns_what_is_typed_into_what_each_type_takes():
|
|
124
|
+
fake = saving_fake()
|
|
125
|
+
result = run(
|
|
126
|
+
fake, "campos", "guardar", "María Torres",
|
|
127
|
+
"--valor", "Hijos=2,0", "--valor", "Fecha de nacimiento=17/05/1990", "--valor", "estado civil=casado/a",
|
|
128
|
+
"--valor", "Conduce=sí", "--si",
|
|
129
|
+
)
|
|
130
|
+
assert result.exit_code == 0, result.output
|
|
131
|
+
assert fake.sent("PATCH", "people/employees/7/extra-fields/") == {
|
|
132
|
+
"values": {"1": 2, "2": "1990-05-17", "3": "Casado/a", "4": True},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_guardar_a_decimal_number_stays_a_number():
|
|
137
|
+
fake = saving_fake()
|
|
138
|
+
run(fake, "campos", "guardar", "María Torres", "--valor", "Hijos=2,5", "--si")
|
|
139
|
+
assert fake.sent("PATCH", "people/employees/7/extra-fields/") == {"values": {"1": 2.5}}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_guardar_vaciar_sends_null_for_that_field():
|
|
143
|
+
fake = saving_fake()
|
|
144
|
+
run(fake, "campos", "guardar", "María Torres", "--vaciar", "Hijos", "--si")
|
|
145
|
+
assert fake.sent("PATCH", "people/employees/7/extra-fields/") == {"values": {"1": None}}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.parametrize("pair, complaint", [
|
|
149
|
+
("Hijos=dos", "no es un número"),
|
|
150
|
+
("Estado civil=Viudo/a", "no es una opción de «Estado civil». Hay: Soltero/a, Casado/a"),
|
|
151
|
+
("Conduce=quizás", "no es sí ni no"),
|
|
152
|
+
("Mascotas=2", "Mascotas"),
|
|
153
|
+
("Hijos", "Campo=valor"),
|
|
154
|
+
])
|
|
155
|
+
def test_guardar_refuses_a_value_that_does_not_fit_before_calling_the_api(pair, complaint):
|
|
156
|
+
fake = saving_fake()
|
|
157
|
+
result = run(fake, "campos", "guardar", "María Torres", "--valor", pair, "--si")
|
|
158
|
+
assert result.exit_code == 1 and complaint in result.output
|
|
159
|
+
assert not fake.called("PATCH", "people/employees/7/extra-fields/")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_guardar_with_nothing_to_save_says_so():
|
|
163
|
+
fake = FakeApi({})
|
|
164
|
+
result = run(fake, "campos", "guardar", "María Torres", "--si")
|
|
165
|
+
assert result.exit_code == 1 and "Di qué guardar" in result.output
|
|
@@ -0,0 +1,177 @@
|
|
|
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": [], "weekdays": [], "people_count": 12}
|
|
9
|
+
HERRAMIENTAS = {
|
|
10
|
+
"id": 4, "text": "Recoger herramientas", "roles": [{"id": 8, "name": "Bodeguero"}],
|
|
11
|
+
"employees": [{"id": 7, "name": "María Torres"}], "weekdays": [0, 3], "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_listar_says_on_which_days_each_routine_goes():
|
|
42
|
+
fake = FakeApi({("GET", "people/routines/"): [BITACORA, HERRAMIENTAS]})
|
|
43
|
+
result = run(fake, "rutinas", "listar")
|
|
44
|
+
assert result.exit_code == 0, result.output
|
|
45
|
+
assert "todos los días" in result.output and "lunes, jueves" in result.output
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_listar_json_is_the_api_s_answer_as_it_is():
|
|
49
|
+
fake = FakeApi({("GET", "people/routines/"): [BITACORA, HERRAMIENTAS]})
|
|
50
|
+
result = run(fake, "rutinas", "listar", "--json")
|
|
51
|
+
assert json.loads(result.output) == [BITACORA, HERRAMIENTAS]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_crear_with_days_sends_them_as_numbers_whatever_the_order_or_accents():
|
|
55
|
+
fake = FakeApi({("POST", "people/routines/"): (201, HERRAMIENTAS)})
|
|
56
|
+
result = run(fake, "rutinas", "crear", "Recoger herramientas", "--dia", "Jueves", "--dia", "lunes", "--dia", "miercoles", "--si")
|
|
57
|
+
assert result.exit_code == 0, result.output
|
|
58
|
+
assert fake.sent("POST", "people/routines/") == {
|
|
59
|
+
"text": "Recoger herramientas", "role_ids": [], "employee_ids": [], "weekdays": [0, 2, 3],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_crear_with_a_day_that_is_not_one_changes_nothing():
|
|
64
|
+
fake = FakeApi({})
|
|
65
|
+
result = run(fake, "rutinas", "crear", "Recoger herramientas", "--dia", "lunez", "--si")
|
|
66
|
+
assert result.exit_code == 1
|
|
67
|
+
assert "lunez" in result.output and "lunes, martes" in result.output
|
|
68
|
+
assert not fake.called("POST", "people/routines/")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_cambiar_replaces_the_days_or_goes_back_to_every_day():
|
|
72
|
+
fake = FakeApi({
|
|
73
|
+
("GET", "people/routines/"): [HERRAMIENTAS],
|
|
74
|
+
("PATCH", "people/routines/4/"): HERRAMIENTAS,
|
|
75
|
+
})
|
|
76
|
+
run(fake, "rutinas", "cambiar", "4", "--dia", "viernes", "--si")
|
|
77
|
+
assert fake.sent("PATCH", "people/routines/4/") == {"weekdays": [4]}
|
|
78
|
+
run(fake, "rutinas", "cambiar", "4", "--todos-los-dias", "--si")
|
|
79
|
+
assert fake.sent("PATCH", "people/routines/4/") == {"weekdays": []}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_cambiar_refuses_days_and_every_day_together():
|
|
83
|
+
fake = FakeApi({})
|
|
84
|
+
result = run(fake, "rutinas", "cambiar", "4", "--dia", "viernes", "--todos-los-dias", "--si")
|
|
85
|
+
assert result.exit_code == 1
|
|
86
|
+
assert "--todos-los-dias no va con --dia" in result.output
|
|
87
|
+
assert fake.requests == []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_crear_names_the_roles_and_people_and_sends_their_ids():
|
|
91
|
+
created = {**HERRAMIENTAS, "id": 5, "text": "Contar la bodega"}
|
|
92
|
+
fake = FakeApi({
|
|
93
|
+
("GET", "people/routines/roles/"): ROLES,
|
|
94
|
+
("GET", "employees/"): page(MARIA),
|
|
95
|
+
("POST", "people/routines/"): (201, created),
|
|
96
|
+
})
|
|
97
|
+
result = run(fake, "rutinas", "crear", "Contar la bodega", "--rol", "bodeguero", "--persona", "María Torres", "--si")
|
|
98
|
+
assert result.exit_code == 0, result.output
|
|
99
|
+
assert fake.sent("POST", "people/routines/") == {"text": "Contar la bodega", "role_ids": [8], "employee_ids": [7]}
|
|
100
|
+
assert "Rutina 5 creada" in result.output
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_crear_without_roles_or_people_is_for_everyone():
|
|
104
|
+
fake = FakeApi({("POST", "people/routines/"): (201, BITACORA)})
|
|
105
|
+
result = run(fake, "rutinas", "crear", "Llenar la bitácora", "--si")
|
|
106
|
+
assert result.exit_code == 0, result.output
|
|
107
|
+
assert fake.sent("POST", "people/routines/") == {"text": "Llenar la bitácora", "role_ids": [], "employee_ids": []}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_crear_with_a_role_nobody_has_says_which_there_are():
|
|
111
|
+
fake = FakeApi({("GET", "people/routines/roles/"): ROLES})
|
|
112
|
+
result = run(fake, "rutinas", "crear", "Contar la bodega", "--rol", "Gerente", "--si")
|
|
113
|
+
assert result.exit_code == 1
|
|
114
|
+
assert "Gerente" in result.output and "Bodeguero, Técnico" in result.output
|
|
115
|
+
assert not fake.called("POST", "people/routines/")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_crear_json_gives_the_record_with_its_id():
|
|
119
|
+
fake = FakeApi({("POST", "people/routines/"): (201, BITACORA)})
|
|
120
|
+
result = run(fake, "rutinas", "crear", "Llenar la bitácora", "--si", "--json")
|
|
121
|
+
assert json.loads(result.output)["id"] == 3
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_cambiar_finds_the_routine_by_its_text_and_replaces_the_roles():
|
|
125
|
+
changed = {**HERRAMIENTAS, "roles": [{"id": 9, "name": "Técnico"}]}
|
|
126
|
+
fake = FakeApi({
|
|
127
|
+
("GET", "people/routines/"): [BITACORA, HERRAMIENTAS],
|
|
128
|
+
("GET", "people/routines/roles/"): ROLES,
|
|
129
|
+
("PATCH", "people/routines/4/"): changed,
|
|
130
|
+
})
|
|
131
|
+
result = run(fake, "rutinas", "cambiar", "recoger herramientas", "--rol", "Técnico", "--si")
|
|
132
|
+
assert result.exit_code == 0, result.output
|
|
133
|
+
assert fake.sent("PATCH", "people/routines/4/") == {"role_ids": [9]}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_cambiar_para_todos_empties_both_lists():
|
|
137
|
+
fake = FakeApi({("GET", "people/routines/"): [HERRAMIENTAS], ("PATCH", "people/routines/4/"): HERRAMIENTAS})
|
|
138
|
+
result = run(fake, "rutinas", "cambiar", "4", "--para-todos", "--si")
|
|
139
|
+
assert result.exit_code == 0, result.output
|
|
140
|
+
assert fake.sent("PATCH", "people/routines/4/") == {"role_ids": [], "employee_ids": []}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_cambiar_needs_something_to_change():
|
|
144
|
+
fake = FakeApi({})
|
|
145
|
+
result = run(fake, "rutinas", "cambiar", "4", "--si")
|
|
146
|
+
assert result.exit_code == 1
|
|
147
|
+
assert "Di qué cambiar" in result.output
|
|
148
|
+
assert fake.requests == []
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_quitar_takes_the_routine_off_the_plans():
|
|
152
|
+
fake = FakeApi({("GET", "people/routines/"): [BITACORA], ("DELETE", "people/routines/3/"): (204, None)})
|
|
153
|
+
result = run(fake, "rutinas", "quitar", "Llenar la bitácora", "--si")
|
|
154
|
+
assert result.exit_code == 0, result.output
|
|
155
|
+
assert fake.called("DELETE", "people/routines/3/")
|
|
156
|
+
assert "quitada" in result.output
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_quitar_with_an_unknown_routine_changes_nothing():
|
|
160
|
+
fake = FakeApi({("GET", "people/routines/"): [BITACORA]})
|
|
161
|
+
result = run(fake, "rutinas", "quitar", "Otra", "--si")
|
|
162
|
+
assert result.exit_code == 1
|
|
163
|
+
assert not fake.called("DELETE", "people/routines/3/")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_cumplimiento_asks_for_the_month_and_shows_each_person():
|
|
167
|
+
fake = FakeApi({("GET", "people/routines/compliance/"): COMPLIANCE})
|
|
168
|
+
result = run(fake, "rutinas", "cumplimiento", "--mes", "10/2026")
|
|
169
|
+
assert result.exit_code == 0, result.output
|
|
170
|
+
assert fake.requests[0].url.params["month"] == "2026-10"
|
|
171
|
+
assert "María Torres" in result.output and "Técnico" in result.output
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_cumplimiento_json_is_the_whole_answer():
|
|
175
|
+
fake = FakeApi({("GET", "people/routines/compliance/"): COMPLIANCE})
|
|
176
|
+
result = run(fake, "rutinas", "cumplimiento", "--mes", "2026-10", "--json")
|
|
177
|
+
assert json.loads(result.output) == COMPLIANCE
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|