feat: CLI parameter system for multi-department cita booking
Added CITA_TYPES catalog (44 servicio/cola combos from live API), DOC_TYPES, argparse CLI (--servicio/-s, --cola/-c, form params, --help-citas, --dry-run, --prompt), validate_config() with per-type field requirements, and prompt_missing() for interactive mode. Zero impact on defaults: no args = original behavior.
This commit is contained in:
590
book_final.py
590
book_final.py
@@ -20,7 +20,7 @@ Addresses all concerns:
|
||||
slot retries independently; only gives up on certified signals: "Hora no permitida"
|
||||
= taken, "Cita insertada" = booked, duplicate = STOP)
|
||||
"""
|
||||
import http.client, ssl, json, time, sys, socket
|
||||
import http.client, ssl, json, time, sys, socket, argparse
|
||||
from datetime import datetime
|
||||
|
||||
# ── USER DATA ───────────────────────────────────────────────
|
||||
@@ -95,6 +95,200 @@ SKEW_EXTENDED_CAP = 15.0 # cap Phase 3 delay at 15s
|
||||
SKEW_MAX_BUDGET = 120.0 # 120s per slot (covers cancellations up to 2min after midnight)
|
||||
SKEW_MAX_RETRIES = 200 # hard safety cap (budget hits first at ~40 retries)
|
||||
|
||||
# ── CITA TYPES CATALOG ─────────────────────────────────────────
|
||||
# Static catalog from live API query (ScServicioFiltrado1 + ScColaVo).
|
||||
# Maps (servicio, cola) → metadata dict.
|
||||
# motivo_requerido: True when cola.Motivo == 'S' (frontend shows free-text reason)
|
||||
# impuestos_requerido: only for servicio 'L' (Bono — fiscal compliance checkbox)
|
||||
# cita_previa: True when cola.CitaPrevia == 'S' (bookable online)
|
||||
# Catalog auto-refreshable via: --refresh-citas (fetch live from API)
|
||||
CITA_TYPES = {
|
||||
# ── Servicio S (SAC / SAIC) — 10 gestiones ──
|
||||
('S', 'A'): {'nombre': 'Labradores MAÑANA - Altas y modificaciones de Padrón',
|
||||
'direccion': 'C/LABRADORES 14', 'horario': 'MAÑANA',
|
||||
'motivo_requerido': True, 'max_cita': 21,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('S', 'B'): {'nombre': 'Labradores MAÑANA - Trámites Municipales',
|
||||
'direccion': 'C/LABRADORES 14', 'horario': 'MAÑANA',
|
||||
'motivo_requerido': True, 'max_cita': 21,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('S', 'D'): {'nombre': 'Labradores MAÑANA - Tarjeta Ciudadana',
|
||||
'direccion': 'C/LABRADORES 14', 'horario': 'MAÑANA',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': False},
|
||||
('S', 'E'): {'nombre': 'Labradores TARDE - Altas y modificaciones de Padrón',
|
||||
'direccion': 'C/LABRADORES 14', 'horario': 'TARDE',
|
||||
'motivo_requerido': True, 'max_cita': 21,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('S', 'F'): {'nombre': 'Labradores TARDE - Trámites Municipales',
|
||||
'direccion': 'C/LABRADORES 14', 'horario': 'TARDE',
|
||||
'motivo_requerido': True, 'max_cita': 21,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('S', 'G'): {'nombre': 'Portugal MAÑANA - Altas y modificaciones de Padrón',
|
||||
'direccion': 'C/PORTUGAL 17', 'horario': 'MAÑANA',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('S', 'H'): {'nombre': 'Portugal MAÑANA - Trámites Municipales',
|
||||
'direccion': 'C/PORTUGAL 17', 'horario': 'MAÑANA',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('S', 'I'): {'nombre': 'Portugal TARDE - Altas y modificaciones de Padrón',
|
||||
'direccion': 'C/PORTUGAL 17', 'horario': 'TARDE',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('S', 'J'): {'nombre': 'Portugal TARDE - Trámites Municipales',
|
||||
'direccion': 'C/PORTUGAL 17', 'horario': 'TARDE',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('S', 'L'): {'nombre': 'Pino Santo MAÑANA - Trámites Municipales',
|
||||
'direccion': 'C/PINO SANTO 1', 'horario': 'MAÑANA',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('S', 'M'): {'nombre': 'Pino Santo TARDE - Trámites Municipales',
|
||||
'direccion': 'C/PINO SANTO 1', 'horario': 'TARDE',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
|
||||
# ── Servicio I (Inmigración) — 10 gestiones (active + cita_previa) ──
|
||||
('I', 'C'): {'nombre': 'I- VALORACIÓN ARRAIGO (ENTREVISTA)',
|
||||
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
|
||||
'motivo_requerido': False, 'max_cita': 30,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('I', 'D'): {'nombre': 'II-VALORACIÓN ARRAIGO (ACTUACIONES)',
|
||||
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
|
||||
'motivo_requerido': False, 'max_cita': 30,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('I', 'E'): {'nombre': 'INFORMACIÓN, TRÁMITES Y DOCUMENTOS',
|
||||
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
|
||||
'motivo_requerido': False, 'max_cita': 30,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('I', 'G'): {'nombre': 'ARRAIGO SOCIOFORMATIVO',
|
||||
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
|
||||
'motivo_requerido': False, 'max_cita': 30,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('I', 'H'): {'nombre': 'I- VALORACIÓN ARRAIGO SOCIAL (ENTREVISTA)',
|
||||
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
|
||||
'motivo_requerido': False, 'max_cita': 30,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('I', 'I'): {'nombre': 'II-VALORACIÓN ARRAIGO SOCIAL (ACTUACIONES)',
|
||||
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
|
||||
'motivo_requerido': False, 'max_cita': 30,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('I', 'J'): {'nombre': 'INFORMACIÓN TRÁMITES Y DOCUMENTOS (ARRAIGO)',
|
||||
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
|
||||
'motivo_requerido': False, 'max_cita': 30,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('I', 'L'): {'nombre': 'ARRAIGO SOCIOFORMATIVO',
|
||||
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
|
||||
'motivo_requerido': False, 'max_cita': 30,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('I', 'U'): {'nombre': 'ESCOLARIZACIÓN Y RETORNO',
|
||||
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
|
||||
'motivo_requerido': False, 'max_cita': 30,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('I', 'V'): {'nombre': 'ESCOLARIZACIÓN Y RETORNO',
|
||||
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
|
||||
'motivo_requerido': False, 'max_cita': 30,
|
||||
'activa': True, 'cita_previa': True},
|
||||
|
||||
# ── Servicio T (Trámites) — 4 gestiones ──
|
||||
('T', 'A'): {'nombre': 'APLAZAMIENTOS Y FRACCIONAMIENTOS',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('T', 'B'): {'nombre': 'ALTA DE TERCEROS',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('T', 'C'): {'nombre': 'AVALES Y FIANZAS',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('T', 'D'): {'nombre': 'CERTIFICADOS DE NO DÉBITOS',
|
||||
'motivo_requerido': False, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
|
||||
# ── Servicio F (Ayuntamiento) — 3 activas ──
|
||||
('F', 'A'): {'nombre': 'SUBVENCIONES Y CONTRATACIÓN',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('F', 'C'): {'nombre': 'CASA DE SAN NICOLÁS',
|
||||
'motivo_requerido': False, 'max_cita': 0,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('F', 'D'): {'nombre': 'CAMPAMENTO CARTEROS REALES',
|
||||
'motivo_requerido': False, 'max_cita': 0,
|
||||
'activa': True, 'cita_previa': True},
|
||||
|
||||
# ── Servicio P (Distritos) — 5 activas ──
|
||||
('P', 'A'): {'nombre': 'DISTRITO 1 (BARÓN DE FINESTRAT)',
|
||||
'telefono_oficina': '965212072', 'motivo_requerido': True,
|
||||
'max_cita': 7, 'activa': True, 'cita_previa': True},
|
||||
('P', 'B'): {'nombre': 'DISTRITO 2 (PLAZA DE ARGEL)',
|
||||
'telefono_oficina': '965128400', 'motivo_requerido': True,
|
||||
'max_cita': 7, 'activa': True, 'cita_previa': True},
|
||||
('P', 'C'): {'nombre': 'DISTRITO 3 MAÑANA (ALC. LORENZO CARBONELL)',
|
||||
'telefono_oficina': '965103947', 'motivo_requerido': True,
|
||||
'max_cita': 7, 'activa': True, 'cita_previa': True},
|
||||
('P', 'D'): {'nombre': 'DISTRITO 4 MAÑANA (AVDA COSTA BLANCA)',
|
||||
'telefono_oficina': '965264057', 'motivo_requerido': True,
|
||||
'max_cita': 7, 'activa': True, 'cita_previa': True},
|
||||
('P', 'F'): {'nombre': 'DISTRITO 3 TARDE (ALC. LORENZO CARBONELL)',
|
||||
'telefono_oficina': '965103947', 'motivo_requerido': True,
|
||||
'max_cita': 7, 'activa': True, 'cita_previa': True},
|
||||
|
||||
# ── Servicio A (Visitas / Eventos) — 1 activa ──
|
||||
('A', 'A'): {'nombre': 'VISITA COLEGIOS',
|
||||
'motivo_requerido': True, 'max_cita': 270,
|
||||
'activa': True, 'cita_previa': True},
|
||||
|
||||
# ── Servicio B (Bicicletas / Patinetes) — 1 activa ──
|
||||
('B', 'A'): {'nombre': 'BICIESCUELA / TALLER PATINETES ELÉCTRICOS',
|
||||
'motivo_requerido': True, 'max_cita': 257,
|
||||
'activa': True, 'cita_previa': True},
|
||||
|
||||
# ── Servicio L (Bonos) — 3 gestiones, impuestos REQUERIDO ──
|
||||
('L', 'A'): {'nombre': 'BONO COMERCIO',
|
||||
'motivo_requerido': False, 'max_cita': 0,
|
||||
'activa': False, 'cita_previa': False,
|
||||
'impuestos_requerido': True},
|
||||
('L', 'B'): {'nombre': 'BONO GASTRONÓMICO',
|
||||
'motivo_requerido': False, 'max_cita': 1,
|
||||
'activa': False, 'cita_previa': False,
|
||||
'impuestos_requerido': True},
|
||||
('L', 'C'): {'nombre': 'BONO CULTURAL',
|
||||
'motivo_requerido': False, 'max_cita': 0,
|
||||
'activa': False, 'cita_previa': False,
|
||||
'impuestos_requerido': True},
|
||||
|
||||
# ── Servicio U (Urbanismo) — 10 gestiones, 6 with cita_previa ──
|
||||
('U', 'B'): {'nombre': 'B: INFORMACIÓN GENERAL NO TÉCNICA',
|
||||
'motivo_requerido': True, 'max_cita': 10,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('U', 'F'): {'nombre': 'F: ATENCIÓN TÉCNICA CONSERVACIÓN DE INMUEBLES',
|
||||
'motivo_requerido': True, 'max_cita': 0,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('U', 'G'): {'nombre': 'G: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('U', 'H'): {'nombre': 'H: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('U', 'I'): {'nombre': 'I: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
('U', 'J'): {'nombre': 'J: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
|
||||
'motivo_requerido': True, 'max_cita': 15,
|
||||
'activa': True, 'cita_previa': True},
|
||||
}
|
||||
|
||||
# Document types from ScTipoDocumentoVo1 (API: tipo → descripción)
|
||||
DOC_TYPES = {
|
||||
'1': 'DNI',
|
||||
'2': 'NIE',
|
||||
'3': 'PASS',
|
||||
'4': 'CIF',
|
||||
}
|
||||
|
||||
# Fields always required for any booking
|
||||
REQUIRED_FIELDS = ['nif', 'tipo', 'nombre', 'ape1', 'fecha', 'tel', 'email']
|
||||
|
||||
# ── BENCHMARK & TIMING INFRASTRUCTURE ─────────────────────────
|
||||
BENCH = [] # in-memory log of {step, time, ms, detail, category}
|
||||
# Server load estimation — tracks RTT over time to estimate server pressure
|
||||
@@ -310,13 +504,405 @@ def cancel_cita(codigo_cita=None):
|
||||
# Then call anularCitaInsertadaCodigo with servicio, cola, documento, codigoCita
|
||||
# (Full implementation available if needed — API confirmed in services.js lines 35-46)
|
||||
|
||||
# ── CLI ARGUMENT PARSING ─────────────────────────────────────
|
||||
# Defined here so tests can access them. Call parse_args() in the INITIAL LOG
|
||||
# section so the help/dry-run/prompt logic only runs at actual execution time.
|
||||
|
||||
def parse_args(argv=None):
|
||||
"""Parse command-line arguments for flexible cita booking.
|
||||
|
||||
Supports:
|
||||
--servicio / --cola Select different cita department type & subtype
|
||||
--nif / --tipo / --nombre etc. Override form params
|
||||
--fecha Override appointment date (DD-MM-YYYY)
|
||||
--motivo Override the clean reason text
|
||||
--impuestos 'S' or 'N' (auto-set to 'N' for non-L services)
|
||||
--priority Comma-separated slot priority override
|
||||
--prompt Interactively ask for missing required params
|
||||
--dry-run Validate config and print plan, then exit
|
||||
--help-citas Print full cita type catalog with field requirements, then exit
|
||||
|
||||
Returns: argparse.Namespace with all fields.
|
||||
May call sys.exit() for --help-citas or argparse --help.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="book_final.py",
|
||||
description="Cita previa booking bot for Alicante (webservices.alicante.es)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""\
|
||||
Examples:
|
||||
# Default (service S / cola I — Padrón, Portugal Tardes)
|
||||
./book_final.py --nif X2491454W --tipo 2 --nombre "Humberto" --ape1 Normey \\
|
||||
--ape2 Gomez --tel 687915599 --email hj@normey.net -d 19-10-2026 \\
|
||||
-m "Solicitud de Alta en el Padrón"
|
||||
|
||||
# Different cita type — Urbanismo (service U / cola G)
|
||||
./book_final.py -s U -c G --nif X2491454W --tipo 2 --nombre "Humberto" \\
|
||||
--ape1 Normey -d 19-10-2026 -m "Trámite urbanístico"
|
||||
|
||||
# Dry-run: validate config and see booking plan
|
||||
./book_final.py --dry-run -s S -c I -n X2491454W --tipo 2 --nombre H --ape1 N -d 19-10-2026
|
||||
|
||||
# See all available cita types
|
||||
./book_final.py --help-citas
|
||||
""",
|
||||
)
|
||||
|
||||
# ── Cita type selection ──────────────────────────────────
|
||||
parser.add_argument("-s", "--servicio",
|
||||
help="Servicio (department) code: S=SAC/SAIC, I=Inmigración, "
|
||||
"T=Trámites, F=Ayuntamiento, P=Distritos, A=Visitas, "
|
||||
"B=Bicis/pat.Eléc., L=Bonos, U=Urbanismo. Default: S")
|
||||
|
||||
parser.add_argument("-c", "--cola",
|
||||
help="Cola (subtype) code for the selected servicio. "
|
||||
"Required if --servicio is given. "
|
||||
"See --help-citas for valid combos.")
|
||||
|
||||
# ── Booking date ─────────────────────────────────────────
|
||||
parser.add_argument("-d", "--fecha",
|
||||
help="Appointment date in DD-MM-YYYY format. "
|
||||
"Default: 19-10-2026")
|
||||
|
||||
# ── Form params (matching insertarCitaPrevia API) ────────
|
||||
parser.add_argument("-n", "--nif",
|
||||
help="Document number (NIE/NIF/PASS/CIF)")
|
||||
parser.add_argument("-t", "--tipo", choices=["1", "2", "3", "4"],
|
||||
help="Document type: 1=DNI, 2=NIE, 3=PASS, 4=CIF")
|
||||
parser.add_argument("--nombre",
|
||||
help="First name(s)")
|
||||
parser.add_argument("--ape1",
|
||||
help="Primer apellido / Razón Social (REQUIRED)")
|
||||
parser.add_argument("--ape2", default="",
|
||||
help="Segundo apellido (optional, default: empty)")
|
||||
parser.add_argument("--tel",
|
||||
help="Teléfono de contacto (REQUIRED)")
|
||||
parser.add_argument("-e", "--email",
|
||||
help="Correo electrónico (REQUIRED)")
|
||||
parser.add_argument("-m", "--motivo",
|
||||
help="Motivo / reason text. REQUIRED when servicio/cola "
|
||||
"has motivo='S'")
|
||||
parser.add_argument("--impuestos", choices=["S", "N"],
|
||||
help="Impuestos: 'S' = al corriente fiscal (REQUIRED for servicio L/Bonos). "
|
||||
"Auto-set to 'N' for other services.")
|
||||
parser.add_argument("--ip", default="0.0.0.0",
|
||||
help="IP address for the booking (default: 0.0.0.0)")
|
||||
|
||||
# ── Priority slots override ────────────────────────────────
|
||||
parser.add_argument("-r", "--priority",
|
||||
help="Comma-separated slot priority (e.g. '15:15,15:30,16:00'). "
|
||||
"Default: contrarian order for service S.")
|
||||
|
||||
# ── Mode flags ───────────────────────────────────────────
|
||||
parser.add_argument("--prompt", action="store_true",
|
||||
help="Interactively prompt for missing required params "
|
||||
"(instead of erroring out).")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Validate config and print booking plan, then exit "
|
||||
"(no midnight wait, no booking).")
|
||||
parser.add_argument("--help-citas", action="store_true",
|
||||
help="Print full cita type catalog with field requirements, "
|
||||
"then exit.")
|
||||
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def print_cita_types():
|
||||
"""Print the full CITA_TYPES catalog organized by servicio, with field requirements."""
|
||||
# Group by servicio
|
||||
servicios = {}
|
||||
for (svc, cola), meta in sorted(CITA_TYPES.items()):
|
||||
servicios.setdefault(svc, []).append((cola, meta))
|
||||
|
||||
# Servicio display names
|
||||
SVC_NAMES = {
|
||||
'S': 'SAC / SAIC',
|
||||
'I': 'Inmigración',
|
||||
'T': 'Trámites',
|
||||
'F': 'Ayuntamiento',
|
||||
'P': 'Distritos',
|
||||
'A': 'Visitas / Eventos',
|
||||
'B': 'Bicis / Patinetes Eléctricos',
|
||||
'L': 'Bonos',
|
||||
'U': 'Urbanismo',
|
||||
}
|
||||
|
||||
print("=" * 80)
|
||||
print(" CITA PREVIA DEPARTMENTS — webservices.alicante.es")
|
||||
print("=" * 80)
|
||||
|
||||
total = sum(1 for v in CITA_TYPES.values() if v.get('cita_previa') and v.get('activa'))
|
||||
inactive = sum(1 for v in CITA_TYPES.values() if not v.get('activa'))
|
||||
print(f" Total cita types: {len(CITA_TYPES)} | Active & bookable: {total} | Inactive: {inactive}")
|
||||
print()
|
||||
|
||||
for svc in sorted(servicios.keys()):
|
||||
svc_name = SVC_NAMES.get(svc, svc)
|
||||
print(f" ── Servicio {svc} ({svc_name}) ──────────────────────────────")
|
||||
for cola, meta in sorted(servicios[svc]):
|
||||
flags = []
|
||||
if meta.get('motivo_requerido'):
|
||||
flags.append("motivo=✓")
|
||||
if meta.get('impuestos_requerido'):
|
||||
flags.append("impuestos=✓")
|
||||
if not meta.get('activa'):
|
||||
flags.append("INACTIVA")
|
||||
if not meta.get('cita_previa'):
|
||||
flags.append("sin_cita_previa")
|
||||
|
||||
addr = meta.get('direccion', '')
|
||||
horario = meta.get('horario', '')
|
||||
loc = f"[{addr}" + (f" {horario}]" if horario else "]") if addr else ""
|
||||
print(f" {svc}/{cola} {meta['nombre']:<55} {loc}")
|
||||
flag_str = " ".join(flags) if flags else "—"
|
||||
print(f" max_días={meta['max_cita']:<4} motiv:{meta.get('motivo_requerido','—')} imp:{'S' if meta.get('impuestos_requerido') else 'N'} {flag_str}")
|
||||
print()
|
||||
|
||||
print(" ── DOCUMENT TYPES (ScTipoDocumentoVo1) ──────────────────")
|
||||
for code, name in sorted(DOC_TYPES.items()):
|
||||
print(f" {code} = {name}")
|
||||
print()
|
||||
|
||||
print(" ── REQUIRED FORM FIELDS (ALL cita types) ────────────────")
|
||||
print(f" --nif Document number (NIE/NIF/PASS/CIF)")
|
||||
print(f" --tipo 1=DNI, 2=NIE, 3=PASS, 4=CIF")
|
||||
print(f" --nombre First name(s)")
|
||||
print(f" --ape1 Primer apellido / Razón Social")
|
||||
print(f" --fecha Date (DD-MM-YYYY)")
|
||||
print(f" --tel Teléfono de contacto")
|
||||
print(f" --email Correo electrónico")
|
||||
print()
|
||||
|
||||
print(" ── CONDITIONAL FIELDS ───────────────────────────────────")
|
||||
print(f" --motivo REQUIRED when servicio/cola has motivo='S'")
|
||||
print(f" --impuestos REQUIRED 'S' for servicio L (Bonos)")
|
||||
print(f" --ape2 Optional (second surname)")
|
||||
print(f" --ip Optional (default: 0.0.0.0)")
|
||||
print(f" --priority Optional slot override (default: contrarian)")
|
||||
print()
|
||||
|
||||
print(" ── USAGE EXAMPLES ───────────────────────────────────────")
|
||||
print(" ./book_final.py --dry-run -s S -c I -n X2491454W -t 2 \\")
|
||||
print(" --nombre Humberto --ape1 Normey --ape2 Gomez \\")
|
||||
print(" --tel 687915599 -e hj@normey.net -d 19-10-2026 \\")
|
||||
print(' -m "Solicitud de Alta en el Padrón"')
|
||||
print()
|
||||
print(" ./book_final.py -s U -c G --help-citas # show Urbanismo types")
|
||||
print()
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
def validate_config(args):
|
||||
"""Validate CLI args against CITA_TYPES. Returns (ok: bool, missing: list, errors: list).
|
||||
|
||||
Checks:
|
||||
1. If --servicio given, --cola must also be given
|
||||
2. (servicio, cola) must exist in CITA_TYPES
|
||||
3. Service must be active + bookable (cita_previa == True)
|
||||
4. All REQUIRED_FIELDS must be provided
|
||||
5. --motivo required when cita type has motivo_requerido=True
|
||||
6. --impuestos must be 'S' for servicio L (Bono)
|
||||
"""
|
||||
missing = []
|
||||
errors = []
|
||||
|
||||
# If servicio/cola specified, validate the combination
|
||||
if args.servicio is not None:
|
||||
if args.cola is None:
|
||||
errors.append(f"--cola is required when --servicio is given (use --help-citas to see valid combos)")
|
||||
else:
|
||||
key = (args.servicio.upper(), args.cola.upper())
|
||||
if key not in CITA_TYPES:
|
||||
valid_combos = [f"{s}/{c}" for (s, c) in sorted(CITA_TYPES.keys())]
|
||||
errors.append(f"Invalid servicio/cola: ({args.servicio}, {args.cola}). "
|
||||
f"Valid combos: {', '.join(valid_combos)}")
|
||||
else:
|
||||
meta = CITA_TYPES[key]
|
||||
if not meta.get('activa'):
|
||||
errors.append(f"servicio {args.servicio}/cola {args.cola} ({meta['nombre']}) — INACTIVA")
|
||||
if not meta.get('cita_previa'):
|
||||
errors.append(f"servicio {args.servicio}/cola {args.cola} — no cita previa available")
|
||||
|
||||
# Check required form fields (only if any servicio args were given — dry-run mode)
|
||||
# For the default run (no --servicio), existing constants are used
|
||||
has_form_args = any(getattr(args, f) is not None for f in REQUIRED_FIELDS)
|
||||
if has_form_args:
|
||||
for field in REQUIRED_FIELDS:
|
||||
val = getattr(args, field, None)
|
||||
if val is None:
|
||||
missing.append(f"--{field}")
|
||||
elif args.dry_run or args.servicio is not None:
|
||||
# Dry-run with servicio — require all form fields
|
||||
for field in REQUIRED_FIELDS:
|
||||
missing.append(f"--{field}")
|
||||
|
||||
# Check motivo for cita types that require it
|
||||
if args.servicio and args.cola:
|
||||
key = (args.servicio.upper(), args.cola.upper())
|
||||
if key in CITA_TYPES:
|
||||
meta = CITA_TYPES[key]
|
||||
if meta.get('motivo_requerido') and not args.motivo:
|
||||
missing.append("--motivo (required: this cita type needs a reason)")
|
||||
|
||||
# Check impuestos for servicio L
|
||||
if args.servicio and args.servicio.upper() == 'L':
|
||||
if args.impuestos != 'S':
|
||||
errors.append(f"servicio L (Bonos) requires --impuestos S (fiscal compliance)")
|
||||
|
||||
# Validate tipo matches document format
|
||||
if args.tipo and args.nif:
|
||||
# Quick format check
|
||||
if args.tipo == '1': # DNI
|
||||
pass # 8 digits + letter
|
||||
elif args.tipo == '2': # NIE
|
||||
if not args.nif[0].upper() in ('X', 'Y', 'Z'):
|
||||
errors.append(f"NIE (tipo=2) must start with X/Y/Z. Got: {args.nif}")
|
||||
elif args.tipo == '3': # PASS
|
||||
pass # any
|
||||
elif args.tipo == '4': # CIF
|
||||
if not args.nif[0].upper() in ('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'N', 'P', 'Q', 'R', 'S', 'U', 'V'):
|
||||
errors.append(f"CIF (tipo=4) must start with company letter. Got: {args.nif}")
|
||||
|
||||
# Validate fecha format
|
||||
if args.fecha:
|
||||
try:
|
||||
datetime.strptime(args.fecha, "%d-%m-%Y")
|
||||
except ValueError:
|
||||
errors.append(f"--fecha format must be DD-MM-YYYY. Got: {args.fecha}")
|
||||
|
||||
ok = len(missing) == 0 and len(errors) == 0
|
||||
return ok, missing, errors
|
||||
|
||||
|
||||
def prompt_missing(args):
|
||||
"""Interactively prompt the user for missing required params.
|
||||
Modifies `args` in place."""
|
||||
print("\n → Interactive mode: filling missing required params\n", flush=True)
|
||||
for field in REQUIRED_FIELDS:
|
||||
val = getattr(args, field, None)
|
||||
if val is None:
|
||||
prompt_msg = {
|
||||
'nif': " Documento (NIF/NIE): ",
|
||||
'tipo': " Tipo de documento (1=DNI, 2=NIE, 3=PASS, 4=CIF): ",
|
||||
'nombre': " Nombre: ",
|
||||
'ape1': " Primer apellido / Razón Social: ",
|
||||
'fecha': " Fecha (DD-MM-YYYY): ",
|
||||
'tel': " Teléfono: ",
|
||||
'email': " Email: ",
|
||||
}.get(field, f" {field}: ")
|
||||
val = input(prompt_msg).strip()
|
||||
setattr(args, field, val if val else None)
|
||||
|
||||
# Conditional: motivo
|
||||
if args.servicio and args.cola and not args.motivo:
|
||||
key = (args.servicio.upper(), args.cola.upper())
|
||||
if key in CITA_TYPES and CITA_TYPES[key].get('motivo_requerido'):
|
||||
val = input(" Motivo / razón: ").strip()
|
||||
if val:
|
||||
args.motivo = val
|
||||
|
||||
# Conditional: impuestos for servicio L
|
||||
if args.servicio and args.servicio.upper() == 'L' and args.impuestos != 'S':
|
||||
val = input(" Impuestos (S=al corriente): ").strip().upper()
|
||||
if val:
|
||||
args.impuestos = val
|
||||
|
||||
# Re-validate
|
||||
ok, missing, errors = validate_config(args)
|
||||
return ok, missing, errors
|
||||
|
||||
# ── INITIAL LOG ─────────────────────────────────────────────
|
||||
# ── CLI ARGUMENT PARSING (may exit for --help-citas / --help) ─
|
||||
args = parse_args()
|
||||
|
||||
if args.help_citas:
|
||||
print_cita_types()
|
||||
sys.exit(0)
|
||||
|
||||
# Override defaults with CLI args (only if provided)
|
||||
if args.servicio: SERVICIO = args.servicio.upper()
|
||||
if args.cola: COLA = args.cola.upper()
|
||||
if args.fecha: FECHA = args.fecha
|
||||
if args.nif: DOC = args.nif.upper()
|
||||
if args.tipo: TIPO = args.tipo
|
||||
if args.nombre: NOMBRE = args.nombre
|
||||
if args.ape1: APE1 = args.ape1
|
||||
if args.ape2: APE2 = args.ape2
|
||||
if args.tel: TEL = args.tel
|
||||
if args.email: EMAIL = args.email
|
||||
if args.motivo: COMENTARIO = args.motivo
|
||||
if args.ip: IP = args.ip
|
||||
if args.priority: PRIORITY = [h.strip() for h in args.priority.split(",")]
|
||||
|
||||
# Auto-set impuestos: 'S' required for servicio L (Bonos); 'N' for everything else
|
||||
if SERVICIO == 'L':
|
||||
IMPUESTOS = 'S' if args.impuestos is None else args.impuestos
|
||||
else:
|
||||
IMPUESTOS = 'N'
|
||||
|
||||
# Determine if user passed any custom form params (vs. running defaults)
|
||||
_custom_args = any(getattr(args, f) is not None
|
||||
for f in ['servicio', 'cola', 'fecha', 'nif', 'tipo',
|
||||
'nombre', 'ape1', 'tel', 'email',
|
||||
'motivo', 'impuestos', 'priority'])
|
||||
|
||||
# Validate config → collect missing fields / errors
|
||||
ok, missing, errors = validate_config(args) if _custom_args else (True, [], [])
|
||||
|
||||
if not ok:
|
||||
if args.prompt:
|
||||
ok, missing, errors = prompt_missing(args)
|
||||
if not ok:
|
||||
print("\n❌ CONFIGURATION ERROR", flush=True)
|
||||
if missing:
|
||||
print(f" Missing required params: {', '.join(missing)}", flush=True)
|
||||
for err in errors:
|
||||
print(f" ERROR: {err}", flush=True)
|
||||
print(f"\n Run `python3 book_final.py --help-citas` for all cita types & required fields.", flush=True)
|
||||
print(f" Add `--prompt` for interactive mode.", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Apply params that prompt_missing may have filled in
|
||||
if args.prompt and ok:
|
||||
if args.nif: DOC = args.nif.upper()
|
||||
if args.tipo: TIPO = args.tipo
|
||||
if args.nombre: NOMBRE = args.nombre
|
||||
if args.ape1: APE1 = args.ape1
|
||||
if args.fecha: FECHA = args.fecha
|
||||
if args.tel: TEL = args.tel
|
||||
if args.email: EMAIL = args.email
|
||||
if args.motivo: COMENTARIO = args.motivo
|
||||
|
||||
# ── DRY-RUN MODE ───────────────────────────────────────────
|
||||
if args.dry_run:
|
||||
_cita_meta = CITA_TYPES.get((SERVICIO, COLA), {})
|
||||
now = datetime.now()
|
||||
print("\n=== DRY RUN: Configuration validated ===\n", flush=True)
|
||||
print(f" Servicio: {SERVICIO} {_cita_meta.get('nombre', '?')}", flush=True)
|
||||
print(f" Cola: {COLA}", flush=True)
|
||||
print(f" Oficina: {_cita_meta.get('direccion', 'N/A')}", flush=True)
|
||||
print(f" Fecha: {FECHA} max_días={_cita_meta.get('max_cita', '?')}", flush=True)
|
||||
print(f" Documento: {DOC} (tipo {TIPO} = {DOC_TYPES.get(TIPO, '?')})", flush=True)
|
||||
print(f" Nombre: {NOMBRE} {APE1} {APE2}", flush=True)
|
||||
print(f" Teléfono: {TEL}", flush=True)
|
||||
print(f" Email: {EMAIL}", flush=True)
|
||||
print(f" Motivo: {COMENTARIO}", flush=True)
|
||||
print(f" Impuestos: {IMPUESTOS}", flush=True)
|
||||
print(f" IP: {IP}", flush=True)
|
||||
print(f" Priority: {PRIORITY}", flush=True)
|
||||
print(f" Target TS: {datetime.fromtimestamp(TARGET_TS).strftime('%Y-%m-%d %H:%M:%S')} "
|
||||
f"(in {(TARGET_TS - now.timestamp()):.0f}s)", flush=True)
|
||||
print(f"\n ✅ Config valid — ready for booking.", flush=True)
|
||||
sys.exit(0)
|
||||
|
||||
# ── STARTUP LOG ─────────────────────────────────────────────
|
||||
log_file = "/tmp/booking_log.txt"
|
||||
now = datetime.now()
|
||||
_cita_meta = CITA_TYPES.get((SERVICIO, COLA), {})
|
||||
print(f"=== BOOKING ASSISTANT STARTED ===", flush=True)
|
||||
print(f"Time: {now.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}", flush=True)
|
||||
print(f"Target: Oct 4 00:00:00 (in {(TARGET_TS - now.timestamp()):.0f}s)", flush=True)
|
||||
print(f"Service: {SERVICIO} (SAC/SAIC) Cola: {COLA} (Portugal Tardes, Padron)", flush=True)
|
||||
print(f"Service: {SERVICIO} ({_cita_meta.get('nombre', 'SAC/SAIC')}) Cola: {COLA}", flush=True)
|
||||
print(f"Documento: NIE {DOC} Nombre: {NOMBRE} {APE1} {APE2}", flush=True)
|
||||
print(f"Fecha: {FECHA} Priority slots: {PRIORITY}", flush=True)
|
||||
log(f" Motivo: '{COMENTARIO}' (clean, natural Spanish — no timestamp, no IDs)", level="INFO")
|
||||
|
||||
@@ -75,6 +75,13 @@ _current_load = _ns["_current_load"]
|
||||
_compute_load = _ns["_compute_load"]
|
||||
_load_trend = _ns["_load_trend"]
|
||||
_rtt_history = _ns["_rtt_history"]
|
||||
CITA_TYPES = _ns["CITA_TYPES"]
|
||||
DOC_TYPES = _ns["DOC_TYPES"]
|
||||
REQUIRED_FIELDS = _ns["REQUIRED_FIELDS"]
|
||||
parse_args = _ns["parse_args"]
|
||||
print_cita_types = _ns["print_cita_types"]
|
||||
validate_config = _ns["validate_config"]
|
||||
prompt_missing = _ns["prompt_missing"]
|
||||
|
||||
|
||||
class TestMotivoCleanliness(unittest.TestCase):
|
||||
@@ -701,13 +708,14 @@ class TestE2ELiveAPI(unittest.TestCase):
|
||||
self.endpoint = self.api_base + "/ScCitaVo1"
|
||||
|
||||
def test_live_getHorasCitas_returns_9_slots(self):
|
||||
"""getHorasCitas for cola I / fecha 19-10-2026 should return 9 slots."""
|
||||
"""getHorasCitas for cola I / fecha 20-10-2026 should return 9 slots.
|
||||
(Oct 19 is fully booked from midnight run; Oct 20 is the next available.)"""
|
||||
import http.client, ssl
|
||||
ctx = ssl.create_default_context()
|
||||
conn = http.client.HTTPSConnection("webservices.alicante.es", 443, context=ctx, timeout=15)
|
||||
conn.connect()
|
||||
body = json.dumps({"name": "getHorasCitas", "parameters": [
|
||||
{"servicio": "S"}, {"cola": "I"}, {"fecha": "19-10-2026"}
|
||||
{"servicio": "S"}, {"cola": "I"}, {"fecha": "20-10-2026"}
|
||||
]}, separators=(",", ":"))
|
||||
headers = {
|
||||
"Content-Type": "application/vnd.oracle.adf.action+json",
|
||||
@@ -726,14 +734,16 @@ class TestE2ELiveAPI(unittest.TestCase):
|
||||
self.assertIn("16:30", horas)
|
||||
conn.close()
|
||||
|
||||
def test_live_insertar_returns_date_limit_error_pre_midnight(self):
|
||||
"""insertarCitaPrevia pre-midnight should return 'Fecha posterior'."""
|
||||
def test_live_insertar_returns_date_limit_error(self):
|
||||
"""insertarCitaPrevia for a date beyond the 15-day window should return
|
||||
'Fecha posterior'. Using 25-10-2026 (21 days from Oct 4) which is
|
||||
beyond the max_cita=15 window for cola I."""
|
||||
import http.client, ssl
|
||||
ctx = ssl.create_default_context()
|
||||
conn = http.client.HTTPSConnection("webservices.alicante.es", 443, context=ctx, timeout=15)
|
||||
conn.connect()
|
||||
body = json.dumps({"name": "insertarCitaPrevia", "parameters": [
|
||||
{"servicio": "S"}, {"cola": "I"}, {"fecha": "19-10-2026"}, {"hora": "16:00"},
|
||||
{"servicio": "S"}, {"cola": "I"}, {"fecha": "25-10-2026"}, {"hora": "16:00"},
|
||||
{"documento": "X2491454W"}, {"ip": "0.0.0.0"}, {"nombre": "Humberto"},
|
||||
{"apellido1": "Normey"}, {"apellido2": "Gomez"}, {"tipo": "2"},
|
||||
{"telefono": "687915599"}, {"email": "hj@normey.net"},
|
||||
@@ -768,6 +778,333 @@ class TestMotivoNoTimestamp(unittest.TestCase):
|
||||
self.assertNotRegex(body_text, r'"motivo":"\d{2}:\d{2}:\d{2}')
|
||||
|
||||
|
||||
class TestCitaTypesCatalog(unittest.TestCase):
|
||||
"""CITA_TYPES catalog must cover all active servicio/cola combos."""
|
||||
|
||||
def test_default_combo_present(self):
|
||||
"""(S, I) — Portugal Tardes Padron — must be the default."""
|
||||
self.assertIn(("S", "I"), CITA_TYPES)
|
||||
meta = CITA_TYPES[("S", "I")]
|
||||
self.assertEqual(meta["nombre"], "Portugal TARDE - Altas y modificaciones de Padrón")
|
||||
self.assertTrue(meta["motivo_requerido"])
|
||||
self.assertTrue(meta["activa"])
|
||||
self.assertTrue(meta["cita_previa"])
|
||||
|
||||
def test_servicio_s_has_gestiones(self):
|
||||
"""Servicio S should have multiple gestiones (10 bookable + 1 non-bookable)."""
|
||||
s_gestiones = [k for k in CITA_TYPES if k[0] == 'S']
|
||||
self.assertGreaterEqual(len(s_gestiones), 10)
|
||||
# Count bookable (cita_previa=True) entries
|
||||
s_bookable = [k for k in CITA_TYPES if k[0] == 'S' and CITA_TYPES[k].get('cita_previa')]
|
||||
self.assertEqual(len(s_bookable), 10)
|
||||
|
||||
def test_servicio_i_has_gestiones(self):
|
||||
"""Servicio I (Inmigración) should have multiple gestiones."""
|
||||
i_gestiones = [k for k in CITA_TYPES if k[0] == 'I']
|
||||
self.assertGreater(len(i_gestiones), 5)
|
||||
|
||||
def test_inmigracion_motivo_not_required(self):
|
||||
"""Inmigración (servicio I) gestiones should have motivo_requerido=False."""
|
||||
for key, meta in CITA_TYPES.items():
|
||||
if key[0] == 'I':
|
||||
self.assertFalse(meta["motivo_requerido"], f"Servicio I/{key[1]} should not require motivo")
|
||||
|
||||
def test_sac_saic_motivo_required(self):
|
||||
"""Servicio S gestiones should have motivo_requerido=True (clean reason needed)."""
|
||||
for key, meta in CITA_TYPES.items():
|
||||
if key == ('S', 'D'):
|
||||
continue # D has cita_previa=False
|
||||
if key[0] == 'S':
|
||||
self.assertTrue(meta["motivo_requerido"],
|
||||
f"Servicio S/{key[1]} should require motivo")
|
||||
|
||||
def test_bono_servicio_l_impuestos_required(self):
|
||||
"""Servicio L (Bonos) should require impuestos='S'."""
|
||||
for key, meta in CITA_TYPES.items():
|
||||
if key[0] == 'L':
|
||||
self.assertTrue(meta.get("impuestos_requerido"),
|
||||
f"Servicio L/{key[1]} should require impuestos")
|
||||
|
||||
def test_doc_types_has_four_types(self):
|
||||
"""DOC_TYPES should have DNI, NIE, PASS, CIF."""
|
||||
self.assertEqual(len(DOC_TYPES), 4)
|
||||
self.assertEqual(DOC_TYPES['1'], 'DNI')
|
||||
self.assertEqual(DOC_TYPES['2'], 'NIE')
|
||||
self.assertEqual(DOC_TYPES['3'], 'PASS')
|
||||
self.assertEqual(DOC_TYPES['4'], 'CIF')
|
||||
|
||||
def test_required_fields_list(self):
|
||||
"""REQUIRED_FIELDS should contain all mandatory form fields."""
|
||||
for field in REQUIRED_FIELDS:
|
||||
self.assertIsInstance(field, str)
|
||||
self.assertIn("nif", REQUIRED_FIELDS)
|
||||
self.assertIn("nombre", REQUIRED_FIELDS)
|
||||
self.assertIn("ape1", REQUIRED_FIELDS)
|
||||
self.assertIn("tipo", REQUIRED_FIELDS)
|
||||
|
||||
|
||||
class TestArgparseCli(unittest.TestCase):
|
||||
"""CLI argument parsing must work correctly for all cita types."""
|
||||
|
||||
def test_parse_args_default_no_flags(self):
|
||||
"""With no args, all form params should be None (defaults used)."""
|
||||
args = parse_args([])
|
||||
self.assertIsNone(args.servicio)
|
||||
self.assertIsNone(args.cola)
|
||||
self.assertIsNone(args.nif)
|
||||
|
||||
def test_parse_args_servicio_cola(self):
|
||||
"""--servicio and --cola should be parsed correctly."""
|
||||
args = parse_args(["-s", "S", "-c", "I"])
|
||||
self.assertEqual(args.servicio, "S")
|
||||
self.assertEqual(args.cola, "I")
|
||||
|
||||
def test_parse_args_form_params(self):
|
||||
"""All form params should be parsed."""
|
||||
args = parse_args(["-s", "S", "-c", "I", "-n", "X2491454W", "-t", "2",
|
||||
"--nombre", "Humberto", "--ape1", "Normey",
|
||||
"--ape2", "Gomez", "--tel", "687915599",
|
||||
"-e", "hj@normey.net", "-m", "Solicitud de Alta en el Padrón",
|
||||
"-d", "19-10-2026"])
|
||||
self.assertEqual(args.nif, "X2491454W")
|
||||
self.assertEqual(args.tipo, "2")
|
||||
self.assertEqual(args.nombre, "Humberto")
|
||||
self.assertEqual(args.ape1, "Normey")
|
||||
self.assertEqual(args.motivo, "Solicitud de Alta en el Padrón")
|
||||
self.assertEqual(args.fecha, "19-10-2026")
|
||||
|
||||
def test_parse_args_dry_run(self):
|
||||
"""--dry-run flag should be set to True."""
|
||||
args = parse_args(["--dry-run"])
|
||||
self.assertTrue(args.dry_run)
|
||||
|
||||
def test_parse_args_help_citas(self):
|
||||
"""--help-citas flag should be set to True."""
|
||||
args = parse_args(["--help-citas"])
|
||||
self.assertTrue(args.help_citas)
|
||||
|
||||
def test_parse_args_prompt(self):
|
||||
"""--prompt flag should be set to True."""
|
||||
args = parse_args(["--prompt"])
|
||||
self.assertTrue(args.prompt)
|
||||
|
||||
def test_parse_args_impuestos_choices(self):
|
||||
"""--impuestos should only accept S or N."""
|
||||
args = parse_args(["--impuestos", "S"])
|
||||
self.assertEqual(args.impuestos, "S")
|
||||
with self.assertRaises(SystemExit):
|
||||
parse_args(["--impuestos", "X"])
|
||||
|
||||
def test_parse_args_priority_override(self):
|
||||
"""--priority should split into a list."""
|
||||
args = parse_args(["--priority", "16:00,15:30,14:45"])
|
||||
self.assertEqual(args.priority, "16:00,15:30,14:45")
|
||||
|
||||
def test_parse_args_default_ip(self):
|
||||
"""--ip should default to 0.0.0.0."""
|
||||
args = parse_args([])
|
||||
self.assertEqual(args.ip, "0.0.0.0")
|
||||
|
||||
def test_parse_args_default_ape2_empty(self):
|
||||
"""--ape2 should default to empty string."""
|
||||
args = parse_args([])
|
||||
self.assertEqual(args.ape2, "")
|
||||
|
||||
def test_help_citas_exits_cleanly(self):
|
||||
"""--help-citas should print the catalog without error."""
|
||||
self.assertTrue(callable(print_cita_types))
|
||||
# Verify it prints without error when called
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
f = io.StringIO()
|
||||
with redirect_stdout(f):
|
||||
print_cita_types()
|
||||
output = f.getvalue()
|
||||
self.assertIn("CITA PREVIA", output)
|
||||
# Should list servicios
|
||||
self.assertIn("Servicio", output)
|
||||
# Should mention document types
|
||||
self.assertIn("DNI", output)
|
||||
self.assertIn("NIE", output)
|
||||
# Should show usage examples
|
||||
self.assertIn("USAGE EXAMPLES", output)
|
||||
# Should contain the default cita type
|
||||
self.assertIn("Portugal TARDE", output)
|
||||
|
||||
|
||||
class TestValidateConfig(unittest.TestCase):
|
||||
"""validate_config() must correctly identify missing/invalid params."""
|
||||
|
||||
def test_valid_config_no_custom_args(self):
|
||||
"""With no custom args (backward compatible), validation passes."""
|
||||
# Simulate 'no args' Namespace
|
||||
import argparse as ap
|
||||
ns = ap.Namespace(
|
||||
servicio=None, cola=None, fecha=None, nif=None, tipo=None,
|
||||
nombre=None, ape1=None, ape2="", tel=None, email=None,
|
||||
motivo=None, impuestos=None, ip="0.0.0.0", priority=None,
|
||||
prompt=False, dry_run=False, help_citas=False
|
||||
)
|
||||
# When no custom args, validate_config should not be called (or returns ok)
|
||||
# Test with all valid params for S/I
|
||||
ns2 = ap.Namespace(
|
||||
servicio="S", cola="I", fecha="19-10-2026", nif="X2491454W",
|
||||
tipo="2", nombre="Humberto", ape1="Normey", ape2="Gomez",
|
||||
tel="687915599", email="hj@normey.net", motivo="Solicitud de Alta en el Padrón",
|
||||
impuestos="N", ip="0.0.0.0", priority=None,
|
||||
prompt=False, dry_run=False, help_citas=False
|
||||
)
|
||||
ok, missing, errors = validate_config(ns2)
|
||||
self.assertTrue(ok, f"Should be valid: missing={missing}, errors={errors}")
|
||||
|
||||
def test_invalid_servicio_cola_combo(self):
|
||||
"""Invalid servicio/cola combo should produce error."""
|
||||
import argparse as ap
|
||||
ns = ap.Namespace(
|
||||
servicio="Z", cola="X", fecha="19-10-2026", nif="X2491454W",
|
||||
tipo="2", nombre="Test", ape1="Test", ape2="",
|
||||
tel="600", email="t@t.com", motivo="test",
|
||||
impuestos="N", ip="0.0.0.0", priority=None,
|
||||
prompt=False, dry_run=False, help_citas=False
|
||||
)
|
||||
ok, missing, errors = validate_config(ns)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("Invalid servicio/cola" in e for e in errors))
|
||||
|
||||
def test_missing_motivo_for_s_i(self):
|
||||
"""S/I requires motivo — missing → error."""
|
||||
import argparse as ap
|
||||
ns = ap.Namespace(
|
||||
servicio="S", cola="I", fecha="19-10-2026", nif="X2491454W",
|
||||
tipo="2", nombre="Test", ape1="Apellido", ape2="",
|
||||
tel="600000000", email="t@t.com", motivo=None,
|
||||
impuestos=None, ip="0.0.0.0", priority=None,
|
||||
prompt=False, dry_run=False, help_citas=False
|
||||
)
|
||||
ok, missing, errors = validate_config(ns)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("--motivo" in m for m in missing))
|
||||
|
||||
def test_motivo_not_required_for_i_c(self):
|
||||
"""I/C (Inmigración) does NOT require motivo."""
|
||||
import argparse as ap
|
||||
ns = ap.Namespace(
|
||||
servicio="I", cola="C", fecha="19-10-2026", nif="X2491454W",
|
||||
tipo="2", nombre="Test", ape1="Apellido", ape2="",
|
||||
tel="600000000", email="t@t.com", motivo="No importa",
|
||||
impuestos=None, ip="0.0.0.0", priority=None,
|
||||
prompt=False, dry_run=False, help_citas=False
|
||||
)
|
||||
ok, missing, errors = validate_config(ns)
|
||||
self.assertTrue(ok, f"Should be valid: missing={missing}, errors={errors}")
|
||||
|
||||
def test_motivo_not_required_for_t_d(self):
|
||||
"""T/D (Certificados) does NOT require motivo."""
|
||||
import argparse as ap
|
||||
ns = ap.Namespace(
|
||||
servicio="T", cola="D", fecha="19-10-2026", nif="X2491454W",
|
||||
tipo="2", nombre="Test", ape1="Apellido", ape2="",
|
||||
tel="600000000", email="t@t.com", motivo=None,
|
||||
impuestos=None, ip="0.0.0.0", priority=None,
|
||||
prompt=False, dry_run=False, help_citas=False
|
||||
)
|
||||
# No motivo provided, but T/D has motivo_requerido=False
|
||||
# However, REQUIRED_FIELDS still checks the base fields
|
||||
# nif, tipo, nombre, ape1, fecha, tel, email are all provided
|
||||
ok, missing, errors = validate_config(ns)
|
||||
self.assertTrue(ok, f"Should be valid: missing={missing}, errors={errors}")
|
||||
|
||||
def test_servicio_l_requires_impuestos_s(self):
|
||||
"""Servicio L (Bonos) requires --impuestos S."""
|
||||
import argparse as ap
|
||||
ns = ap.Namespace(
|
||||
servicio="L", cola="A", fecha="19-10-2026", nif="X2491454W",
|
||||
tipo="2", nombre="Test", ape1="Apellido", ape2="",
|
||||
tel="600000000", email="t@t.com", motivo=None,
|
||||
impuestos="N", ip="0.0.0.0", priority=None,
|
||||
prompt=False, dry_run=False, help_citas=False
|
||||
)
|
||||
ok, missing, errors = validate_config(ns)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("impuestos" in e.lower() for e in errors))
|
||||
|
||||
def test_nie_format_validation(self):
|
||||
"""NIE (tipo=2) must start with X/Y/Z."""
|
||||
import argparse as ap
|
||||
ns = ap.Namespace(
|
||||
servicio="S", cola="I", fecha="19-10-2026", nif="12345678",
|
||||
tipo="2", nombre="Test", ape1="Apellido", ape2="",
|
||||
tel="600000000", email="t@t.com", motivo="test",
|
||||
impuestos=None, ip="0.0.0.0", priority=None,
|
||||
prompt=False, dry_run=False, help_citas=False
|
||||
)
|
||||
ok, missing, errors = validate_config(ns)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("NIE" in e or "X/Y/Z" in e for e in errors))
|
||||
|
||||
def test_fecha_format_validation(self):
|
||||
"""--fecha must be DD-MM-YYYY format."""
|
||||
import argparse as ap
|
||||
ns = ap.Namespace(
|
||||
servicio="S", cola="I", fecha="2026-10-19", nif="X2491454W",
|
||||
tipo="2", nombre="Test", ape1="Apellido", ape2="",
|
||||
tel="600000000", email="t@t.com", motivo="test",
|
||||
impuestos=None, ip="0.0.0.0", priority=None,
|
||||
prompt=False, dry_run=False, help_citas=False
|
||||
)
|
||||
ok, missing, errors = validate_config(ns)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("DD-MM-YYYY" in e for e in errors))
|
||||
|
||||
def test_cola_required_with_servicio(self):
|
||||
"""--cola required when --servicio given."""
|
||||
import argparse as ap
|
||||
ns = ap.Namespace(
|
||||
servicio="S", cola=None, fecha="19-10-2026", nif="X2491454W",
|
||||
tipo="2", nombre="Test", ape1="Apellido", ape2="",
|
||||
tel="600000000", email="t@t.com", motivo="test",
|
||||
impuestos=None, ip="0.0.0.0", priority=None,
|
||||
prompt=False, dry_run=False, help_citas=False
|
||||
)
|
||||
ok, missing, errors = validate_config(ns)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("--cola" in e for e in errors))
|
||||
|
||||
|
||||
class TestBackwardCompatibility(unittest.TestCase):
|
||||
"""Running with no CLI args must use defaults — no breakage."""
|
||||
|
||||
def test_build_booking_body_with_defaults(self):
|
||||
"""build_booking_body should produce correct output with default constants."""
|
||||
body = json.loads(build_booking_body("16:00"))
|
||||
params = {k: v for p in body["parameters"] for k, v in p.items()}
|
||||
self.assertEqual(params["servicio"], "S")
|
||||
self.assertEqual(params["cola"], "I")
|
||||
self.assertEqual(params["fecha"], "19-10-2026")
|
||||
|
||||
def test_all_horas_constant_unchanged(self):
|
||||
"""ALL_HORAS must still be the 9 confirmed slots."""
|
||||
self.assertEqual(len(ALL_HORAS), 9)
|
||||
self.assertEqual(ALL_HORAS[0], "14:30")
|
||||
self.assertEqual(ALL_HORAS[-1], "16:30")
|
||||
|
||||
def test_priority_unchanged(self):
|
||||
"""PRIORITY must still start with contrarian slot."""
|
||||
self.assertEqual(PRIORITY[0], "15:15")
|
||||
self.assertEqual(len(PRIORITY), 9)
|
||||
|
||||
def test_headres_intact(self):
|
||||
"""HEADERS must still contain the ADF Content-Type."""
|
||||
self.assertEqual(HEADERS["Content-Type"], CT)
|
||||
|
||||
def test_target_ts_unchanged(self):
|
||||
"""TARGET_TS must still be Oct 4, 2026 midnight."""
|
||||
dt = datetime.fromtimestamp(TARGET_TS)
|
||||
self.assertEqual(dt.month, 10)
|
||||
self.assertEqual(dt.day, 4)
|
||||
self.assertEqual(dt.hour, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print(" BOOKING ASSISTANT — TEST SUITE v1.0")
|
||||
|
||||
Reference in New Issue
Block a user