commit 9f45ed3452db8a65035e0c57227b3fea3914bcfc Author: kamaji Date: Mon Jan 26 04:46:56 2026 -0600 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..230f0c2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +venv/ +__pycache__/ +uploads/ +.git/ +*.swp +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb7e70f --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +NC_URL=https://nextcloud.example.com +NC_USER=admin +NC_PASS=changeme +NC_FOLDER=APtool +SECRET_KEY=changeme-generate-a-random-string diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..775eee2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +venv/ +__pycache__/ +uploads/ +*.swp +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1d249a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY templates/ templates/ +COPY sites.conf . + +RUN mkdir -p uploads + +EXPOSE 5000 + +CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "--access-logfile", "-", "app:app"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..46f25d2 --- /dev/null +++ b/app.py @@ -0,0 +1,1042 @@ +############################################################################## +# APtool - AP Installation Data Collection Web App +# +# A mobile-friendly Flask web application for collecting access point (AP) +# installation data at job sites. Technicians log in with a PIN and 4-digit +# site number, then submit AP details (location, serial number, cable length) +# along with 6 required photos per AP. +# +# Data is saved locally and synced to Nextcloud via WebDAV: +# Local: uploads/{site}/ — photos + data.xlsx +# Nextcloud: APtool/{site}/uploads/ — photos +# APtool/{site}/data.xlsx — spreadsheet +# +# Configuration is via environment variables (with defaults): +# NC_URL, NC_USER, NC_PASS — Nextcloud connection +# NC_FOLDER — Nextcloud root folder (default: "APtool") +# sites.conf — per-site PIN + optional NC creds (site:pin[:nc_user:nc_pass]) +# SECRET_KEY — Flask session secret (auto-generated if unset) +############################################################################## + +import os +import re +import glob +import logging +import secrets +from pathlib import Path + +import requests +from flask import ( + Flask, render_template, request, jsonify, session, redirect, url_for, + send_file, +) +from openpyxl import Workbook, load_workbook +# Font – controls text style (bold, italic, color, size) +# PatternFill – controls cell background color +# Alignment – controls text alignment (left, center, right) +# These are used in get_or_create_workbook() to style the header row, +# and in submit() to apply alternating row colors. +from openpyxl.styles import Font, PatternFill, Alignment + +# --------------------------------------------------------------------------- +# Flask app initialization +# --------------------------------------------------------------------------- +# secret_key is required for session cookies. If SECRET_KEY env var is not +# set, a random key is generated at startup (sessions won't survive restarts). +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32)) +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Filesystem paths +# --------------------------------------------------------------------------- +# BASE_DIR — the directory containing this script (/home/kamaji/aptool/) +# UPLOAD_DIR — root directory for all site uploads; each site gets a +# subdirectory named by its 4-digit site number, e.g. +# uploads/5001/AP010.jpg, uploads/5001/data.xlsx +BASE_DIR = Path(__file__).resolve().parent +UPLOAD_DIR = BASE_DIR / "uploads" + +# Create the uploads directory if it doesn't already exist +UPLOAD_DIR.mkdir(exist_ok=True) + +# --------------------------------------------------------------------------- +# Nextcloud configuration +# --------------------------------------------------------------------------- +# NC_URL — base URL of the Nextcloud instance (e.g. https://cloud.example.com) +# NC_USER — Nextcloud username for WebDAV authentication +# NC_PASS — Nextcloud app password (generate at Settings > Security > App passwords) +# NC_FOLDER — top-level folder in Nextcloud where site data is stored; +# each site gets a subfolder: APtool/5001/uploads/, APtool/5001/data.xlsx +NC_URL = os.environ.get("NC_URL", "") +NC_USER = os.environ.get("NC_USER", "") +NC_PASS = os.environ.get("NC_PASS", "") +NC_FOLDER = os.environ.get("NC_FOLDER", "APtool") + +# --------------------------------------------------------------------------- +# Site authentication — loaded from sites.conf +# --------------------------------------------------------------------------- +# Each site has its own PIN, defined in sites.conf (one per line, format: +# site_number:pin[:nc_user:nc_pass]). +# The optional nc_user:nc_pass fields allow per-site Nextcloud credentials. +# If omitted, the global NC_USER / NC_PASS defaults are used. +# The file is re-read on every login attempt so that an admin can add/remove +# sites without restarting the app. +# +# Example sites.conf: +# 5001:1234 (uses global NC credentials) +# 5002:5678:alice:AppPass-12345 (uses per-site NC user "alice") +SITES_FILE = BASE_DIR / "sites.conf" + + +def load_sites() -> dict[str, dict]: + """Read sites.conf and return a dict mapping site number → site config. + + Each value is a dict with keys: + pin — the site's login PIN (always present) + nc_user — per-site Nextcloud username (None → use global NC_USER) + nc_pass — per-site Nextcloud password (None → use global NC_PASS) + + Skips blank lines and lines starting with #. + If the file doesn't exist, returns an empty dict (no sites can log in). + Re-read on every call so edits take effect immediately. + """ + sites = {} + if not SITES_FILE.exists(): + log.warning("sites.conf not found — no sites can log in") + return sites + for line in SITES_FILE.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if ":" not in line: + continue + parts = line.split(":") + site_num = parts[0].strip() + pin = parts[1].strip() if len(parts) > 1 else "" + nc_user = parts[2].strip() if len(parts) > 2 else None + nc_pass = parts[3].strip() if len(parts) > 3 else None + if site_num and pin: + sites[site_num] = { + "pin": pin, + "nc_user": nc_user or None, + "nc_pass": nc_pass or None, + } + return sites + + +def save_sites(sites: dict): + """Write the sites dict back to sites.conf with the standard header. + + Sorts sites numerically by site number. Only appends :nc_user:nc_pass + when both are present. + + Args: + sites: dict mapping site number → {pin, nc_user, nc_pass} + """ + header = ( + "# APtool Site Configuration\n" + "#\n" + "# Each line defines a site that technicians can log into.\n" + "# Format: site_number:pin[:nc_user:nc_pass]\n" + "#\n" + "# - site_number must be exactly 4 digits\n" + "# - pin can be any string (digits recommended for mobile entry)\n" + "# - nc_user and nc_pass are optional Nextcloud credentials for this site\n" + "# If omitted, the global NC_USER / NC_PASS defaults are used.\n" + "# - blank lines and lines starting with # are ignored\n" + "#\n" + "# Examples:\n" + "# 5001:1234 (uses global Nextcloud credentials)\n" + "# 5002:5678:alice:AppPass-12345 (uses per-site Nextcloud user \"alice\")\n" + "# 9999:0000:bob:AppPass-67890 (uses per-site Nextcloud user \"bob\")\n" + "#\n" + "# To add a site: add a new line with site_number:pin[:nc_user:nc_pass]\n" + "# To remove a site: delete or comment out the line\n" + "# To change a PIN: edit the pin after the colon\n" + "#\n" + "# The app reloads this file on every login attempt, so changes\n" + "# take effect immediately — no restart needed.\n" + "\n" + ) + lines = [] + for site_num in sorted(sites.keys(), key=lambda s: int(s) if s.isdigit() else 0): + cfg = sites[site_num] + line = f"{site_num}:{cfg['pin']}" + if cfg.get("nc_user") and cfg.get("nc_pass"): + line += f":{cfg['nc_user']}:{cfg['nc_pass']}" + lines.append(line) + SITES_FILE.write_text(header + "\n".join(lines) + "\n") + + +def get_nc_creds(site: str) -> tuple[str, str]: + """Return the (nc_user, nc_pass) to use for a given site. + + Checks sites.conf for per-site Nextcloud credentials. If the site has + its own nc_user and nc_pass, those are returned. Otherwise, the global + NC_USER / NC_PASS defaults are used. + + Args: + site: 4-digit site number (e.g. "5001") + + Returns: + (username, password) tuple for Nextcloud authentication + """ + sites = load_sites() + site_cfg = sites.get(site, {}) + user = site_cfg.get("nc_user") or NC_USER + passwd = site_cfg.get("nc_pass") or NC_PASS + return user, passwd + +# --------------------------------------------------------------------------- +# Photo field definitions +# --------------------------------------------------------------------------- +# Each tuple is (form_field_name, filename_suffix). +# For AP number "010", the resulting filenames would be: +# photo_ap → AP010.jpg (close-up of the AP) +# photo_far → AP010F.jpg (distant/wide shot of the AP) +# photo_length → AP010_length.jpg (cable length measurement) +# photo_cont → AP010_cont.jpg (continuity test result) +# photo_rate → AP010_rate.jpg (speed test result) +# photo_box → AP010_box.jpg (box label / packaging) +PHOTO_FIELDS = [ + ("photo_ap", ""), # APxxx + ("photo_far", "F"), # APxxxF + ("photo_length", "_length"),# APxxx_length + ("photo_cont", "_cont"), # APxxx_cont + ("photo_rate", "_rate"), # APxxx_rate + ("photo_box", "_box"), # APxxx_box +] + +# Maps URL-friendly suffix names → file suffixes used in PHOTO_FIELDS. +# Used by the /photo routes to translate e.g. "close" → "" (AP027.jpg). +SUFFIX_MAP = { + "close": "", + "far": "F", + "length": "_length", + "cont": "_cont", + "rate": "_rate", + "box": "_box", +} + +# Human-readable labels for each photo suffix (used in the entries template) +SUFFIX_LABELS = { + "close": "Close-up", + "far": "Far shot", + "length": "Cable length", + "cont": "Continuity", + "rate": "Speed test", + "box": "Box label", +} + + +def find_photo(site: str, ap_number: str, file_suffix: str) -> Path | None: + """Find a photo file regardless of extension. + + Globs for AP{ap_number}{file_suffix}.* in the site's upload directory. + Returns the first match as a Path, or None if no file is found. + + Args: + site: 4-digit site number (e.g. "1102") + ap_number: 3-digit AP number (e.g. "027") + file_suffix: filename suffix (e.g. "" for close-up, "F" for far) + """ + site_dir = UPLOAD_DIR / site + pattern = str(site_dir / f"AP{ap_number}{file_suffix}.*") + matches = glob.glob(pattern) + if matches: + return Path(matches[0]) + return None + + +############################################################################## +# Nextcloud OCS Provisioning API helpers +############################################################################## +# The OCS Provisioning API allows admins to create and manage users: +# https:///ocs/v1.php/cloud/users +# All OCS calls require the header OCS-APIRequest: true and are +# authenticated with the global admin credentials (NC_USER / NC_PASS). +# A successful response has ocs.meta.statuscode == 100. +############################################################################## + +def nc_user_exists(username: str) -> bool: + """Check whether a user account exists on Nextcloud. + + Uses the OCS Provisioning API to query user details. + Authenticated with the global admin credentials. + + Args: + username: The Nextcloud user ID to check (e.g. "alice") + + Returns: + True if the user exists, False otherwise (including on errors). + """ + url = f"{NC_URL.rstrip('/')}/ocs/v1.php/cloud/users/{username}?format=json" + try: + resp = requests.get( + url, + auth=(NC_USER, NC_PASS), + headers={"OCS-APIRequest": "true"}, + timeout=30, + ) + if resp.status_code == 200: + data = resp.json() + return data.get("ocs", {}).get("meta", {}).get("statuscode") == 100 + except Exception: + log.exception("Failed to check if NC user %s exists", username) + return False + + +def nc_create_user(username: str, password: str) -> bool: + """Create a new user account on Nextcloud. + + Uses the OCS Provisioning API. Authenticated with the global admin + credentials (NC_USER / NC_PASS must be a Nextcloud admin account). + + Args: + username: The user ID to create (e.g. "alice") + password: The password for the new account + + Returns: + True on success (statuscode 100), False on failure. + """ + url = f"{NC_URL.rstrip('/')}/ocs/v1.php/cloud/users?format=json" + try: + resp = requests.post( + url, + auth=(NC_USER, NC_PASS), + headers={"OCS-APIRequest": "true"}, + data={"userid": username, "password": password}, + timeout=30, + ) + if resp.status_code == 200: + data = resp.json() + status = data.get("ocs", {}).get("meta", {}).get("statuscode") + if status == 100: + return True + msg = data.get("ocs", {}).get("meta", {}).get("message", "unknown error") + log.error("Failed to create NC user %s: %s (status %s)", username, msg, status) + else: + log.error("Failed to create NC user %s: HTTP %s", username, resp.status_code) + except Exception: + log.exception("Failed to create NC user %s", username) + return False + + +def nc_provision_site_users(): + """Ensure all per-site Nextcloud users from sites.conf exist. + + Iterates over every site in sites.conf that has per-site nc_user/nc_pass. + For each one, checks whether the user already exists on Nextcloud; + if not, creates the account using the OCS Provisioning API. + + Uses the global admin credentials (NC_USER / NC_PASS) for the API calls. + The per-site nc_pass becomes the new user's password. + + Called at app startup. To pick up newly added sites, restart the service. + """ + if not all([NC_URL, NC_USER, NC_PASS]): + log.info("Nextcloud not configured — skipping user provisioning") + return + + sites = load_sites() + for site_num, cfg in sites.items(): + nc_user = cfg.get("nc_user") + nc_pass = cfg.get("nc_pass") + if not nc_user or not nc_pass: + continue # no per-site credentials — uses global, skip + + if nc_user_exists(nc_user): + print(f" Site {site_num}: NC user '{nc_user}' exists ✓") + else: + print(f" Site {site_num}: NC user '{nc_user}' not found — creating...") + if nc_create_user(nc_user, nc_pass): + print(f" Site {site_num}: NC user '{nc_user}' created ✓") + else: + print(f" Site {site_num}: FAILED to create NC user '{nc_user}'") + + +############################################################################## +# Nextcloud WebDAV helpers +############################################################################## +# Nextcloud exposes a WebDAV endpoint at: +# https:///remote.php/dav/files// +# Files are uploaded with PUT, directories created with MKCOL. +# All Nextcloud errors are logged but treated as non-fatal — local saves +# always succeed even if Nextcloud is unreachable. +############################################################################## + +def nc_enabled(nc_user: str, nc_pass: str) -> bool: + """Check whether Nextcloud credentials are configured. + + Returns True only if NC_URL and the given user/pass are all non-empty. + When False, nc_sync() is a no-op. + + Args: + nc_user: Nextcloud username (per-site or global) + nc_pass: Nextcloud password (per-site or global) + """ + return all([NC_URL, nc_user, nc_pass]) + + +def nc_webdav_url(path: str, nc_user: str) -> str: + """Build the full WebDAV URL for a path relative to the user's files root. + + The WebDAV path includes the username, so per-site users get their own + file namespace on the same Nextcloud instance. + + Args: + path: Remote path (e.g. "APtool/5001/uploads/AP010.jpg") + nc_user: Nextcloud username whose files root to use + + Example: + nc_webdav_url("APtool/5001/uploads/AP010.jpg", "alice") + → "https://nextcloud.example.com/remote.php/dav/files/alice/APtool/5001/uploads/AP010.jpg" + """ + base = NC_URL.rstrip("/") + return f"{base}/remote.php/dav/files/{nc_user}/{path.lstrip('/')}" + + +def nc_ensure_folder(folder_path: str, nc_user: str, nc_pass: str): + """Create a folder (and all parent folders) on Nextcloud via MKCOL. + + Walks the path segments one by one, issuing MKCOL for each. + HTTP 201 = created, 405 = already exists — both are acceptable. + + Args: + folder_path: Remote folder path (e.g. "APtool/5001/uploads") + nc_user: Nextcloud username for authentication + nc_pass: Nextcloud password for authentication + + Example: + nc_ensure_folder("APtool/5001/uploads", "alice", "pass") + → MKCOL APtool, then MKCOL APtool/5001, then MKCOL APtool/5001/uploads + """ + parts = folder_path.strip("/").split("/") + current = "" + for part in parts: + current = f"{current}/{part}" if current else part + url = nc_webdav_url(current, nc_user) + resp = requests.request("MKCOL", url, auth=(nc_user, nc_pass), timeout=30) + # 201 = created, 405 = already exists — both are fine + if resp.status_code not in (201, 405): + log.warning("MKCOL %s returned %s", url, resp.status_code) + + +def nc_upload_file(local_path: Path, remote_path: str, nc_user: str, nc_pass: str): + """Upload a single local file to Nextcloud via WebDAV PUT. + + Args: + local_path: Path to the file on disk (e.g. uploads/5001/AP010.jpg) + remote_path: Destination path in Nextcloud (e.g. APtool/5001/uploads/AP010.jpg) + nc_user: Nextcloud username for authentication + nc_pass: Nextcloud password for authentication + + Returns: + True on success (HTTP 200/201/204), False on failure. + """ + url = nc_webdav_url(remote_path, nc_user) + with open(local_path, "rb") as f: + resp = requests.put(url, data=f, auth=(nc_user, nc_pass), timeout=120) + if resp.status_code not in (200, 201, 204): + log.error("Nextcloud upload failed: PUT %s → %s", url, resp.status_code) + return False + return True + + +def nc_sync(site: str, photo_paths: list[Path], excel_path: Path): + """Sync a site's photos and Excel file to Nextcloud. + + Called after every successful form submission. Looks up per-site Nextcloud + credentials from sites.conf (falling back to global NC_USER/NC_PASS), + creates the remote folder structure if needed, uploads all photos into + the site's uploads/ subfolder, and uploads the updated data.xlsx. + + All errors are caught and logged — a Nextcloud failure will not cause + the submission to fail for the technician. + + Args: + site: 4-digit site number (e.g. "5001") + photo_paths: list of local photo file Paths just saved + excel_path: Path to the site's data.xlsx + """ + # Resolve per-site or global Nextcloud credentials + nc_user, nc_pass = get_nc_creds(site) + + if not nc_enabled(nc_user, nc_pass): + return + + try: + # Ensure APtool/{site}/uploads/ exists on Nextcloud + nc_ensure_folder(f"{NC_FOLDER}/{site}/uploads", nc_user, nc_pass) + + # Upload each photo + for local_path in photo_paths: + remote = f"{NC_FOLDER}/{site}/uploads/{local_path.name}" + nc_upload_file(local_path, remote, nc_user, nc_pass) + + # Upload the updated Excel spreadsheet + nc_upload_file(excel_path, f"{NC_FOLDER}/{site}/data.xlsx", nc_user, nc_pass) + except Exception: + log.exception("Nextcloud sync failed for site %s", site) + + +############################################################################## +# Local file helpers +############################################################################## + +def get_site_paths(site: str): + """Return the local (upload_dir, excel_file) paths for a given site. + + Creates the site directory if it doesn't exist. + + Example: + get_site_paths("5001") + → (Path("uploads/5001"), Path("uploads/5001/data.xlsx")) + """ + site_dir = UPLOAD_DIR / site + site_dir.mkdir(parents=True, exist_ok=True) + return site_dir, site_dir / "data.xlsx" + + +def get_or_create_workbook(excel_path: Path): + """Open an existing Excel workbook or create a new one with headers. + + If the file exists, it is opened and the active sheet is returned. + If not, a new workbook is created with a header row: + AP Number | AP Location | Serial Number | Cable Length + + Returns: + (workbook, worksheet) tuple + """ + if excel_path.exists(): + wb = load_workbook(excel_path) + ws = wb.active + else: + wb = Workbook() + ws = wb.active + ws.title = "AP Data" + ws.append(["AP Number", "AP Location", "Serial Number", "MAC Address", "Cable Length"]) + + # ── Header row styling ────────────────────────────────────── + # Make the header row visually distinct so it's easy to spot + # in the spreadsheet. The blue color (#2563EB) matches the + # app's web UI theme for consistency. + # + # Font: bold + white text so it's readable on the blue bg + # Fill: solid blue background (start_color and end_color + # must both be set for a solid fill in openpyxl) + # Alignment: center-align header text for a clean look + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="2563EB", end_color="2563EB", fill_type="solid") + header_alignment = Alignment(horizontal="center") + + # ws[1] gives us all cells in row 1 (the header row). + # We loop through each cell and apply all three styles. + for cell in ws[1]: + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + + # ── Column widths ────────────────────────────────────────── + # By default, Excel columns are ~8 chars wide, which truncates + # most of our data. These widths are tuned to fit typical values + # without needing manual resizing. The unit is roughly "number + # of characters" (Excel's column width unit). + ws.column_dimensions["A"].width = 12 # AP Number (e.g. "001") + ws.column_dimensions["B"].width = 30 # AP Location (e.g. "Conference Room A") + ws.column_dimensions["C"].width = 20 # Serial Number(e.g. "ABCD-1234-EF56") + ws.column_dimensions["D"].width = 22 # MAC Address (e.g. "AA:BB:CC:DD:EE:FF") + ws.column_dimensions["E"].width = 14 # Cable Length (e.g. "25ft") + + # ── Freeze panes ────────────────────────────────────────── + # Freezing at "A2" means everything above row 2 (i.e. the + # header in row 1) stays pinned at the top of the screen + # when you scroll down through many rows of data. + ws.freeze_panes = "A2" + + wb.save(excel_path) + return wb, ws + + +############################################################################## +# Authentication +############################################################################## +# Each site has its own PIN defined in sites.conf. On login, the technician +# enters a site number and its corresponding PIN. The app reads sites.conf +# on every login attempt (no restart needed to add/remove sites). +# +# On successful login, two values are stored in the Flask session cookie: +# session["authenticated"] = True +# session["site"] = "5001" (the 4-digit site number) +# +# The login_required decorator checks both values are present; if not, the +# user is redirected to /login. The session persists until the browser is +# closed or the user clicks "Change site" (which hits /logout). +############################################################################## + +def login_required(f): + """Decorator that redirects to /login if the user is not authenticated + or has not selected a site.""" + from functools import wraps + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("authenticated") or not session.get("site"): + return redirect(url_for("login")) + return f(*args, **kwargs) + return decorated + + +def admin_required(f): + """Decorator that redirects to /admin/login if admin is not authenticated. + + Independent from the technician session — both can coexist. + Checks session["admin_authenticated"]. + """ + from functools import wraps + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("admin_authenticated"): + return redirect(url_for("admin_login")) + return f(*args, **kwargs) + return decorated + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + """Login page — accepts a site number and its PIN. + + GET: renders the login form. + POST: validates the site number and PIN against sites.conf. + - Site must exist in sites.conf. + - PIN must match the site's configured PIN. + On success, sets session values and redirects to the main form. + On failure, re-renders the login page with an error message. + + sites.conf is re-read on every POST so admins can add/remove sites + without restarting the app. + """ + error = None + if request.method == "POST": + pin = request.form.get("pin", "") + site = request.form.get("site", "").strip() + + # Load the current site→pin mappings from sites.conf + sites = load_sites() + + if site not in sites: + error = "Unknown site number" + elif pin != sites[site]["pin"]: + error = "Incorrect PIN" + else: + session["authenticated"] = True + session["site"] = site + return redirect(url_for("index")) + return render_template("login.html", error=error) + + +@app.route("/logout") +def logout(): + """Clear the session (PIN + site) and redirect to login. + + Used by the "Change site" link on the main form page. + """ + session.clear() + return redirect(url_for("login")) + + +############################################################################## +# Main routes +############################################################################## + +@app.route("/") +@login_required +def index(): + """Serve the main AP data collection form. + + Passes the current site number to the template so it can be displayed + as a badge at the top of the page. + """ + return render_template("index.html", site=session["site"]) + + +@app.route("/entries") +@login_required +def entries(): + """Show all submitted AP entries for the current site. + + Reads uploads/{site}/data.xlsx and extracts all data rows (skipping the + header) into a list of dicts for display in cards with photo info. + """ + site = session["site"] + _, excel_path = get_site_paths(site) + + rows = [] + if excel_path.exists(): + wb = load_workbook(excel_path, read_only=True) + ws = wb.active + for i, row in enumerate(ws.iter_rows(values_only=True)): + if i == 0: + continue # skip header + ap_num = row[0] or "" + # Check which photos exist for this AP + photos = [] + if re.fullmatch(r"\d{3}", str(ap_num)): + for suffix_name, file_suffix in SUFFIX_MAP.items(): + photos.append({ + "suffix": suffix_name, + "label": SUFFIX_LABELS[suffix_name], + "exists": find_photo(site, ap_num, file_suffix) is not None, + }) + rows.append({ + "ap_number": ap_num, + "ap_location": row[1] or "", + "serial_number": row[2] or "", + "mac_address": row[3] or "", + "cable_length": row[4] or "", + "photos": photos, + }) + wb.close() + + return render_template("entries.html", site=site, rows=rows) + + +@app.route("/submit", methods=["POST"]) +@login_required +def submit(): + """Handle form submission — save AP data and photos. + + Expects multipart/form-data with: + - ap_number, ap_location, serial_number, cable_length (text fields) + - photo_ap, photo_far, photo_length, photo_cont, photo_rate, photo_box (files) + + Processing steps: + 1. Validate that all text fields are non-empty + 2. Validate that all 6 photos are present + 3. Save each photo to uploads/{site}/ with the structured filename + (e.g. AP010.jpg, AP010F.jpg, AP010_length.jpg, etc.) + 4. Append a row to uploads/{site}/data.xlsx + 5. Sync photos + Excel to Nextcloud (non-blocking on failure) + 6. Return a JSON success/error response to the frontend + """ + # Get the site number from the session + site = session["site"] + site_upload_dir, site_excel = get_site_paths(site) + + # Collect text fields + ap_number = request.form.get("ap_number", "").strip() + ap_location = request.form.get("ap_location", "").strip() + serial_number = request.form.get("serial_number", "").strip() + mac_address = request.form.get("mac_address", "").strip() + cable_length = request.form.get("cable_length", "").strip() + + # Validate text fields — all five are required + if not all([ap_number, ap_location, serial_number, mac_address, cable_length]): + return jsonify({"success": False, "error": "All text fields are required."}), 400 + + # AP Number: exactly 3 digits (e.g. 001, 042, 999) + if not re.fullmatch(r"\d{3}", ap_number): + return jsonify({"success": False, "error": "AP Number must be exactly 3 digits."}), 400 + + # Serial Number: format xxxx-xxxx-xxxx (alphanumeric groups separated by dashes) + if not re.fullmatch(r"[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}", serial_number): + return jsonify({"success": False, "error": "Serial Number must be in format xxxx-xxxx-xxxx."}), 400 + + # MAC Address: 6 hex pairs separated by colons or dashes (e.g. AA:BB:CC:DD:EE:FF) + if not re.fullmatch(r"([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}", mac_address): + return jsonify({"success": False, "error": "MAC Address must be valid (e.g. AA:BB:CC:DD:EE:FF)."}), 400 + + # Validate and save each of the 6 required photos + saved_files = [] # filenames for the JSON response + saved_paths = [] # full Paths for Nextcloud sync + for field_name, suffix in PHOTO_FIELDS: + photo = request.files.get(field_name) + if not photo or photo.filename == "": + return jsonify({ + "success": False, + "error": f"Missing photo: {field_name}", + }), 400 + + # Preserve the original file extension (e.g. .jpg, .png, .heic) + ext = os.path.splitext(photo.filename)[1].lower() or ".jpg" + # Build the structured filename: AP{number}{suffix}.{ext} + filename = f"AP{ap_number}{suffix}{ext}" + save_path = site_upload_dir / filename + photo.save(str(save_path)) + saved_files.append(filename) + saved_paths.append(save_path) + + # Append a row to the site's Excel spreadsheet + wb, ws = get_or_create_workbook(site_excel) + ws.append([ap_number, ap_location, serial_number, mac_address, cable_length]) + + # ── Alternating row colors ("zebra striping") ──────────────── + # Makes the spreadsheet easier to read by giving every other + # row a light blue background (#E8F0FE). The pattern is: + # Row 1 = header (blue, styled above in get_or_create_workbook) + # Row 2 = first data row → even → light blue + # Row 3 = second data row → odd → white (no fill needed) + # Row 4 = third data row → even → light blue + # ...and so on. + # + # ws.max_row gives the row number of the row we just appended. + # We only need to paint even rows; odd rows stay white by default. + row_num = ws.max_row + if row_num % 2 == 0: + fill = PatternFill(start_color="E8F0FE", end_color="E8F0FE", fill_type="solid") + for cell in ws[row_num]: + cell.fill = fill + + wb.save(site_excel) + + # Sync all files to Nextcloud (errors are logged but won't fail the request) + nc_sync(site, saved_paths, site_excel) + + return jsonify({ + "success": True, + "message": f"Site {site} — AP {ap_number} saved successfully.", + "files": saved_files, + }) + + +############################################################################## +# Photo review & replacement routes +############################################################################## + +@app.route("/photo//") +@login_required +def serve_photo(ap_number, suffix): + """Serve a stored photo for the current site. + + URL-friendly suffix names are mapped to file suffixes via SUFFIX_MAP. + The AP number is validated to prevent path traversal. + """ + if suffix not in SUFFIX_MAP: + return jsonify({"error": "Invalid photo type"}), 400 + if not re.fullmatch(r"\d{3}", ap_number): + return jsonify({"error": "Invalid AP number"}), 400 + + site = session["site"] + file_suffix = SUFFIX_MAP[suffix] + photo_path = find_photo(site, ap_number, file_suffix) + + if not photo_path or not photo_path.exists(): + return jsonify({"error": "Photo not found"}), 404 + + return send_file(photo_path) + + +@app.route("/photo///replace", methods=["POST"]) +@login_required +def replace_photo(ap_number, suffix): + """Replace (or upload) a photo for the current site. + + Accepts a multipart 'photo' file field. Deletes the old file first + (handles extension changes), saves the new file, and syncs to Nextcloud. + """ + if suffix not in SUFFIX_MAP: + return jsonify({"success": False, "error": "Invalid photo type"}), 400 + if not re.fullmatch(r"\d{3}", ap_number): + return jsonify({"success": False, "error": "Invalid AP number"}), 400 + + photo = request.files.get("photo") + if not photo or photo.filename == "": + return jsonify({"success": False, "error": "No file provided"}), 400 + + site = session["site"] + file_suffix = SUFFIX_MAP[suffix] + site_dir, site_excel = get_site_paths(site) + + # Delete old file (may have different extension) + old_path = find_photo(site, ap_number, file_suffix) + if old_path and old_path.exists(): + old_path.unlink() + + # Save new file + ext = os.path.splitext(photo.filename)[1].lower() or ".jpg" + filename = f"AP{ap_number}{file_suffix}{ext}" + save_path = site_dir / filename + photo.save(str(save_path)) + + # Sync to Nextcloud + nc_sync(site, [save_path], site_excel) + + return jsonify({"success": True, "message": f"Photo replaced: {filename}"}) + + +############################################################################## +# Admin routes +############################################################################## +# The admin interface allows managing sites.conf from the browser. +# Admin authenticates with the global Nextcloud admin credentials +# (NC_USER / NC_PASS). The session flag "admin_authenticated" is independent +# from the technician "authenticated" flag — both can coexist. +############################################################################## + +@app.route("/admin/login", methods=["GET", "POST"]) +def admin_login(): + """Admin login page — validates against NC_USER / NC_PASS. + + GET: renders the admin login form. + POST: checks username/password against the global NC_USER and NC_PASS. + On success, sets session["admin_authenticated"] and redirects to /admin. + On failure, re-renders with an error message. + """ + error = None + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if username == NC_USER and password == NC_PASS: + session["admin_authenticated"] = True + return redirect(url_for("admin_dashboard")) + else: + error = "Invalid admin credentials" + return render_template("admin_login.html", error=error) + + +@app.route("/admin/logout") +def admin_logout(): + """Clear admin session flag (preserves technician session).""" + session.pop("admin_authenticated", None) + return redirect(url_for("admin_login")) + + +@app.route("/admin") +@admin_required +def admin_dashboard(): + """Admin dashboard — lists all sites with NC user status. + + Loads sites from sites.conf, checks Nextcloud user existence for + each site that has per-site credentials, and renders admin.html. + """ + sites = load_sites() + site_list = [] + for site_num in sorted(sites.keys(), key=lambda s: int(s) if s.isdigit() else 0): + cfg = sites[site_num] + entry = { + "id": site_num, + "pin": cfg["pin"], + "nc_user": cfg.get("nc_user"), + "nc_pass": cfg.get("nc_pass"), + } + # Check NC user status if per-site credentials are set + if cfg.get("nc_user"): + entry["nc_exists"] = nc_user_exists(cfg["nc_user"]) + else: + entry["nc_exists"] = None # uses global — no status to check + site_list.append(entry) + return render_template("admin.html", sites=site_list) + + +@app.route("/admin/api/sites", methods=["POST"]) +@admin_required +def admin_add_site(): + """Add a new site to sites.conf. + + JSON body: {site, pin, nc_user?, nc_pass?} + Site must be exactly 4 digits and must not already exist. + """ + data = request.get_json(force=True) + site = data.get("site", "").strip() + pin = data.get("pin", "").strip() + nc_user = data.get("nc_user", "").strip() or None + nc_pass = data.get("nc_pass", "").strip() or None + + if not re.fullmatch(r"\d{4}", site): + return jsonify({"ok": False, "error": "Site must be exactly 4 digits"}), 400 + + if not pin: + return jsonify({"ok": False, "error": "PIN is required"}), 400 + + sites = load_sites() + if site in sites: + return jsonify({"ok": False, "error": f"Site {site} already exists"}), 409 + + sites[site] = {"pin": pin, "nc_user": nc_user, "nc_pass": nc_pass} + save_sites(sites) + return jsonify({"ok": True}) + + +@app.route("/admin/api/sites/", methods=["PUT"]) +@admin_required +def admin_edit_site(site_id): + """Edit an existing site in sites.conf. + + JSON body: {pin?, nc_user?, nc_pass?} + Only provided fields are updated. + """ + sites = load_sites() + if site_id not in sites: + return jsonify({"ok": False, "error": f"Site {site_id} not found"}), 404 + + data = request.get_json(force=True) + if "pin" in data and data["pin"].strip(): + sites[site_id]["pin"] = data["pin"].strip() + if "nc_user" in data: + sites[site_id]["nc_user"] = data["nc_user"].strip() or None + if "nc_pass" in data: + sites[site_id]["nc_pass"] = data["nc_pass"].strip() or None + + save_sites(sites) + return jsonify({"ok": True}) + + +@app.route("/admin/api/sites/", methods=["DELETE"]) +@admin_required +def admin_delete_site(site_id): + """Delete a site from sites.conf (preserves uploaded files).""" + sites = load_sites() + if site_id not in sites: + return jsonify({"ok": False, "error": f"Site {site_id} not found"}), 404 + + del sites[site_id] + save_sites(sites) + return jsonify({"ok": True}) + + +@app.route("/admin/api/sites//provision", methods=["POST"]) +@admin_required +def admin_provision_site(site_id): + """Create the per-site Nextcloud user via OCS Provisioning API. + + Only works if the site has nc_user and nc_pass set. + """ + sites = load_sites() + if site_id not in sites: + return jsonify({"ok": False, "error": f"Site {site_id} not found"}), 404 + + cfg = sites[site_id] + nc_user = cfg.get("nc_user") + nc_pass_val = cfg.get("nc_pass") + + if not nc_user or not nc_pass_val: + return jsonify({"ok": False, "error": "Site has no per-site NC credentials"}), 400 + + if nc_user_exists(nc_user): + return jsonify({"ok": True, "message": f"User '{nc_user}' already exists"}) + + if nc_create_user(nc_user, nc_pass_val): + return jsonify({"ok": True, "message": f"User '{nc_user}' created successfully"}) + else: + return jsonify({"ok": False, "error": f"Failed to create user '{nc_user}'"}), 500 + + +############################################################################## +# Entry point +############################################################################## +# When run directly (python3 app.py), starts the Flask development server +# on all interfaces (0.0.0.0) port 5000 with debug mode enabled. +# In production, this is managed by the aptool.service systemd unit. +############################################################################## + +# --------------------------------------------------------------------------- +# Startup provisioning — runs on import (works with both gunicorn and dev server) +# --------------------------------------------------------------------------- +if nc_enabled(NC_USER, NC_PASS): + print(f"Nextcloud sync enabled → {NC_URL} (default user: {NC_USER}, folder: {NC_FOLDER})") + nc_provision_site_users() +else: + print("Nextcloud sync disabled (set NC_URL, NC_USER, NC_PASS to enable)") + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b0fec39 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + aptool: + build: . + ports: + - "5000:5000" + volumes: + - ./uploads:/app/uploads + - ./sites.conf:/app/sites.conf + env_file: + - .env + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0d65e71 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask +gunicorn +openpyxl +requests diff --git a/sites.conf b/sites.conf new file mode 100644 index 0000000..3be453f --- /dev/null +++ b/sites.conf @@ -0,0 +1,25 @@ +# APtool Site Configuration +# +# Each line defines a site that technicians can log into. +# Format: site_number:pin[:nc_user:nc_pass] +# +# - site_number must be exactly 4 digits +# - pin can be any string (digits recommended for mobile entry) +# - nc_user and nc_pass are optional Nextcloud credentials for this site +# If omitted, the global NC_USER / NC_PASS defaults are used. +# - blank lines and lines starting with # are ignored +# +# Examples: +# 5001:1234 (uses global Nextcloud credentials) +# 5002:5678:alice:AppPass-12345 (uses per-site Nextcloud user "alice") +# 9999:0000:bob:AppPass-67890 (uses per-site Nextcloud user "bob") +# +# To add a site: add a new line with site_number:pin[:nc_user:nc_pass] +# To remove a site: delete or comment out the line +# To change a PIN: edit the pin after the colon +# +# The app reloads this file on every login attempt, so changes +# take effect immediately — no restart needed. + +1234:1234:1234:railFocus11 +2725:2725:2725:makeBiscuits diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..1a67212 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,500 @@ + + + + + + APtool Admin + + + +
+ +
JCP Wifi Migration 2026
+ + +
+

APtool

+ Admin + Log out +
+ + +
+ + +
+

Add Site

+
+ + +
+
+ + +
+ +
+ + + {% for site in sites %} +
+

Site {{ site.id }}

+
PIN: {{ site.pin }}
+
+ NC User: + {% if site.nc_user %} + {{ site.nc_user }} + {% if site.nc_exists == true %} + Exists + {% elif site.nc_exists == false %} + Not found + {% endif %} + {% else %} + Global + {% endif %} +
+
+ {% if site.nc_user and site.nc_exists == false %} + + {% endif %} + + +
+
+ {% endfor %} + + {% if not sites %} +
+ No sites configured. Add one above. +
+ {% endif %} + + +
+ + + + + +
© 2026 Mack Allison, sdAnywhere LLC (with Claude Code)
+ + diff --git a/templates/admin_login.html b/templates/admin_login.html new file mode 100644 index 0000000..fb3f5cd --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,132 @@ + + + + + + APtool Admin - Login + + + + + +
© 2026 Mack Allison, sdAnywhere LLC (with Claude Code)
+ + diff --git a/templates/entries.html b/templates/entries.html new file mode 100644 index 0000000..4bb725a --- /dev/null +++ b/templates/entries.html @@ -0,0 +1,388 @@ + + + + + + APtool - Submitted APs + + + +
+
JCP Wifi Migration 2026
+

APtool

+
+ Site {{ site }} + Back to form +
+ +
{{ rows|length }} AP{{ 's' if rows|length != 1 else '' }} submitted
+ + {% if rows %} + {% for row in rows %} +
+
+
+
AP {{ row.ap_number }}
+
{{ row.ap_location }}
+
+
+
+
Serial
+
{{ row.serial_number }}
+
MAC
+
{{ row.mac_address }}
+
Cable
+
{{ row.cable_length }}
+
+ + {% if row.photos %} + +
+ {% for p in row.photos %} +
+
{{ p.label }}
+
+ {% if p.exists %} + {{ p.label }} + {% else %} + No photo + {% endif %} +
+
+ {% if p.exists %} + + {% else %} + + {% endif %} +
+
+ {% endfor %} +
+ {% endif %} +
+ {% endfor %} + {% else %} +
No APs submitted yet for this site.
+ {% endif %} + + +
+ + + + + +
+ + + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..42edb55 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,681 @@ + + + + + + + + APtool - AP Data Collection + + + + + +
JCP Wifi Migration 2026
+

APtool

+ +
+ + Site {{ site }} + + Change site +
+ + + +
+ + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+

Photos

+ + +
+ + + +
+ + +
+
+ Preview +
+ + +
+ + + +
+ + +
+
+ Preview +
+ + +
+ + + +
+ + +
+
+ Preview +
+ + +
+ + + +
+ + +
+
+ Preview +
+ + +
+ + + +
+ + +
+
+ Preview +
+ + +
+ + + +
+ + +
+
+ Preview +
+
+ + + +
+ + +
+
+ +
+ Admin +
© 2026 Mack Allison, sdAnywhere LLC (with Claude Code)
+
+ + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..dff6d7a --- /dev/null +++ b/templates/login.html @@ -0,0 +1,209 @@ + + + + + + + + APtool - Login + + + + + + + +
© 2026 Mack Allison, sdAnywhere LLC (with Claude Code)
+ +