diff --git a/.github/workflows/capturas.yml b/.github/workflows/capturas.yml deleted file mode 100644 index 0703c91..0000000 --- a/.github/workflows/capturas.yml +++ /dev/null @@ -1,123 +0,0 @@ -# Weekly screenshots of the five suite apps (Phase 11). -# -# Brings up the throwaway lean stack, enables the apps from clones (never -# app:install), pins es/es_CL + light theme, captures 1440x900 Playwright -# screenshots, oxipng-normalizes them, and opens a draft PR only when PNG -# bytes actually change. Schedule implies main-only (writes-on-main doctrine). -# GITHUB_TOKEN is never used for writes: two step-scoped App-token mints — -# mint A clones the roster repos (contents: read), mint B authors the PR -# (contents + pull_requests write, this repo only). -name: capturas - -on: - schedule: - - cron: "17 4 * * 1" # Mondays 04:17 UTC, off the top of the hour - workflow_dispatch: - -permissions: {} - -concurrency: - group: capturas - cancel-in-progress: false - -jobs: - roster: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - outputs: - repos: ${{ steps.roster.outputs.repos }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Fold catalogo.yml into the five-app roster - id: roster - run: | - pip install --quiet pyyaml - python3 - <<'PY' - import os - import yaml - repos = [r["repo"] for r in yaml.safe_load(open("catalogo.yml"))["repos"] - if r["kind"] == "nextcloud-app"] - if len(repos) != 5: - raise SystemExit(f"expected exactly 5 nextcloud-app rows, got {len(repos)}: {repos}") - with open(os.environ["GITHUB_OUTPUT"], "a") as out: - out.write("repos<.gestion.apsconecta.cl`. No pide ningún dato. +:::{figure} ../capturas/asistente-direccion.png +:alt: Paso «Dirección» del asistente con el nombre de la suite asignado y el botón «Continuar». + +«Dirección» con el nombre que obtuvo para la suite; en esta instalación de prueba, bajo `ci.gestion.apsconecta.cl`. +::: + - El nombre se forma con el nombre corto del establecimiento, en minúsculas, sin tildes, con «ñ» como «n» y con guiones. Si dos establecimientos comparten el nombre corto, se agrega la comuna. - Cada código DEIS tiene un solo nombre, que no se edita. - El nombre apunta a la dirección del servidor dentro de la red del establecimiento. El servidor publica esa dirección cada 10 minutos, así que el nombre sigue al servidor si su dirección cambia. @@ -134,6 +146,12 @@ Si el mensaje sigue después de la excepción en el router, compruebe que el com 4. Revisar la lista. Para quitar a alguien que dejó el establecimiento, usar «Retirar». 5. Presionar «Continuar». +:::{figure} ../capturas/asistente-equipos.png +:alt: Paso «Equipos» del asistente: dos sectores y un programa con sus carpetas, y la lista del personal de la planilla subida. + +«Equipos» con los sectores, los programas y el personal de la planilla. +::: + Cada sector tiene además su cuenta de jefatura. Los sectores y los programas quedan fijos al presionar «Instalar». #### La planilla del personal @@ -193,6 +211,12 @@ Una planilla corregida actualiza el nombre, el correo y el cargo de cada persona «Revisar» resume lo que se instalará: el centro, la dirección, los equipos, las personas, las cuentas de cargo, los componentes, las aplicaciones, la IA local y las copias de seguridad. +:::{figure} ../capturas/asistente-revisar.png +:alt: Paso «Revisar» completo: centro, dirección, equipos, personas, cuentas de cargo, componentes, aplicaciones, aplicaciones opcionales, IA local, copias de seguridad y el botón «Instalar». + +«Revisar» completo, antes de presionar «Instalar». +::: + 1. Revisar el resumen. 2. En «Aplicaciones opcionales», marcar las que usará el establecimiento: Farmacia, Estadística o Tótem. La elección del primer «Instalar» queda fija. 3. En «IA local», marcarla si el establecimiento la usará. Sin marcar, queda desactivada. @@ -213,6 +237,12 @@ La IA local es un modelo de lenguaje que corre en el propio servidor, en el cont La página muestra el avance de cada paso de la instalación y de cada contenedor. Descargar e iniciar los contenedores puede tardar varios minutos. La instalación sigue en el servidor aunque se cierre la página. +:::{figure} ../capturas/asistente-progreso.png +:alt: Avance de la instalación: el estado de cada contenedor, de «En espera» a «En ejecución», y el registro de la instalación. + +El avance de la instalación mientras AIO descarga e inicia los contenedores. +::: + Un paso que falla no detiene a los que no dependen de él. Sus dependientes quedan «En espera», y la página dice «Algunos pasos no se completaron; su motivo está en rojo. …». 1. Leer el motivo en rojo de cada paso y corregir lo que indique. @@ -224,6 +254,12 @@ Con todos los pasos completados, la instalación compara la suite en marcha con «Listo» muestra «APS Conecta Gestión quedó instalada. El personal del establecimiento la abre en esta dirección:» y la dirección. Al llegar a «Listo», el asistente se cierra y su enlace deja de funcionar. +:::{figure} ../capturas/asistente-listo.png +:alt: Paso «Listo»: la dirección de la suite, el botón «Descargar el archivo de contraseñas» y el aviso de que el enlace del asistente ya no funciona. + +«Listo», con la dirección de la suite y el archivo de contraseñas. +::: + 1. Presionar «Descargar el archivo de contraseñas» y guardar el archivo en un lugar seguro. El botón funciona mientras la página sigue abierta. 2. Leer el mensaje bajo la dirección. Si dice que el computador no abre la dirección, seguir [Si un computador no abre la dirección](#instalar-gestion-aio-router). 3. Compartir la dirección con el personal y entregar a cada persona su usuario y su primera contraseña. diff --git a/capturas/asistente-centro.png b/capturas/asistente-centro.png new file mode 100644 index 0000000..0dc6831 Binary files /dev/null and b/capturas/asistente-centro.png differ diff --git a/capturas/asistente-direccion.png b/capturas/asistente-direccion.png new file mode 100644 index 0000000..358f075 Binary files /dev/null and b/capturas/asistente-direccion.png differ diff --git a/capturas/asistente-equipos.png b/capturas/asistente-equipos.png new file mode 100644 index 0000000..976c34c Binary files /dev/null and b/capturas/asistente-equipos.png differ diff --git a/capturas/asistente-listo.png b/capturas/asistente-listo.png new file mode 100644 index 0000000..483b863 Binary files /dev/null and b/capturas/asistente-listo.png differ diff --git a/capturas/asistente-progreso.png b/capturas/asistente-progreso.png new file mode 100644 index 0000000..d34a18a Binary files /dev/null and b/capturas/asistente-progreso.png differ diff --git a/capturas/asistente-revisar.png b/capturas/asistente-revisar.png new file mode 100644 index 0000000..622987c Binary files /dev/null and b/capturas/asistente-revisar.png differ diff --git a/capturas/epidemiologia.png b/capturas/epidemiologia.png deleted file mode 100644 index a11d8ce..0000000 Binary files a/capturas/epidemiologia.png and /dev/null differ diff --git a/capturas/estadistica.png b/capturas/estadistica.png deleted file mode 100644 index 3c8972a..0000000 Binary files a/capturas/estadistica.png and /dev/null differ diff --git a/capturas/farmacia.png b/capturas/farmacia.png deleted file mode 100644 index f15a9c4..0000000 Binary files a/capturas/farmacia.png and /dev/null differ diff --git a/capturas/inicio-de-sesion.png b/capturas/inicio-de-sesion.png new file mode 100644 index 0000000..fafa3b1 Binary files /dev/null and b/capturas/inicio-de-sesion.png differ diff --git a/capturas/inicio.png b/capturas/inicio.png new file mode 100644 index 0000000..866954a Binary files /dev/null and b/capturas/inicio.png differ diff --git a/capturas/intravox.png b/capturas/intravox.png deleted file mode 100644 index 3ee8889..0000000 Binary files a/capturas/intravox.png and /dev/null differ diff --git a/capturas/territorio.png b/capturas/territorio.png deleted file mode 100644 index d8652fa..0000000 Binary files a/capturas/territorio.png and /dev/null differ diff --git a/desarrollo/documentation.md b/desarrollo/documentation.md index b71a1a6..5d638d7 100644 --- a/desarrollo/documentation.md +++ b/desarrollo/documentation.md @@ -96,13 +96,14 @@ El flujo `build` corre en cada pull request, en cada push a `main` y a diario, p El flujo `docs` corre `make structure` —la estructura de las páginas: encabezado tipado y esqueleto de secciones— y, mientras el escaneo nativo de secretos de GitHub no esté activado en este repositorio, el barrido `gitleaks` del rango de commits del pull request. El título del pull request, en inglés, es regla de esta página: la sección «Idioma y registro» la fija. -Tres flujos más completan el repositorio: `about-sync` replica los roles de `catalogo.yml` en las descripciones de los repositorios cuando cambia el catálogo, `capturas` toma cada lunes las capturas de las cinco aplicaciones y abre un borrador de pull request solo si cambian, e `ia-check` comprueba cada lunes que ningún modelo ni bot escriba en los repositorios sin estar declarado en `ia.yml`. +Dos flujos más completan el repositorio: `about-sync` replica los roles de `catalogo.yml` en las descripciones de los repositorios cuando cambia el catálogo, e `ia-check` comprueba cada lunes que ningún modelo ni bot escriba en los repositorios sin estar declarado en `ia.yml`. + +Las capturas de pantalla del manual (el inicio de sesión, «Inicio» y los pasos del asistente de instalación) se guardan en `capturas/` y vienen del artefacto `acceptance-screenshots` de la ejecución que publica cada versión de APS Conecta Gestión. La actualización del manual de cada versión las renueva a mano; nada lo automatiza. ## Compromisos y límites - **Enlaces que `linkcheck` omite.** Las URL que solo aparecen dentro de bloques tejidos no se comprueban: mantenerlas vivas es tarea del original. Tampoco se comprueban las URL de repositorios privados, las anclas de GitHub, que se arman con JavaScript, ni dos hosts que fallan de forma intermitente desde los runners de GitHub (deis.minsal.cl y gitnet.fr). - **Traducción mayoritariamente automática.** Los catálogos oficiales en español cubren cerca del 20 % del texto vigente; el resto lo traduce un flujo con inteligencia artificial, acotado por `upstream-fidelity`. -- **Capturas comparadas por tamaño.** Dos ejecuciones locales sobre una pila nueva produjeron PNG distintos byte a byte en las cinco aplicaciones por el ruido de tiempo de gráficos y mapas en canvas, así que la compuerta semanal compara por clase de tamaño con un 5 % de tolerancia en lugar de exigir igualdad de bytes. - **Solo modo claro.** El tema fija el modo claro; las variables oscuras repiten las claras. - **Sin URL canónica.** `html_baseurl` falta a propósito: emitiría etiquetas canónicas con un host externo, que la regla de aislamiento del sitio prohíbe. - **La hoja de ruta fuera de CI.** `tools/hoja-de-ruta.sh` usa la sesión de GitHub del responsable, porque el token de CI no alcanza para crear ni actualizar el proyecto de la organización; por eso no es un objetivo de `make` ni corre en CI. diff --git a/tools/capturas/captura.py b/tools/capturas/captura.py deleted file mode 100755 index cdacc56..0000000 --- a/tools/capturas/captura.py +++ /dev/null @@ -1,409 +0,0 @@ -#!/usr/bin/env python3 -"""captura — the screenshots driver (Phase 11, capturas.yml). - -Brings up the throwaway lean stack (tools/capturas/compose.yaml), enables the -five suite apps from clones (never `occ app:install`), pins es/es_CL + the -enforced light theme, seeds a deterministic synthetic fixture, and captures one -1440x900 viewport screenshot per app with Playwright. Every PNG is -oxipng-normalized before diffing against HEAD; the byte-stability table lands -in $GITHUB_STEP_SUMMARY when set, stdout otherwise. - -Idioms mirrored from instalador/provisioning (citations org-root-relative): - - tar-stream each app into custom_apps as www-data (provisioning/lib.sh's - ensure_vendored_app unpack: files must land owned by www-data); - - `occ app:enable` only, store off first — enable reads what is on disk and - never contacts the store; keep occ's own error text (lib.sh:355-361); - - the four locale calls of provisioning/phases/10-locale.sh:15-18; - - enforce_theme light (system) + theming disable-user-theming, written 'yes' - and stored '1' (phases/15-branding.sh:48-49 + lib.sh theming_set's trap); - - one visibly-synthetic sample file, then `occ files:scan` - (phases/60-fixtures.sh ensure_sample_file idiom). - -Env: APS_ORG_ROOT (default /opt/aps-conecta-org) — local clone root; - CAPTURAS_APPS — clone dir in CI (takes precedence over APS_ORG_ROOT); - CAPTURAS_PORT (default 8095, mirrors the compose port default); - OXIPNG_BIN (default `oxipng` on PATH) — the sha256-verified binary. - -Exit: 0 = captured (changed or not); 1 = any hard failure (roster != 5 apps, -missing clone, unhealthy stack, enable failure, wrong PNG set, dimension -mismatch, oxipng failure). -""" - -import argparse -import json -import os -import shutil -import struct -import subprocess -import sys -import tempfile -import time -import xml.etree.ElementTree as ET -from pathlib import Path - -import yaml - -HERE = Path(__file__).resolve().parent -REPO = HERE.parents[1] -COMPOSE = HERE / "compose.yaml" -CATALOGO = REPO / "catalogo.yml" -CAPTURAS = REPO / "capturas" - -VIEWPORT_W = 1440 -VIEWPORT_H = 900 -SETTLE_MS = 1500 # fixed settle after networkidle (build-og idiom, sized for the SPA) -SIZECLASS_LENIENCY = 0.05 # --compare sizeclass fallback (build-og.py's "noise people ignore" warning) -NO_ANIM_CSS = ("*, *::before, *::after {" - " animation: none !important; transition: none !important; }") - -FIXTURE_NAME = "Bienvenida-captura.md" -FIXTURE_BODY = """\ -# Bienvenida a APS Conecta (captura de pantalla) - -Este archivo **sintético** existe para que la captura semanal de pantallas -muestre un estado determinista. No contiene datos reales. -""" - - -def log(msg: str) -> None: - print(f"[capturas] {msg}") - - -def die(msg: str) -> None: - print(f"[capturas] FAILED: {msg}", file=sys.stderr) - sys.exit(1) - - -def run(argv, **kw): - return subprocess.run(argv, capture_output=True, text=True, **kw) - - -def run_bytes(argv, **kw): - return subprocess.run(argv, capture_output=True, **kw) - - -# --- roster + appid ---------------------------------------------------------- - - -def apps_dir() -> Path: - d = os.environ.get("CAPTURAS_APPS") or os.environ.get("APS_ORG_ROOT", "/opt/aps-conecta-org") - return Path(d) - - -def roster() -> list: - """catalogo.yml -> the five kind: nextcloud-app rows, canonical repo names. - - The SSOT-side half of gen-catalogo.py's parity invariant: exactly five - nextcloud-app rows. The OWN_APPS fold against the installer stays owned by the - catalog generator — one invariant, one owner. - """ - rows = yaml.safe_load(CATALOGO.read_text(encoding="utf-8"))["repos"] - names = [r["repo"] for r in rows if r.get("kind") == "nextcloud-app"] - if len(names) != 5: - die(f"catalogo.yml holds {len(names)} kind: nextcloud-app rows " - f"({', '.join(names)}); this pipeline is built for exactly five") - return names - - -def appid_of(clone: Path) -> str: - """ from the clone's appinfo/info.xml — the only true source - (IntraVox -> intravox; never a hardcoded map).""" - info = clone / "appinfo" / "info.xml" - if not info.is_file(): - die(f"missing {info} — clone incomplete?") - return ET.parse(info).getroot().findtext("id").strip() - - -def resolve_roster() -> list: - """[(repo, appid)] in roster order, every clone verified on disk.""" - pairs = [] - for name in roster(): - clone = apps_dir() / name - if not (clone / "appinfo" / "info.xml").is_file(): - die(f"clone {clone} is missing its appinfo/info.xml — clone it first " - f"(APS_ORG_ROOT locally, CAPTURAS_APPS in CI)") - pairs.append((name, appid_of(clone))) - return pairs - - -# --- compose + occ ----------------------------------------------------------- - - -def compose(*argv, **kw): - return run(["docker", "compose", "-f", str(COMPOSE), *argv], **kw) - - -def occ(*args): - """occ in the container as www-data — env.sh's console form. Every call is - logged: the job log is the evidence that the store went off BEFORE the - first enable and that no `app:install` ever runs.""" - log("occ " + " ".join(args)) - return compose("exec", "-T", "-u", "www-data", "nextcloud", - "php", "/var/www/html/occ", *args) - - -def wait_installed() -> None: - """occ's own view of installed — `up --wait` gates on status.php, which can - answer 200 while the installer is still finishing.""" - for _ in range(60): # 60 x 5s = 5 min on top of the compose health budget - p = occ("status", "--output", "json") - if p.returncode == 0: - try: - if json.loads(p.stdout).get("installed") is True: - return - except ValueError: - pass - time.sleep(5) - die("stack never reached installed=true (occ status)") - - -def stream_app_in(clone: Path, appid: str) -> None: - """Stage the clone as / (repo case != appid for IntraVox), .git and - node_modules excluded at staging time, then tar-stream it into custom_apps - as www-data — lib.sh's unpack idiom: nothing is copied through the host, and - files must land owned by www-data (uid 33) or the app cannot be used. - node_modules is a local build artifact (untracked in every clone, absent - from a CI shallow clone — and one local copy self-loops), so excluding it - makes the local staging byte-equal to CI's.""" - with tempfile.TemporaryDirectory(prefix="capturas-stage-") as stage: - shutil.copytree(clone, Path(stage) / appid, - ignore=shutil.ignore_patterns(".git", "node_modules")) - pack = subprocess.Popen(["tar", "-C", stage, "-cf", "-", appid], stdout=subprocess.PIPE) - unpack = subprocess.Popen( - ["docker", "compose", "-f", str(COMPOSE), "exec", "-T", "-u", "www-data", - "nextcloud", "tar", "xf", "-", "-C", "/var/www/html/custom_apps"], - stdin=pack.stdout) - pack.stdout.close() # let SIGPIPE propagate either way - unpack_rc, pack_rc = unpack.wait(), pack.wait() - if unpack_rc != 0 or pack_rc != 0: - die(f"tar-stream of {clone.name} into custom_apps/{appid} failed") - - -def enable_apps(appids: list) -> None: - # Store OFF before anything is enabled. `app:enable` only, never - # `app:install`: enable reads what is on disk and never contacts the store. - p = occ("config:system:set", "appstoreenabled", "--value=false", "--type=boolean") - if p.returncode != 0: - die(f"appstoreenabled=false failed — occ said: {p.stderr.strip()[:300]}") - for appid in appids: - p = occ("app:enable", appid) - if p.returncode != 0: - # Keep occ's own error: it is the only thing that says WHY - # (lib.sh's #41 lesson — never guess a cause). - die(f"occ app:enable {appid} failed — occ said: {p.stderr.strip()[:300]}") - - -def pin_config() -> None: - """The four 10-locale.sh calls + the 15-branding.sh theme pair, verbatim.""" - for args in ( - ("config:system:set", "default_language", "--value=es"), - ("config:system:set", "force_language", "--value=es"), - ("config:system:set", "default_locale", "--value=es_CL"), - ("config:system:set", "default_phone_region", "--value=CL"), - ("config:system:set", "enforce_theme", "--value=light"), - ): - p = occ(*args) - if p.returncode != 0: - die(f"{' '.join(args)} failed — occ said: {p.stderr.strip()[:300]}") - # theming-app config, NOT a raw config:app:set — ThemingController stores - # this via setAppValueBool: written 'yes', stored '1' (lib.sh theming_set). - p = occ("theming:config", "disable-user-theming", "yes") - if p.returncode != 0: - die(f"theming:config disable-user-theming failed — occ said: {p.stderr.strip()[:300]}") - - -def seed_fixture() -> None: - """One visibly-synthetic welcome file in admin's Files + firstrunwizard - off, so first-run hints can never differ between runs. Written as www-data - into the image-default datadir, then files:scan — the ensure_sample_file - idiom. Content is pinned in this file, so overwrite + scan is idempotent.""" - dest = f"/var/www/html/data/admin/files/{FIXTURE_NAME}" - argv = ["docker", "compose", "-f", str(COMPOSE), "exec", "-T", "-u", "www-data", - "nextcloud", "sh", "-c", - f'mkdir -p "$(dirname "{dest}")" && cat > "{dest}"'] - fed = subprocess.run(argv, input=FIXTURE_BODY.encode(), capture_output=True) - if fed.returncode != 0: - die(f"seeding {FIXTURE_NAME} failed — {fed.stderr.decode(errors='replace').strip()[:300]}") - if occ("files:scan", "admin").returncode != 0: - die("occ files:scan admin failed") - if occ("user:setting", "admin", "firstrunwizard", "show", "0").returncode != 0: - die("occ user:setting admin firstrunwizard show 0 failed") - log(f"fixture {FIXTURE_NAME} seeded + indexed") - - -# --- Playwright -------------------------------------------------------------- - - -def assert_ihdr(png: Path) -> None: - head = png.read_bytes()[:24] - if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n": - die(f"{png.name}: not a PNG") - w, h = struct.unpack(">II", head[16:24]) - if (w, h) != (VIEWPORT_W, VIEWPORT_H): - die(f"{png.name}: IHDR is {w}x{h}, expected {VIEWPORT_W}x{VIEWPORT_H}") - - -def nav_hrefs(page, base_url: str, appids: list) -> dict: - """appid -> href from the server-side OCS navigation endpoint — the same - source the SPA and the mobile clients consume. Route-agnostic by - construction: hrefs come from the server, never guessed (epidemiologia's - Spanish URL included). NC 34's Vue dashboard renders no li[data-id] nav - and no app href links to click, so the server is the only route source.""" - r = page.request.get(base_url + "/ocs/v2.php/core/navigation/apps?format=json", - headers={"OCS-APIRequest": "true"}) - try: - entries = r.json()["ocs"]["data"] - except (ValueError, KeyError): - die(f"navigation endpoint answered {r.status} — cannot resolve app hrefs") - hrefs = {o["id"]: o["href"] for o in entries} - missing = [a for a in appids if a not in hrefs] - if missing: - die(f"navigation has no entry for {missing} — app enabled without nav?") - return hrefs - - -def capture(base_url: str, appids: list) -> None: - # Imported lazily so --selftest never needs Playwright installed. - from playwright.sync_api import sync_playwright - - CAPTURAS.mkdir(exist_ok=True) - with sync_playwright() as p: - browser = p.chromium.launch(args=["--no-sandbox"]) - page = browser.new_page(viewport={"width": VIEWPORT_W, "height": VIEWPORT_H}, - device_scale_factor=1, reduced_motion="reduce") - page.goto(base_url + "/", wait_until="networkidle") - page.fill('input[name="user"]', "admin") - page.fill('input[name="password"]', "admin") - # NC 34 renders the login form with class login-form (no id), fully - # client-side; the submit button is the form's type=submit button-vue. - page.click('form.login-form button[type="submit"]') - page.wait_for_url(lambda url: "/login" not in url, timeout=90_000) - page.wait_for_load_state("networkidle") - hrefs = nav_hrefs(page, base_url, appids) - for appid in appids: - # Navigate by the server-served href — see nav_hrefs: guessing - # routes breaks on apps whose route is a Spanish URL (epidemiologia - # has exactly one, and it is not guessable). - page.goto(base_url + hrefs[appid], wait_until="networkidle") - page.wait_for_timeout(SETTLE_MS) - page.add_style_tag(content=NO_ANIM_CSS) - out = CAPTURAS / f"{appid}.png" - page.screenshot(path=str(out), full_page=False) # the viewport, exactly - assert_ihdr(out) - log(f"captured {out.relative_to(REPO)} ({out.stat().st_size} B)") - browser.close() - - -# --- normalize + diff -------------------------------------------------------- - - -def normalize(pngs: list) -> None: - binary = os.environ.get("OXIPNG_BIN", "oxipng") - for png in pngs: - p = run([binary, "-o", "4", "--strip", "safe", str(png)]) - if p.returncode != 0: - die(f"oxipng failed on {png.name}: {(p.stderr or p.stdout).strip()[:300]}") - - -def old_bytes(png: Path): - """HEAD's bytes for the PNG, or None when untracked (run 1).""" - p = run_bytes(["git", "-C", str(REPO), "show", f"HEAD:capturas/{png.name}"]) - return p.stdout if p.returncode == 0 and p.stdout else None - - -def equal_under(old, new: bytes, compare: str) -> bool: - if old is None: - return False # a new file is a change, not an equality - if compare == "sizeclass": - return abs(len(old) - len(new)) <= len(new) * SIZECLASS_LENIENCY - return old == new - - -def report(rows: list, compare: str) -> None: - lines = ["| png | old B | new B | delta | equal? |", - "|---|---:|---:|---:|---|"] - for name, old, new, equal in rows: - old_s = "new" if old is None else str(len(old)) - delta = "-" if old is None else f"{len(new) - len(old):+d}" - lines.append(f"| `{name}` | {old_s} | {len(new)} | {delta} | {'yes' if equal else 'NO'} |") - table = "\n".join(lines) - print(table) - summary = os.environ.get("GITHUB_STEP_SUMMARY") - if summary: - with open(summary, "a", encoding="utf-8") as fh: - fh.write(f"\n### capturas — byte stability (compare: {compare})\n\n{table}\n") - - -# --- commands ---------------------------------------------------------------- - - -def cmd_capture(args) -> None: - pairs = resolve_roster() - appids = [appid for _, appid in pairs] - port = os.environ.get("CAPTURAS_PORT", "8095") - - up = compose("up", "-d", "--wait", "--wait-timeout", "420") - try: - if up.returncode != 0: - die(f"stack failed to reach healthy: {(up.stderr or up.stdout).strip()[:300]}") - wait_installed() - for name, appid in pairs: - stream_app_in(apps_dir() / name, appid) - log(f"streamed {name} -> custom_apps/{appid}") - enable_apps(appids) - pin_config() - seed_fixture() - capture(f"http://localhost:{port}", appids) - finally: - down = compose("down", "-v") # teardown on success AND failure paths - if down.returncode != 0: - log(f"WARNING: compose down -v failed: {down.stderr.strip()[:200]}") - - pngs = sorted(CAPTURAS.glob("*.png")) - expected = {f"{appid}.png" for appid in appids} - if {p.name for p in pngs} != expected: - die(f"capturas/ must hold exactly {sorted(expected)}, found " - f"{sorted(p.name for p in pngs)}") - normalize(pngs) - rows = [] - for png in pngs: - new = png.read_bytes() - old = old_bytes(png) - rows.append((png.name, old, new, equal_under(old, new, args.compare))) - report(rows, args.compare) - log("done — 5 captures, normalized, diffed against HEAD") - - -def cmd_selftest(_args) -> None: - """No docker: pin the roster fold + the appid map against local checkouts.""" - names = roster() - want_names = {"farmacia", "territorio", "estadistica", "epidemiologia", "IntraVox"} - assert set(names) == want_names, f"roster fold is {names}, want {sorted(want_names)}" - want_map = {"farmacia": "farmacia", "territorio": "territorio", - "estadistica": "estadistica", "epidemiologia": "epidemiologia", - "IntraVox": "intravox"} - got = {name: appid_of(apps_dir() / name) for name in names} - assert got == want_map, f"appid map is {got}, want {want_map}" - print("selftest ok: roster fold = 5 nextcloud-app rows; appid map = " - + ", ".join(f"{k}->{v}" for k, v in got.items())) - - -def main() -> None: - ap = argparse.ArgumentParser(description="weekly screenshots driver (Phase 11)") - ap.add_argument("--selftest", action="store_true", - help="no docker: pin the roster fold + the appid map") - sub = ap.add_subparsers(dest="cmd") - cap = sub.add_parser("capture", help="up, enable, configure, capture, teardown") - cap.add_argument("--compare", choices=["bytes", "sizeclass"], default="bytes", - help="diff gate: byte equality (default) or 5%% size class") - args = ap.parse_args() - if args.selftest: - cmd_selftest(args) - elif args.cmd == "capture": - cmd_capture(args) - else: - ap.error("nothing to do — pass `capture` or `--selftest`") - - -if __name__ == "__main__": - main() diff --git a/tools/capturas/compose.yaml b/tools/capturas/compose.yaml deleted file mode 100644 index b6c468e..0000000 --- a/tools/capturas/compose.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# Throwaway lean stack for the weekly screenshots (driven by tools/capturas/captura.py). -# -# Faithful copy of farmacia/compose.dev.yaml with named deltas: name `capturas`, -# loopback port 8095 (8083/8090/8092 are taken by the territorio/farmacia/ -# estadistica dev stacks), named volumes under the capturas_ prefix, and NO app -# mounts — all app wiring lives in captura.py via occ, so a roster change never -# touches this file. postgres:18-alpine because estadistica declares pgsql >= 18 -# (estadistica/appinfo/info.xml); same images as the dev stacks to share layers. -name: capturas - -services: - db: - image: postgres:18-alpine - restart: "no" - volumes: - # postgres:18+ stores data in a major-version subdir, so mount the - # parent, not /data (territorio's comment, learned the hard way). - - capturas_postgres:/var/lib/postgresql - environment: - POSTGRES_DB: nextcloud - POSTGRES_USER: nextcloud - POSTGRES_PASSWORD: dev - healthcheck: - test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"] - interval: 5s - timeout: 5s - retries: 10 - - nextcloud: - image: nextcloud:34-apache - restart: "no" - ports: - - "127.0.0.1:${CAPTURAS_PORT:-8095}:80" - depends_on: - db: - condition: service_healthy - volumes: - - capturas_nextcloud:/var/www/html - environment: - POSTGRES_HOST: db - POSTGRES_DB: nextcloud - POSTGRES_USER: nextcloud - POSTGRES_PASSWORD: dev - # Throwaway admin/admin creds: loopback-only port, volumes destroyed in - # the same run's `down -v`. Same shape as the farmacia dev stack. - NEXTCLOUD_ADMIN_USER: admin - NEXTCLOUD_ADMIN_PASSWORD: admin - NEXTCLOUD_TRUSTED_DOMAINS: localhost - healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost/status.php || exit 1"] - interval: 10s - timeout: 5s - retries: 30 - start_period: 60s - -volumes: - capturas_postgres: - capturas_nextcloud: diff --git a/tools/capturas/pr.py b/tools/capturas/pr.py deleted file mode 100755 index 58c7f15..0000000 --- a/tools/capturas/pr.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 -"""pr — open the weekly screenshots draft PR (Phase 11, capturas.yml). - -Adapted from repo-docs' open_pr() (.github/repo-docs.py): refuses on a tree -dirty outside capturas/*.png, branches to the next free capturas/refresh-, -commits the PNG paths add-only, pushes, opens a DRAFT PR with an accent-free -English title (title_is_english-safe: no accented characters, no Spanish -stopwords) and a body carrying the byte-stability table. Never force-pushes, -never merges, never touches the default branch. - -The PR is App-authored (mint B in capturas.yml), NOT GITHUB_TOKEN-authored, -so the repo's own workflows run on it — GITHUB_TOKEN-originated events trigger -no workflows. - -Env: GH_TOKEN (mint B). The push authenticates via a one-shot git credential -helper reading $GH_TOKEN from the environment — the token never reaches argv, -URL, or a config file. Exit: 0 = PR opened or nothing to open; 1 = any git/gh -failure or an unrelated dirty tree. -""" - -import re -import subprocess -import sys -from pathlib import Path - -REPO = Path(__file__).resolve().parents[2] -PREFIX = "capturas/refresh-" -PNG = re.compile(r"^capturas/[^/]+\.png$") - -BOT_NAME = "aps-pi[bot]" -BOT_EMAIL = "aps-pi[bot]@users.noreply.github.com" - - -def sh(argv, **kw): - return subprocess.run(argv, capture_output=True, text=True, **kw) - - -def fail(msg: str) -> None: - print(msg, file=sys.stderr) - sys.exit(1) - - -def porcelain() -> list: - r = sh(["git", "-C", str(REPO), "status", "--porcelain"]) - return [ln for ln in r.stdout.split("\n") if ln.strip()] - - -def path_of(line: str) -> str: - """Porcelain lines are 'XY path'; split on whitespace — the file is the - tail (repo-docs _porcelain_path: stdout may arrive as 'M file', a fixed - column slice silently truncates the filename).""" - parts = line.split(None, 1) - return (parts[1] if len(parts) > 1 else parts[0]).strip().strip('"') - - -def default_branch() -> str: - r = sh(["git", "-C", str(REPO), "symbolic-ref", "--short", - "refs/remotes/origin/HEAD"]) - ref = r.stdout.strip() - return ref[len("origin/"):] if ref.startswith("origin/") else "main" - - -def next_free_branch() -> str: - """open_pr scanned local branches only; on an ephemeral runner the REMOTE - list is the one that persists week to week, so both are scanned.""" - used = set() - r = sh(["git", "-C", str(REPO), "branch", "--list", f"{PREFIX}*"]) - used |= {int(m.group(1)) for ln in r.stdout.splitlines() - if (m := re.search(r"(\d+)$", ln.strip()))} - r = sh(["git", "-C", str(REPO), "ls-remote", "--heads", "origin"]) - used |= {int(m.group(1)) for ln in r.stdout.splitlines() - if (m := re.search(r"refs/heads/capturas/refresh-(\d+)$", ln))} - n = 1 - while n in used: - n += 1 - return f"{PREFIX}{n}" - - -def byte_table(pngs: list) -> str: - """The same table captura.py printed — rebuilt here so the PR body is - self-contained evidence of what changed and by how much.""" - lines = ["| png | old B | new B | delta | equal? |", "|---|---:|---:|---:|---|"] - for rel in pngs: - png = REPO / rel - new = png.read_bytes() - p = subprocess.run(["git", "-C", str(REPO), "show", f"HEAD:{rel}"], - capture_output=True) - old = p.stdout if p.returncode == 0 and p.stdout else None - old_s = "new" if old is None else str(len(old)) - delta = "-" if old is None else f"{len(new) - len(old):+d}" - equal = "yes" if (old is not None and old == new) else "NO" - lines.append(f"| `{Path(rel).name}` | {old_s} | {len(new)} | {delta} | {equal} |") - return "\n".join(lines) - - -def main() -> None: - changed = [path_of(ln) for ln in porcelain()] - stray = [p for p in changed if not PNG.match(p)] - if stray: - fail("refused: working tree has unrelated changes —\n " - + "\n ".join(stray[:8]) + "\ncommit or stash them first") - pngs = [p for p in changed if PNG.match(p)] - if not pngs: - print("nothing to open a PR for") - return - - k = len(pngs) - title = f"capturas: refresh {k} screenshot" + ("s" if k != 1 else "") - branch = next_free_branch() - body = (f"### capturas — byte stability\n\n{byte_table(pngs)}\n\n" - "Rerun: **Actions → capturas → Run workflow** (`workflow_dispatch`), or wait " - "for the weekly Monday 04:17 UTC run. Byte-equality is the default gate; " - "`--compare sizeclass` is the pre-built fallback.\n\n" - "Draft: screenshots need a human look before merge. No `Docs:` trailer — " - "PNG-only paths are pr-gate-exempt.") - - for label, argv in ( - ("checkout", ["git", "-C", str(REPO), "checkout", "-q", "-b", branch]), - ("add", ["git", "-C", str(REPO), "add", "--", *pngs]), - ("commit", ["git", "-C", str(REPO), "-c", f"user.name={BOT_NAME}", "-c", - f"user.email={BOT_EMAIL}", "commit", "-qm", - f"{title}\n\nGenerated by capturas.yml; byte-diff guarded."]), - ("push", ["git", "-C", str(REPO), - "-c", "credential.helper=", - "-c", "credential.helper=!f() { echo username=x-access-token; " - "echo password=$GH_TOKEN; }; f", - "push", "-q", "-u", "origin", branch]), - ): - r = sh(argv) - if r.returncode != 0: - fail(f"failed at git {label}: {r.stderr.strip()[:300]}") - - r = sh(["gh", "pr", "create", "--draft", "--repo", "APS-Conecta/documentation", - "--head", branch, "--base", default_branch(), "--title", title, - "--body", body]) - if r.returncode != 0: - fail(f"branch pushed, PR not created: {r.stderr.strip()[:300]}") - print(r.stdout.strip()) - - -if __name__ == "__main__": - main() diff --git a/tools/capturas/requirements.txt b/tools/capturas/requirements.txt deleted file mode 100644 index 3b3e8e9..0000000 --- a/tools/capturas/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Capture-driver deps only. CI installs from here — the site's root -# requirements.txt stays sphinx-only, so build.yml never pays for Playwright. -# -# playwright is pinned to the version on the org box so the local double-capture -# (risk r8) and the weekly CI captures ride the same driver + chromium build. -playwright==1.61.0 -pyyaml==6.0.3 diff --git a/usuario/interfaz-web.md b/usuario/interfaz-web.md index 35531e7..29ccecb 100644 --- a/usuario/interfaz-web.md +++ b/usuario/interfaz-web.md @@ -153,8 +153,8 @@ Los problemas de APS Conecta Gestión, de sus aplicaciones y de su instalación APS Conecta Gestión configura la interfaz igual para todo el personal del establecimiento. Las diferencias con la descripción anterior son estas: -- **Pantalla de inicio.** Después de iniciar sesión se abre la pantalla de bienvenida de la intranet (IntraVox), no el **Dashboard**. El icono de casa de la barra superior vuelve a esa pantalla. Si la intranet no está instalada, el inicio vuelve al **Dashboard**. -- **Nombre del centro.** La barra superior y la página de inicio de sesión muestran el nombre corto del establecimiento junto a la marca de APS Conecta. +- **Pantalla de inicio.** Después de iniciar sesión se abre «Inicio», la pantalla de bienvenida de la intranet (IntraVox). El logotipo de la barra superior vuelve a esa pantalla. Si la intranet no está instalada, se abre **Archivos**. +- **Nombre del centro.** La barra superior y la página de inicio de sesión muestran el nombre corto del establecimiento junto a la marca de APS Conecta. En la pantalla de un teléfono, la barra superior muestra solo el logotipo. - **Idioma.** La interfaz está siempre en español, con fechas y números en el formato de Chile. El idioma del navegador no la cambia. - **Apariencia.** La suite fija el tema claro con los colores de APS Conecta. El tema oscuro, el de alto contraste y los fondos o colores personales que describe la sección «Acceso universal» no están disponibles. - **Aplicaciones.** El conjunto de aplicaciones es fijo y la tienda de aplicaciones está desactivada: ninguna aplicación se agrega desde la interfaz. @@ -166,3 +166,15 @@ Para entrar por primera vez: 2. Escribir el nombre de cuenta y la contraseña inicial entregados. 3. Iniciar sesión. 4. Leer la pantalla de bienvenida de la intranet, que se abre sola. + +:::{figure} ../capturas/inicio-de-sesion.png +:alt: Página de inicio de sesión de APS Conecta Gestión: los campos de cuenta y contraseña, «Iniciar sesión» y «¿Contraseña olvidada?». + +La página de inicio de sesión, con el nombre corto del establecimiento sobre el formulario y el lema de la suite al pie. +::: + +:::{figure} ../capturas/inicio.png +:alt: «Inicio», la pantalla de bienvenida de la intranet, con el nombre corto del establecimiento en la barra superior, el botón «Editar página» y cuatro accesos: «Las carpetas del establecimiento», «La agenda del equipo», «Teléfonos y correos» y «Mensajes y videollamadas». + +«Inicio», la pantalla que se abre después de iniciar sesión. +:::