operx-cli 0.1.0__py3-none-any.whl
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/__init__.py +5 -0
- operx_cli/app.py +87 -0
- operx_cli/client.py +134 -0
- operx_cli/commands/__init__.py +1 -0
- operx_cli/commands/account.py +68 -0
- operx_cli/commands/common.py +116 -0
- operx_cli/commands/inventario.py +105 -0
- operx_cli/commands/lecturas.py +57 -0
- operx_cli/commands/ordenes.py +187 -0
- operx_cli/commands/personas.py +107 -0
- operx_cli/commands/raw.py +114 -0
- operx_cli/commands/registros.py +192 -0
- operx_cli/commands/search.py +21 -0
- operx_cli/lookup.py +42 -0
- operx_cli/output.py +102 -0
- operx_cli/settings.py +90 -0
- operx_cli/spreadsheet.py +179 -0
- operx_cli/values.py +59 -0
- operx_cli-0.1.0.dist-info/METADATA +136 -0
- operx_cli-0.1.0.dist-info/RECORD +23 -0
- operx_cli-0.1.0.dist-info/WHEEL +5 -0
- operx_cli-0.1.0.dist-info/entry_points.txt +2 -0
- operx_cli-0.1.0.dist-info/top_level.txt +1 -0
operx_cli/output.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""How answers are shown: a table for people, JSON for scripts, and CSV the
|
|
2
|
+
way OperX's own exports write it (backend/utils/spanish_csv.py), so it opens
|
|
3
|
+
right in a Spanish Excel.
|
|
4
|
+
|
|
5
|
+
Results go to stdout and notes about them to stderr, so
|
|
6
|
+
`operx equipos listar --csv > equipos.csv` keeps only the rows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
FORMATS = ("tabla", "json", "csv")
|
|
19
|
+
BOM = ""
|
|
20
|
+
# Excel runs a cell that starts with one of these as a formula.
|
|
21
|
+
FORMULA_STARTS = ("=", "+", "-", "@", "\t", "\r")
|
|
22
|
+
# The API sends money and quantities as text: "24.5000".
|
|
23
|
+
DECIMAL_TEXT = re.compile(r"^-?\d+\.\d+$")
|
|
24
|
+
# A table sent to a file or another program isn't cut to a screen's width.
|
|
25
|
+
WIDTH_OFF_SCREEN = 500
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cell_text(value):
|
|
29
|
+
if value is None:
|
|
30
|
+
return ""
|
|
31
|
+
if isinstance(value, bool):
|
|
32
|
+
return "Sí" if value else "No"
|
|
33
|
+
if isinstance(value, list):
|
|
34
|
+
return ", ".join(cell_text(item) for item in value)
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
for key in ("name", "title", "full_name", "label"):
|
|
37
|
+
if key in value:
|
|
38
|
+
return cell_text(value[key])
|
|
39
|
+
return json.dumps(value, ensure_ascii=False)
|
|
40
|
+
return str(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def excel_cell(value):
|
|
44
|
+
text = cell_text(value)
|
|
45
|
+
if DECIMAL_TEXT.match(text):
|
|
46
|
+
return text.rstrip("0").rstrip(".").replace(".", ",")
|
|
47
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
48
|
+
return text.replace(".", ",")
|
|
49
|
+
if text.startswith(FORMULA_STARTS):
|
|
50
|
+
return "'" + text
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def csv_text(columns, rows):
|
|
55
|
+
buffer = io.StringIO()
|
|
56
|
+
buffer.write(BOM)
|
|
57
|
+
writer = csv.writer(buffer, delimiter=";", lineterminator="\n")
|
|
58
|
+
writer.writerow([label for _key, label in columns])
|
|
59
|
+
for row in rows:
|
|
60
|
+
writer.writerow([excel_cell(row.get(key)) for key, _label in columns])
|
|
61
|
+
return buffer.getvalue()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def json_text(data):
|
|
65
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def print_rows(columns, rows, output_format):
|
|
69
|
+
"""A list: the chosen columns in a table or CSV, every field in JSON."""
|
|
70
|
+
if output_format == "json":
|
|
71
|
+
typer.echo(json_text(rows))
|
|
72
|
+
return
|
|
73
|
+
if output_format == "csv":
|
|
74
|
+
typer.echo(csv_text(columns, rows), nl=False)
|
|
75
|
+
return
|
|
76
|
+
if not rows:
|
|
77
|
+
typer.echo("No hay resultados.")
|
|
78
|
+
return
|
|
79
|
+
table = Table(show_lines=False)
|
|
80
|
+
for _key, label in columns:
|
|
81
|
+
table.add_column(label)
|
|
82
|
+
for row in rows:
|
|
83
|
+
table.add_row(*[cell_text(row.get(key)) for key, _label in columns])
|
|
84
|
+
console = Console()
|
|
85
|
+
if not console.is_terminal:
|
|
86
|
+
console = Console(width=WIDTH_OFF_SCREEN)
|
|
87
|
+
console.print(table)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def print_record(record, output_format):
|
|
91
|
+
"""One record: each field on its line, or the JSON as it came."""
|
|
92
|
+
if output_format != "tabla":
|
|
93
|
+
typer.echo(json_text(record))
|
|
94
|
+
return
|
|
95
|
+
width = max((len(key) for key in record), default=0)
|
|
96
|
+
for key, value in record.items():
|
|
97
|
+
typer.echo(f"{key.ljust(width)} {cell_text(value)}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def note(message):
|
|
101
|
+
"""A line for the person, never mixed into the results."""
|
|
102
|
+
typer.echo(message, err=True)
|
operx_cli/settings.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Where the CLI keeps each profile's address and token.
|
|
2
|
+
|
|
3
|
+
A profile ("principal" unless another is named) is one API and one token:
|
|
4
|
+
production, staging, or a second company. Its address goes in a small JSON
|
|
5
|
+
file in the user's settings folder; its token goes in the system's keychain,
|
|
6
|
+
never in a file. On a server without a keychain, OPERX_TOKEN and
|
|
7
|
+
OPERX_DIRECCION take their place, and they win over what's saved.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import keyring
|
|
15
|
+
import typer
|
|
16
|
+
from keyring.errors import KeyringError, PasswordDeleteError
|
|
17
|
+
|
|
18
|
+
DEFAULT_PROFILE = "principal"
|
|
19
|
+
DEFAULT_ADDRESS = "https://api.operx.pro"
|
|
20
|
+
KEYRING_SERVICE = "operx-cli"
|
|
21
|
+
|
|
22
|
+
NO_KEYCHAIN = (
|
|
23
|
+
"Este equipo no tiene un llavero del sistema donde guardar el token. "
|
|
24
|
+
"Ponlo en la variable de entorno OPERX_TOKEN."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SettingsError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def settings_file():
|
|
33
|
+
folder = os.environ.get("OPERX_CONFIG_DIR") or typer.get_app_dir("operx")
|
|
34
|
+
return Path(folder) / "config.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read():
|
|
38
|
+
path = settings_file()
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return {"perfiles": {}}
|
|
41
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write(content):
|
|
45
|
+
path = settings_file()
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
path.write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def profile_name(option):
|
|
51
|
+
"""The --perfil option, else OPERX_PERFIL, else "principal"."""
|
|
52
|
+
return option or os.environ.get("OPERX_PERFIL") or DEFAULT_PROFILE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def address_of(profile):
|
|
56
|
+
from_environment = os.environ.get("OPERX_DIRECCION")
|
|
57
|
+
if from_environment:
|
|
58
|
+
return from_environment.rstrip("/")
|
|
59
|
+
saved = _read()["perfiles"].get(profile, {}).get("direccion")
|
|
60
|
+
return saved or DEFAULT_ADDRESS
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def save_address(profile, address):
|
|
64
|
+
content = _read()
|
|
65
|
+
content["perfiles"].setdefault(profile, {})["direccion"] = address.rstrip("/")
|
|
66
|
+
_write(content)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def token_of(profile):
|
|
70
|
+
from_environment = os.environ.get("OPERX_TOKEN")
|
|
71
|
+
if from_environment:
|
|
72
|
+
return from_environment.strip()
|
|
73
|
+
try:
|
|
74
|
+
return keyring.get_password(KEYRING_SERVICE, profile)
|
|
75
|
+
except KeyringError:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def save_token(profile, token):
|
|
80
|
+
try:
|
|
81
|
+
keyring.set_password(KEYRING_SERVICE, profile, token)
|
|
82
|
+
except KeyringError as error:
|
|
83
|
+
raise SettingsError(NO_KEYCHAIN) from error
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def forget_token(profile):
|
|
87
|
+
try:
|
|
88
|
+
keyring.delete_password(KEYRING_SERVICE, profile)
|
|
89
|
+
except (PasswordDeleteError, KeyringError):
|
|
90
|
+
pass
|
operx_cli/spreadsheet.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Reading a company's own Excel or CSV for `operx personas importar`.
|
|
2
|
+
|
|
3
|
+
The API takes rows already mapped to Personas' fields (backend/people/
|
|
4
|
+
import_views.py), so this module finds each column by the names people give
|
|
5
|
+
it ("Nombres", "Fecha de ingreso", "Celular"), whatever their case or
|
|
6
|
+
accents, and writes each value the way the API reads it: dates as
|
|
7
|
+
YYYY-MM-DD (text dates are day first, as written in Latin America), contract
|
|
8
|
+
names as their keys, codes and phone numbers as text.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import io
|
|
13
|
+
import unicodedata
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from openpyxl import load_workbook
|
|
18
|
+
|
|
19
|
+
from .values import iso_date
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SpreadsheetError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Each field the import takes, and the column names that mean it (already
|
|
27
|
+
# normalized: lower case, no accents). "sede" is a place's name: the command
|
|
28
|
+
# finds its id (branch_id) in the API.
|
|
29
|
+
PEOPLE_COLUMNS = {
|
|
30
|
+
"first_name": ["nombre", "nombres", "primer nombre"],
|
|
31
|
+
"last_name": ["apellido", "apellidos"],
|
|
32
|
+
"employee_code": ["codigo", "codigo de empleado", "codigo empleado", "id empleado"],
|
|
33
|
+
"position": ["cargo", "puesto"],
|
|
34
|
+
"contract_type": ["tipo de contrato", "contrato", "tipo contrato"],
|
|
35
|
+
"contract_end_date": ["fin de contrato", "fecha fin de contrato", "fecha de terminacion", "vence contrato"],
|
|
36
|
+
"hire_date": ["fecha de ingreso", "ingreso", "fecha ingreso", "fecha de contratacion"],
|
|
37
|
+
"phone_work": ["telefono", "celular", "movil", "telefono de trabajo"],
|
|
38
|
+
"emergency_contact_name": ["contacto de emergencia", "nombre contacto de emergencia", "nombre contacto emergencia"],
|
|
39
|
+
"emergency_contact_phone": ["telefono de emergencia", "telefono contacto de emergencia", "telefono contacto emergencia"],
|
|
40
|
+
"sede": ["sede", "sucursal"],
|
|
41
|
+
"reports_to_id": [],
|
|
42
|
+
"branch_id": [],
|
|
43
|
+
}
|
|
44
|
+
DATE_FIELDS = ("hire_date", "contract_end_date")
|
|
45
|
+
CONTRACT_NAMES = {
|
|
46
|
+
"termino indefinido": "indefinite", "indefinido": "indefinite",
|
|
47
|
+
"termino fijo": "fixed", "fijo": "fixed",
|
|
48
|
+
"obra o labor": "project", "obra labor": "project", "obra": "project",
|
|
49
|
+
"aprendizaje": "apprenticeship",
|
|
50
|
+
"prestacion de servicios": "services", "servicios": "services",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class PeopleFile:
|
|
56
|
+
rows: list = field(default_factory=list) # each person, as the API takes them
|
|
57
|
+
lines: list = field(default_factory=list) # each row's line in the file
|
|
58
|
+
mapping: dict = field(default_factory=dict) # column → field
|
|
59
|
+
ignored: list = field(default_factory=list) # columns nobody maps
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalized(text):
|
|
63
|
+
"""'Fecha de Ingreso:' → 'fecha de ingreso'."""
|
|
64
|
+
without_accents = "".join(
|
|
65
|
+
char for char in unicodedata.normalize("NFKD", str(text)) if not unicodedata.combining(char)
|
|
66
|
+
)
|
|
67
|
+
kept = "".join(char if char.isalnum() or char.isspace() else " " for char in without_accents)
|
|
68
|
+
return " ".join(kept.split()).casefold()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_empty(value):
|
|
72
|
+
return value is None or (isinstance(value, str) and not value.strip())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _rows_from_xlsx(path, sheet_name):
|
|
76
|
+
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
77
|
+
if sheet_name and sheet_name not in workbook.sheetnames:
|
|
78
|
+
raise SpreadsheetError(f"El archivo no tiene la hoja «{sheet_name}». Tiene: {', '.join(workbook.sheetnames)}.")
|
|
79
|
+
sheet = workbook[sheet_name] if sheet_name else workbook.worksheets[0]
|
|
80
|
+
return [list(row) for row in sheet.iter_rows(values_only=True)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _rows_from_csv(path):
|
|
84
|
+
raw = Path(path).read_bytes()
|
|
85
|
+
try:
|
|
86
|
+
text = raw.decode("utf-8-sig")
|
|
87
|
+
except UnicodeDecodeError:
|
|
88
|
+
# What a Spanish Excel writes when it saves "CSV".
|
|
89
|
+
text = raw.decode("cp1252")
|
|
90
|
+
first_line = text.split("\n", 1)[0]
|
|
91
|
+
delimiter = ";" if first_line.count(";") >= first_line.count(",") else ","
|
|
92
|
+
return [list(row) for row in csv.reader(io.StringIO(text), delimiter=delimiter)]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def read_table(path, sheet_name=None):
|
|
96
|
+
"""The header (the first row with anything in it) and every other row
|
|
97
|
+
that isn't empty, each with its line number in the file."""
|
|
98
|
+
path = Path(path)
|
|
99
|
+
if not path.exists():
|
|
100
|
+
raise SpreadsheetError(f"No existe el archivo {path}.")
|
|
101
|
+
if path.suffix.lower() == ".csv":
|
|
102
|
+
raw_rows = _rows_from_csv(path)
|
|
103
|
+
elif path.suffix.lower() in (".xlsx", ".xlsm"):
|
|
104
|
+
raw_rows = _rows_from_xlsx(path, sheet_name)
|
|
105
|
+
else:
|
|
106
|
+
raise SpreadsheetError("El archivo debe ser .xlsx o .csv (en Excel: Guardar como › Libro de Excel).")
|
|
107
|
+
|
|
108
|
+
header = None
|
|
109
|
+
rows = []
|
|
110
|
+
for line, cells in enumerate(raw_rows, start=1):
|
|
111
|
+
if all(_is_empty(cell) for cell in cells):
|
|
112
|
+
continue
|
|
113
|
+
if header is None:
|
|
114
|
+
header = [str(cell).strip() if not _is_empty(cell) else "" for cell in cells]
|
|
115
|
+
continue
|
|
116
|
+
values = {name: cells[index] if index < len(cells) else None for index, name in enumerate(header) if name}
|
|
117
|
+
rows.append((line, values))
|
|
118
|
+
if header is None:
|
|
119
|
+
raise SpreadsheetError("El archivo está vacío.")
|
|
120
|
+
return [name for name in header if name], rows
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _column_mapping(header, overrides):
|
|
124
|
+
known = {}
|
|
125
|
+
for field_name, names in PEOPLE_COLUMNS.items():
|
|
126
|
+
for name in [field_name, *names]:
|
|
127
|
+
known[normalized(name)] = field_name
|
|
128
|
+
|
|
129
|
+
mapping = {}
|
|
130
|
+
for override in overrides or []:
|
|
131
|
+
column, _equals, field_name = override.partition("=")
|
|
132
|
+
column, field_name = column.strip(), field_name.strip()
|
|
133
|
+
if field_name not in PEOPLE_COLUMNS:
|
|
134
|
+
raise SpreadsheetError(
|
|
135
|
+
f"«{field_name}» no es un campo de la importación. Los campos son: {', '.join(PEOPLE_COLUMNS)}."
|
|
136
|
+
)
|
|
137
|
+
if column not in header:
|
|
138
|
+
raise SpreadsheetError(f"La columna «{column}» no está en el archivo.")
|
|
139
|
+
mapping[column] = field_name
|
|
140
|
+
|
|
141
|
+
for column in header:
|
|
142
|
+
field_name = known.get(normalized(column))
|
|
143
|
+
if column not in mapping and field_name and field_name not in mapping.values():
|
|
144
|
+
mapping[column] = field_name
|
|
145
|
+
return mapping
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _plain_text(value):
|
|
149
|
+
"""1001.0 → '1001': Excel keeps codes and phone numbers as numbers."""
|
|
150
|
+
if isinstance(value, float) and value.is_integer():
|
|
151
|
+
return str(int(value))
|
|
152
|
+
return str(value).strip()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _api_value(field_name, value):
|
|
156
|
+
if field_name in DATE_FIELDS:
|
|
157
|
+
return iso_date(value)
|
|
158
|
+
if field_name == "contract_type":
|
|
159
|
+
return CONTRACT_NAMES.get(normalized(value), _plain_text(value))
|
|
160
|
+
return _plain_text(value)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def people_rows(path, overrides=None, sheet_name=None):
|
|
164
|
+
header, rows = read_table(path, sheet_name)
|
|
165
|
+
mapping = _column_mapping(header, overrides)
|
|
166
|
+
if "first_name" not in mapping.values():
|
|
167
|
+
raise SpreadsheetError(
|
|
168
|
+
"No encontré la columna del nombre. Llámala «Nombres» o indícala con --mapa \"Tu columna=first_name\"."
|
|
169
|
+
)
|
|
170
|
+
result = PeopleFile(mapping=mapping, ignored=[column for column in header if column not in mapping])
|
|
171
|
+
for line, values in rows:
|
|
172
|
+
person = {}
|
|
173
|
+
for column, field_name in mapping.items():
|
|
174
|
+
value = values.get(column)
|
|
175
|
+
if not _is_empty(value):
|
|
176
|
+
person[field_name] = _api_value(field_name, value)
|
|
177
|
+
result.rows.append(person)
|
|
178
|
+
result.lines.append(line)
|
|
179
|
+
return result
|
operx_cli/values.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Numbers and dates the way people in Latin America write them, turned into
|
|
2
|
+
what the API reads: "73,4" → "73.4", "03/02/2026" → "2026-02-03" (the day
|
|
3
|
+
comes first)."""
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
7
|
+
|
|
8
|
+
DATE_FORMATS = ("%d/%m/%Y", "%d-%m-%Y", "%d.%m.%Y", "%Y-%m-%d")
|
|
9
|
+
DATETIME_FORMATS = ("%d/%m/%Y %H:%M", "%d-%m-%Y %H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def decimal_text(text):
|
|
13
|
+
"""'1.234,5' and '1,234.5' → '1234.5': the separator that comes last is
|
|
14
|
+
the decimal one."""
|
|
15
|
+
text = str(text).strip()
|
|
16
|
+
if "," in text and "." in text:
|
|
17
|
+
decimal_mark = "," if text.rfind(",") > text.rfind(".") else "."
|
|
18
|
+
thousands_mark = "." if decimal_mark == "," else ","
|
|
19
|
+
text = text.replace(thousands_mark, "").replace(decimal_mark, ".")
|
|
20
|
+
else:
|
|
21
|
+
text = text.replace(",", ".")
|
|
22
|
+
try:
|
|
23
|
+
Decimal(text)
|
|
24
|
+
except InvalidOperation:
|
|
25
|
+
raise ValueError(f"«{text}» no es un número.") from None
|
|
26
|
+
return text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def iso_date(value):
|
|
30
|
+
"""A date as YYYY-MM-DD. Text nobody can read stays as it is: the API
|
|
31
|
+
answers what's wrong with it."""
|
|
32
|
+
if isinstance(value, datetime):
|
|
33
|
+
return value.date().isoformat()
|
|
34
|
+
if isinstance(value, date):
|
|
35
|
+
return value.isoformat()
|
|
36
|
+
text = str(value).strip()
|
|
37
|
+
for date_format in DATE_FORMATS:
|
|
38
|
+
try:
|
|
39
|
+
return datetime.strptime(text, date_format).date().isoformat()
|
|
40
|
+
except ValueError:
|
|
41
|
+
continue
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def iso_datetime(text):
|
|
46
|
+
"""A moment with this computer's time zone: '03/02/2026 14:05' →
|
|
47
|
+
'2026-02-03T14:05:00-05:00'."""
|
|
48
|
+
text = str(text).strip()
|
|
49
|
+
for datetime_format in DATETIME_FORMATS:
|
|
50
|
+
try:
|
|
51
|
+
moment = datetime.strptime(text, datetime_format)
|
|
52
|
+
except ValueError:
|
|
53
|
+
continue
|
|
54
|
+
return moment.astimezone().isoformat(timespec="seconds")
|
|
55
|
+
raise ValueError(f"«{text}» no es una fecha con hora. Escríbela así: 03/02/2026 14:05.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def now_iso():
|
|
59
|
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: operx-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: typer>=0.27
|
|
8
|
+
Requires-Dist: httpx>=0.28
|
|
9
|
+
Requires-Dist: keyring>=25
|
|
10
|
+
Requires-Dist: openpyxl>=3.1
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
13
|
+
|
|
14
|
+
# operx: OperX desde la terminal
|
|
15
|
+
|
|
16
|
+
`operx` usa la API de OperX con tu token personal: lo mismo que puedes hacer
|
|
17
|
+
en la app, desde una terminal, un script o una tarea programada. Actúa como
|
|
18
|
+
tú, con tu rol, y solo en tu empresa.
|
|
19
|
+
|
|
20
|
+
## Instalar
|
|
21
|
+
|
|
22
|
+
Necesitas Python 3.10 o más nuevo.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pipx install ./cli # desde esta carpeta del repositorio
|
|
26
|
+
operx --version
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Entrar
|
|
30
|
+
|
|
31
|
+
1. En la app, abre el menú de tu cuenta › **Acceso por API** y crea un token.
|
|
32
|
+
Elige *Lectura y escritura* si vas a crear o cambiar datos.
|
|
33
|
+
2. Guárdalo en tu equipo:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
operx entrar # pega el token; no se muestra
|
|
37
|
+
operx cuenta # quién eres, tu empresa y tu rol
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
El token queda en el llavero del sistema (en Windows, el Administrador de
|
|
41
|
+
credenciales), nunca en un archivo. En un servidor sin llavero, usa variables
|
|
42
|
+
de entorno: `OPERX_TOKEN` (el token) y, si no es producción, `OPERX_DIRECCION`.
|
|
43
|
+
|
|
44
|
+
Para otra dirección u otra empresa, usa un perfil:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
operx --perfil pruebas entrar --direccion https://…
|
|
48
|
+
operx --perfil pruebas ordenes listar
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`operx salir` borra el token de este equipo; para que deje de funcionar,
|
|
52
|
+
revócalo en Acceso por API.
|
|
53
|
+
|
|
54
|
+
## Comandos
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
operx buscar "bomba" # como la barra de búsqueda de la app
|
|
58
|
+
|
|
59
|
+
operx ordenes listar --estado en-progreso --asignada-a yo
|
|
60
|
+
operx ordenes ver 1234
|
|
61
|
+
operx ordenes crear --titulo "Cambio de rodamiento" --equipo BOM-04 --prioridad alta --vence 30/10/2026
|
|
62
|
+
operx ordenes estado 1234 completada
|
|
63
|
+
operx ordenes asignar 1234 "Pedro Pérez"
|
|
64
|
+
operx ordenes comentar 1234 "Llegó el repuesto"
|
|
65
|
+
|
|
66
|
+
operx equipos listar --buscar compresor
|
|
67
|
+
operx equipos ver BOM-04
|
|
68
|
+
operx equipos crear --nombre "Bomba 4" --codigo BOM-04 --lugar "Planta Norte"
|
|
69
|
+
operx clientes listar
|
|
70
|
+
operx clientes crear --nombre "Acme S.A.S." --nit 900123456
|
|
71
|
+
operx clientes crear --nombre "Filtros Ltda" --rol proveedor
|
|
72
|
+
operx lugares listar
|
|
73
|
+
|
|
74
|
+
operx inventario existencias --buscar filtro
|
|
75
|
+
operx inventario entrada --articulo FIL-010 --lugar "Bodega central" --cantidad 10 --costo-unitario 1.500,50
|
|
76
|
+
operx inventario salida --articulo FIL-010 --lugar "Bodega central" --cantidad 2 --motivo Consumo
|
|
77
|
+
|
|
78
|
+
operx lecturas puntos BOM-04
|
|
79
|
+
operx lecturas registrar --punto 12 --valor 73,4
|
|
80
|
+
|
|
81
|
+
operx personas importar empleados.xlsx # revisa el archivo, no guarda
|
|
82
|
+
operx personas importar empleados.xlsx --confirmar # guarda si ninguna fila tiene errores
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`operx --help` y `operx ordenes --help` muestran todas las opciones.
|
|
86
|
+
|
|
87
|
+
- Los equipos, lugares, clientes, artículos y personas se nombran por su
|
|
88
|
+
código o su nombre exacto. Si dos se llaman igual, `operx` lo dice y pide
|
|
89
|
+
el id.
|
|
90
|
+
- Los números y las fechas se escriben como en la app: `73,4`, `1.500,50`,
|
|
91
|
+
`30/10/2026`, `22/10/2026 08:00`.
|
|
92
|
+
- Las listas muestran la primera página; `--todas` las trae todas.
|
|
93
|
+
- `--json` da todo lo que responde la API; `--csv` da un CSV que Excel abre
|
|
94
|
+
bien (`operx equipos listar --todas --csv > equipos.csv`).
|
|
95
|
+
|
|
96
|
+
### Antes de escribir
|
|
97
|
+
|
|
98
|
+
Todo lo que crea o cambia algo pregunta antes. En un script o una tarea
|
|
99
|
+
programada no hay a quién preguntar: agrega `--si`. Cada escritura queda en
|
|
100
|
+
el historial del token (Acceso por API › el token › Actividad) marcada como
|
|
101
|
+
hecha desde la CLI.
|
|
102
|
+
|
|
103
|
+
### Importar personas
|
|
104
|
+
|
|
105
|
+
La primera columna con el nombre basta. `operx` reconoce las columnas
|
|
106
|
+
Nombres, Apellidos, Código, Cargo, Tipo de contrato, Fecha de ingreso, Fin de
|
|
107
|
+
contrato, Celular, Sede y las de contacto de emergencia, con o sin tildes.
|
|
108
|
+
Una columna con otro nombre se indica con `--mapa "Tu columna=campo"`.
|
|
109
|
+
Nadie recibe acceso a la app al importarlo: las invitaciones se envían en
|
|
110
|
+
Usuarios y acceso.
|
|
111
|
+
|
|
112
|
+
### Cualquier otra llamada
|
|
113
|
+
|
|
114
|
+
Lo que no tiene comando propio se hace con `operx api`, con las direcciones
|
|
115
|
+
de Acceso por API › Documentación (sin `/api/v1/`):
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
operx api GET pm/schedules/ -q search=compresor
|
|
119
|
+
operx api POST contacts/ -d @contacto.json
|
|
120
|
+
operx api GET work-orders/export/ -o ordenes.csv
|
|
121
|
+
operx api POST work-orders/1234/attachments/ -a file=foto.jpg -d '{"file_type": "photo"}'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
En Git Bash, una dirección que empieza por `/` se vuelve una ruta de Windows:
|
|
125
|
+
escríbela sin la barra inicial, como arriba.
|
|
126
|
+
|
|
127
|
+
## Para desarrollar
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
cd cli
|
|
131
|
+
python -m venv .venv && .venv/Scripts/python -m pip install -e ".[test]"
|
|
132
|
+
.venv/Scripts/python -m pytest
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`operx_cli/client.py` es lo único que habla con la red; el servidor MCP lo
|
|
136
|
+
usará igual.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
operx_cli/__init__.py,sha256=uQFILki-hzkuCkdJzw-PgqIdv0Vagxr-W6YD3nHIhOg,185
|
|
2
|
+
operx_cli/app.py,sha256=fo6BlcQ6LQUGlAxy-e8PSMXNGjwc2rh7CQkDCl1XnwA,2788
|
|
3
|
+
operx_cli/client.py,sha256=eVtKKyMk_Hski3M7py343RQFc0kcYq3D5wsrpaBszn4,5064
|
|
4
|
+
operx_cli/lookup.py,sha256=oU1wh6_UnllhiQNXMYk4Xx6FJ12KAsTRBcjXNM83eiY,1541
|
|
5
|
+
operx_cli/output.py,sha256=LHzqmxWQB9MtTWrxXsFIEnMgsPvLewnZWoyBRRXfxyU,3191
|
|
6
|
+
operx_cli/settings.py,sha256=o-eWeZLINiXcjo2ww9FrXF7LWMmDzF6EzPLG2Pkp7fY,2527
|
|
7
|
+
operx_cli/spreadsheet.py,sha256=KKk1qvJE7QFck1JHK79z-2EpXgelefNoA4MrYD1eBp8,7275
|
|
8
|
+
operx_cli/values.py,sha256=IKGm-xGver_pU179i4Q_VIhaId3lTeU2Rcb2LC0btwE,2099
|
|
9
|
+
operx_cli/commands/__init__.py,sha256=TyBLGCZdfsGPg8l5U_4DKgaGPJxWE5TepeOXMQ7IL4Q,82
|
|
10
|
+
operx_cli/commands/account.py,sha256=jxkl95CqMkmtwA8gH1Nh8hJuSkM3adHiBArnI9uV9Cw,2729
|
|
11
|
+
operx_cli/commands/common.py,sha256=6KP2ffN9_sxsHRk6tddojr5FJNgwPQ6A7UgJ1CwFogQ,3703
|
|
12
|
+
operx_cli/commands/inventario.py,sha256=DmVqEQTQ-CRljGlQm28YK103Ct17SMF7sbuonpCY6WA,4277
|
|
13
|
+
operx_cli/commands/lecturas.py,sha256=DjTk11MPgIGwgY6df6CpuHRhwIYkED1VGeKP8-qSVy8,2440
|
|
14
|
+
operx_cli/commands/ordenes.py,sha256=7JXOVYkcEMmM8luPhZHmQrZk7bthilfZrPlYZmEYcMc,7847
|
|
15
|
+
operx_cli/commands/personas.py,sha256=ZNzx6lve33h4OSimwbHzUFe6KO0a0STRI9UReDo2pi0,4507
|
|
16
|
+
operx_cli/commands/raw.py,sha256=Rt2peqxCqgZ8b7EHo4lepXFPR_g9U8C5cI5hBzLmi4w,4528
|
|
17
|
+
operx_cli/commands/registros.py,sha256=j8YM7eC3DDyNgtKebgDQf7b1vfsOzIir0HEwmIjpf0k,7907
|
|
18
|
+
operx_cli/commands/search.py,sha256=YSugPxWUC7TuNnUbP2ccGmJE0nd4Oiw3SANMLA9p1no,741
|
|
19
|
+
operx_cli-0.1.0.dist-info/METADATA,sha256=ZYDIiPIBfI_M2786UUpqpmP_oWvxXlV5Nj6-Hxh5jxU,4925
|
|
20
|
+
operx_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
21
|
+
operx_cli-0.1.0.dist-info/entry_points.txt,sha256=eP1Jr75H5WYGYRByAYb_vi-seM_bnyWm046g3LBlqjs,45
|
|
22
|
+
operx_cli-0.1.0.dist-info/top_level.txt,sha256=n-uXOP0Havuey-ATo4rkU-FQxPDqYXhysCIbgDc-7Tg,10
|
|
23
|
+
operx_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
operx_cli
|