diff --git a/.env.example b/.env.example index 9703fdd..f393466 100644 --- a/.env.example +++ b/.env.example @@ -300,3 +300,10 @@ LOGIN_TIMEOUT_MS=120000 NAV_TIMEOUT_MS=300000 NAV_RETRIES=3 CAPTCHA_ATTEMPTS=2 + +# ── Results Portal (web/) ───────────────────────────────────────────────────── +# Unset = this PC: Windows login to the DB, http://127.0.0.1:8000, Start / Stop. +# On the server (Dokploy) set these as the app's environment, never in Git: +# RESULTS_DB_CONN=DRIVER={ODBC Driver 18 for SQL Server};SERVER=;DATABASE=Charge_Validation;UID=;PWD=;TrustServerCertificate=yes; +# PORTAL_URLS=https:// +# PORTAL_SERVER=1 diff --git a/Context.md b/Context.md index dfb2e2d..4497c16 100644 --- a/Context.md +++ b/Context.md @@ -187,7 +187,7 @@ Get-ChildItem tests\test_*.py | ForEach-Object { "$($_.Name): $((python $_.FullN - Tests run offline against fake HTML pages in `tests/fixtures/` and fake DB connections; they never touch eCW or SQL Server (`test_cdp_browser.py` starts a local Chromium on a debug port). As of 2026-10-27: 71 test - files, **1220 tests** (full run 2026-10-27: 1220/1220) (earlier flaky, each + files, **1222 tests** (full run 2026-10-27: 1222/1222) (earlier flaky, each failed once and passed on rerun: test_verify_no_live_writes, depression_dx_pointer, progress_note_wait — watch them) (about 5 minutes for all of them). @@ -623,7 +623,8 @@ the Portal (restarted 2026-10-26, [portal] terminal tab). History: `git log --oneline`. -Last code commits: `f99dc17` (2026-10-27, Portal roles Super Admin / +Last code commits: `70ba709` (2026-10-27, Portal server mode for Dokploy — +§10), `f99dc17` (2026-10-27, Portal roles Super Admin / Admin / Viewer — §2), `61338b4` (2026-10-26, Sign-in page redesign + tab icon — §5), `a436489` (2026-10-26, Portal look: aligned header / footer, 7-day chart, today's reasons, pass rate — §5), `2d4d4fc` @@ -761,8 +762,16 @@ test claim ids — new posts must not (§7). - **Gitea:** one overall snapshot update at the end of a day, when the operator asks (from the clone `D:\ecw-charge-validation`; operator pushes; 2026-10-26: `ba15a57` → `120fd68`). -- **Portal for the client — Gitea + Dokploy (asked 2026-10-23, not - started).** Gitea `giteanew.ecareindia.com` (10.200.0.15, internal; its +- **Portal for the client — Gitea + Dokploy (asked 2026-10-23; server + mode built 2026-10-27 `70ba709`, not deployed yet).** `Dockerfile` + (python 3.13-slim + ODBC Driver 18, copies only `src/` + `web/`, + `requirements-portal.txt`); env in Dokploy, never in Git: + `RESULTS_DB_CONN` (a SQL login; a Linux container cannot use + Trusted_Connection or, likely, the named instance — host,port), + `PORTAL_URLS` (hosts / origins; all-https = Secure cookie), + `PORTAL_SERVER=1` (set in the image: NoRunner, no Start / Stop). The + Runs page there is empty (events files stay on the bot PC). Earlier + notes: Gitea `giteanew.ecareindia.com` (10.200.0.15, internal; its HTTPS certificate is not in Git Bash's curl bundle) and Dokploy (`http://10.200.0.15:3000`, same server). The operator joins Dokploy and creates a **Private** Gitea repo themselves; nothing is pushed until they diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd3da58 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# The results Portal on a Linux server (Dokploy, 2026-10-27): Sign-in, +# Dashboard, Claim status, Runs. The bot runs only on the Windows PC, so +# PORTAL_SERVER=1 turns Start / Stop off. Set RESULTS_DB_CONN (a SQL login) +# and PORTAL_URLS in Dokploy's environment — never in Git (.env.example). +FROM python:3.13-slim-bookworm + +# Microsoft ODBC Driver 18 for SQL Server, for pyodbc. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl gnupg ca-certificates \ + && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ + | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ + && curl -fsSL https://packages.microsoft.com/config/debian/12/prod.list \ + -o /etc/apt/sources.list.d/mssql-release.list \ + && apt-get update \ + && ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 \ + && apt-get purge -y curl gnupg && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements-portal.txt . +RUN pip install --no-cache-dir -r requirements-portal.txt +COPY src ./src +COPY web ./web + +RUN useradd --create-home portal +USER portal +ENV PORTAL_SERVER=1 PYTHONUNBUFFERED=1 +EXPOSE 8000 +CMD ["python", "-m", "uvicorn", "web.app:app", "--host", "0.0.0.0", \ + "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/requirements-portal.txt b/requirements-portal.txt new file mode 100644 index 0000000..8dff81d --- /dev/null +++ b/requirements-portal.txt @@ -0,0 +1,9 @@ +# The results Portal (web/) on a Linux server — Dockerfile. The bot's own +# packages (playwright, pyotp, openpyxl) are not needed there: the bot runs +# only on the Windows PC. Versions as on that PC, 2026-10-27. +fastapi==0.141.1 +starlette==1.3.1 +uvicorn==0.52.1 +jinja2==3.1.6 +python-multipart==0.0.32 +pyodbc==5.3.0 diff --git a/src/ecw_bot/core/run_lock.py b/src/ecw_bot/core/run_lock.py index 52f856a..3f48ac8 100644 --- a/src/ecw_bot/core/run_lock.py +++ b/src/ecw_bot/core/run_lock.py @@ -21,7 +21,10 @@ import os import time import json import ctypes -from ctypes import wintypes +try: + from ctypes import wintypes +except ValueError: # some non-Windows Pythons; the server Portal + wintypes = None # (PORTAL_SERVER=1) never calls the kernel32 parts from datetime import datetime, timedelta from .. import settings as cfg diff --git a/src/ecw_bot/settings.py b/src/ecw_bot/settings.py index c23e817..b7cbf01 100644 --- a/src/ecw_bot/settings.py +++ b/src/ecw_bot/settings.py @@ -688,6 +688,13 @@ RESULTS_DB_CONN = _env( "DATABASE=Charge_Validation;Trusted_Connection=yes;" "TrustServerCertificate=yes;") +# The Portal on a server (Dokploy, 2026-10-27). PORTAL_URLS: the address(es) +# people type, comma separated (e.g. https://cv.example.com); empty = this +# PC only (127.0.0.1:8000). PORTAL_SERVER=1: no Start / Stop — the bot runs +# only on the Windows PC. +PORTAL_URLS = _env("PORTAL_URLS") +PORTAL_SERVER = _env_bool("PORTAL_SERVER", False) + # eCW builds the file server-side over a wide date range, so this is generous. DOWNLOAD_TIMEOUT = _env_int("DOWNLOAD_TIMEOUT_MS", 300_000) # 5 min diff --git a/tests/test_portal_signin.py b/tests/test_portal_signin.py index 5233d3b..b5a04c2 100644 --- a/tests/test_portal_signin.py +++ b/tests/test_portal_signin.py @@ -332,6 +332,52 @@ def the_sign_in_page_says_what_the_portal_is_and_helps_with_the_password(): assert ' list: for (cat, text), n in ordered[:top]] +def portal_settings(urls: str, server: bool) -> dict: + """create_app's host / origin / cookie / Start settings from + PORTAL_URLS (comma separated, as people type them in the browser) and + PORTAL_SERVER. No URLs: this PC only. A server Portal never starts the + bot; an all-https Portal sends the cookie only over https.""" + parts = [urlsplit(u.strip()) for u in (urls or "").split(",") + if u.strip()] + if not parts: + return {"allowed_hosts": list(ALLOWED_HOSTS), + "allowed_origins": list(ALLOWED_ORIGINS), + "secure_cookie": False, "can_start": not server} + return {"allowed_hosts": [u.hostname for u in parts], + "allowed_origins": [f"{u.scheme}://{u.netloc}" for u in parts], + "secure_cookie": all(u.scheme == "https" for u in parts), + "can_start": not server} + + def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, allowed_origins=ALLOWED_ORIGINS, logs_dir=None, screenshots_root=None, users_store=None, - sessions=None) -> FastAPI: + sessions=None, can_start=True, + secure_cookie=False) -> FastAPI: app = FastAPI(title="eCW Charge Validation — Results", docs_url=None, redoc_url=None, openapi_url=None) - runner = runner if runner is not None else BotRunner() + if runner is None: + runner = BotRunner() if can_start else NoRunner() logs_dir = logs_dir if logs_dir is not None else cfg.LOGS screenshots_root = (screenshots_root if screenshots_root is not None else cfg.ROOT / "Screenshots") @@ -379,7 +398,8 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, "mode": "LIVE-WRITE" if cfg.SAVE_CORRECTIONS else "VERIFY", "run_token": app.state.run_token, "crumbs": crumbs, "categories": CATEGORIES, "user": user, - "can_run": bool(user and user["role"] in CAN_RUN), + "can_run": bool(can_start and user + and user["role"] in CAN_RUN), "is_admin": bool(user and user["role"] in CAN_ADMIN), **context}) @@ -521,7 +541,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, @app.post("/api/bot/start") def bot_start(request: Request): _check_writer(request) - user = _need(request, CAN_RUN) + user = _need(request, CAN_RUN if can_start else ()) try: return run_status.summarize( runner.start(started_by=user["username"])) @@ -531,7 +551,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, @app.post("/api/bot/stop") def bot_stop(request: Request): _check_writer(request) - user = _need(request, CAN_RUN) + user = _need(request, CAN_RUN if can_start else ()) try: return run_status.summarize( runner.stop(stopped_by=user["username"])) @@ -576,7 +596,8 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None, else _safe_next(next)) response = RedirectResponse(target, status_code=303) response.set_cookie(COOKIE, sessions.new(user), httponly=True, - samesite="strict", path="/") + samesite="strict", path="/", + secure=secure_cookie) return response @app.post("/signout") @@ -709,4 +730,5 @@ def _safe_next(target) -> str: app = create_app(SqlResultsRepo(cfg.RESULTS_DB_CONN), - users_store=SqlUserStore(cfg.RESULTS_DB_CONN)) + users_store=SqlUserStore(cfg.RESULTS_DB_CONN), + **portal_settings(cfg.PORTAL_URLS, cfg.PORTAL_SERVER)) diff --git a/web/bot_runner.py b/web/bot_runner.py index 1548f44..343e0d8 100644 --- a/web/bot_runner.py +++ b/web/bot_runner.py @@ -50,6 +50,23 @@ class StopFailed(Exception): pass +class NoRunner: + """The Portal on a server (Dokploy, 2026-10-27): the bot runs only on + the Windows PC, so nothing here ever starts, stops or looks for it.""" + + def start(self, started_by: str = "") -> dict: + raise NotRunning("Runs start on the bot PC, not from this server") + + def stop(self, stopped_by: str = "") -> dict: + raise NotRunning("Runs start on the bot PC, not from this server") + + def status(self) -> dict: + return {"running": False, "starting": False, "pid": None, + "started_at": None, "ended_at": None, "exit_code": None, + "stopped_by_operator": False, "source": None, + "started_by": None, "log_path": None, "out_path": None} + + class BotRunner: # How long stop() waits for a killed process to be gone. stop_wait = 10.0