diff --git a/README.md b/README.md index 2909cf0..b5ecf09 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,11 @@ badge holders to real names, and displays a unified live attendance table with first/latest badge times, source controller, and ON TIME / LATE status. **Multi-controller:** add as many UniFi Access controllers as the host can reach. -Webhooks are auto-registered when you add a controller from the UI. (05/28/26) +Webhooks are auto-registered when you add a controller from the UI. + +**Tenant filtering:** hide building tenants (or any non-staff actor) from the +attendance table with one click β€” events are still recorded so unfiltering +restores their full history. (05/28/26) --- @@ -169,8 +173,10 @@ Per-controller actions in the modal: | **Date picker** | Choose which day to view | | **Badged in by** | Set your on-time cutoff (HH:MM) | | **Controller** | Filter the table to one controller, or show All | +| **Show filtered** | Include filtered tenants in the table (dimmed and tagged) | | **Refresh** | Reload the table | | **Sync Users** | Pull latest users from every enabled controller | +| **🚫 Filtered** | Open the filtered-tenants modal to review and unhide | | **βš™ Controllers** | Add / manage controllers | | **Reset Day** | Delete all badge records for the selected date (respects the Controller filter β€” testing only) | @@ -185,6 +191,7 @@ Per-controller actions in the modal: | **Latest Badge In** | Most recent entry β€” shows *"β€” same"* if only one badge event | | **Actor ID** | First 8 characters of the UniFi user UUID | | **Status** | ON TIME (green) or LATE (red) based on first badge vs cutoff | +| **Actions** | **Hide** filters this tenant out of future views; **Unhide** restores them | > The same physical person on two different controllers will appear as two rows > (different controllers issue different user UUIDs). They're distinguishable @@ -192,6 +199,25 @@ Per-controller actions in the modal: --- +## Filtering tenants + +Use this when an actor (typically a building tenant, vendor, or contractor) +badges into the same doors as your staff but you don't want them counted on +the attendance table. + +- Click **Hide** on any row to filter that actor out. They're removed from the + table immediately and stay hidden on future days. +- Toggle **Show filtered** in the controls bar to see them again β€” filtered + rows render dimmed with a FILTERED tag and an **Unhide** action. +- Click the **🚫 Filtered** button in the header for a bulk-management view + across all controllers, with one-click unhide per actor. + +The filter is per `(controller, actor)`, so the same person on two controllers +must be hidden on each one. Badge events are still recorded while an actor is +filtered β€” unhiding restores their full history with no gaps. + +--- + ## Updating from GitHub ```bash @@ -217,7 +243,9 @@ reverse proxy with auth in front of it. |---|---|---|---| | `POST` | `/api/unifi-access/` | webhook body | Receives UniFi Access webhook for that controller | | `POST` | `/api/unifi-access` | webhook body | Legacy alias β€” routes to the oldest controller | -| `GET` | `/api/first-badge-status` | `date`, `cutoff`, `controller_id?` | Returns first + latest badge per user | +| `GET` | `/api/first-badge-status` | `date`, `cutoff`, `controller_id?`, `include_filtered?` | Returns first + latest badge per user (filtered tenants hidden unless `include_filtered=1`) | +| `GET` | `/api/users` | `controller_id?`, `filtered?` | List cached actors with their filtered flag | +| `PATCH` | `/api/users//` | `filtered` (bool) | Hide / unhide an actor from the attendance table | | `GET` | `/api/controllers` | β€” | List configured controllers | | `POST` | `/api/controllers` | `name`, `host`, `port`, `api_token` | Add a controller (also registers webhook) | | `PATCH` | `/api/controllers/` | `name?`, `enabled?` | Rename or enable/disable a controller | @@ -241,6 +269,8 @@ reverse proxy with auth in front of it. | Webhook URL stored in controller points to the wrong address | Browser's origin isn't reachable from the controller | Set `DASHBOARD_BASE_URL` in `.env`, remove + re-add the controller | | `Port 12445 connection refused` | Firewall blocking port | Add LAN IN firewall rule in UniFi Network (Step 1) | | Dashboard shows stale names after a user rename | Cache not refreshed | Click **Sync Users** or wait for the hourly auto-sync | +| A tenant I hid is still showing up | Same person exists on a second controller | Hide them on each controller β€” the filter is per `(controller, actor)` | +| Filtered tenant doesn't appear when I tick "Show filtered" | They've never badged in on the selected date | Open the **🚫 Filtered** modal to confirm they're filtered | --- diff --git a/app.py b/app.py index 22b5158..f13532d 100644 --- a/app.py +++ b/app.py @@ -93,11 +93,17 @@ def init_db(): actor_id TEXT NOT NULL, full_name TEXT NOT NULL, updated_at TEXT NOT NULL, + filtered INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (controller_id, actor_id) ) """ ) + if not _column_exists(db, "user_cache", "filtered"): + db.execute( + "ALTER TABLE user_cache ADD COLUMN filtered INTEGER NOT NULL DEFAULT 0" + ) + # Seed a Default controller from env vars when the table is empty. existing = db.execute("SELECT COUNT(*) AS n FROM controllers").fetchone()["n"] default_id = None @@ -299,6 +305,7 @@ def first_badge_status(): date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d")) cutoff = request.args.get("cutoff", "09:00") controller_filter = request.args.get("controller_id", "").strip() or None + include_filtered = request.args.get("include_filtered") == "1" if not re.match(r"^\d{2}:\d{2}$", cutoff): cutoff = "09:00" @@ -314,7 +321,8 @@ def first_badge_status(): COALESCE( u.full_name, 'Unknown (' || SUBSTR(b.actor_id,1,8) || '...)' - ) AS name + ) AS name, + COALESCE(u.filtered, 0) AS filtered FROM badge_events b LEFT JOIN user_cache u ON u.actor_id = b.actor_id AND u.controller_id = b.controller_id @@ -325,6 +333,8 @@ def first_badge_status(): if controller_filter: sql += " AND b.controller_id = ?" params.append(controller_filter) + if not include_filtered: + sql += " AND COALESCE(u.filtered, 0) = 0" sql += " GROUP BY b.actor_id, b.controller_id ORDER BY first_ts ASC" with get_db() as db: @@ -335,12 +345,14 @@ def first_badge_status(): first = r["first_ts"] latest = r["latest_ts"] result.append({ - "actor_id": r["actor_id"], - "name": r["name"], - "source": r["source"] or "β€”", - "first_ts": first, - "latest_ts": latest if latest != first else None, - "status": "ON TIME" if first <= cutoff_end else "LATE", + "actor_id": r["actor_id"], + "controller_id": r["controller_id"], + "name": r["name"], + "source": r["source"] or "β€”", + "first_ts": first, + "latest_ts": latest if latest != first else None, + "status": "ON TIME" if first <= cutoff_end else "LATE", + "filtered": bool(r["filtered"]), }) return jsonify(result) @@ -575,6 +587,97 @@ def sync_one(controller_id): return jsonify({"status": "ok", "synced": n}) +# --------------------------------------------------------------------------- +# User cache (tenant filtering) +# --------------------------------------------------------------------------- +@app.route("/api/users", methods=["GET"]) +def list_users(): + controller_id = request.args.get("controller_id", "").strip() or None + filtered_arg = request.args.get("filtered") + + sql = """ + SELECT + u.controller_id, + u.actor_id, + u.full_name, + u.updated_at, + COALESCE(u.filtered, 0) AS filtered, + c.name AS controller_name + FROM user_cache u + LEFT JOIN controllers c ON c.id = u.controller_id + WHERE 1=1 + """ + params = [] + if controller_id: + sql += " AND u.controller_id = ?" + params.append(controller_id) + if filtered_arg in ("0", "1"): + sql += " AND COALESCE(u.filtered, 0) = ?" + params.append(int(filtered_arg)) + sql += " ORDER BY c.name, u.full_name" + + with get_db() as db: + rows = db.execute(sql, params).fetchall() + return jsonify([ + { + "controller_id": r["controller_id"], + "controller_name": r["controller_name"] or "β€”", + "actor_id": r["actor_id"], + "full_name": r["full_name"], + "updated_at": r["updated_at"], + "filtered": bool(r["filtered"]), + } + for r in rows + ]) + + +@app.route("/api/users//", methods=["PATCH"]) +def update_user(controller_id, actor_id): + body = request.get_json(silent=True) or {} + if "filtered" not in body: + return jsonify({"error": "filtered field required"}), 400 + filtered = 1 if body["filtered"] else 0 + + with get_db() as db: + ctrl = db.execute( + "SELECT id FROM controllers WHERE id = ?", (controller_id,) + ).fetchone() + if not ctrl: + return jsonify({"error": "unknown controller"}), 404 + + now_iso = datetime.now(timezone.utc).isoformat() + placeholder = f"User {actor_id[:8]}" + db.execute( + """ + INSERT INTO user_cache (controller_id, actor_id, full_name, updated_at, filtered) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(controller_id, actor_id) DO UPDATE SET + filtered = excluded.filtered + """, + (controller_id, actor_id, placeholder, now_iso, filtered), + ) + db.commit() + row = db.execute( + """ + SELECT u.controller_id, u.actor_id, u.full_name, u.updated_at, + COALESCE(u.filtered, 0) AS filtered, c.name AS controller_name + FROM user_cache u + LEFT JOIN controllers c ON c.id = u.controller_id + WHERE u.controller_id = ? AND u.actor_id = ? + """, + (controller_id, actor_id), + ).fetchone() + + return jsonify({ + "controller_id": row["controller_id"], + "controller_name": row["controller_name"] or "β€”", + "actor_id": row["actor_id"], + "full_name": row["full_name"], + "updated_at": row["updated_at"], + "filtered": bool(row["filtered"]), + }) + + # --------------------------------------------------------------------------- # Misc admin # --------------------------------------------------------------------------- diff --git a/static/index.html b/static/index.html index 4cf242b..017d43a 100644 --- a/static/index.html +++ b/static/index.html @@ -96,6 +96,32 @@ .empty-state { padding: 28px 16px; text-align: center; color: var(--muted); font-size: 0.9rem; } .empty-state span { color: var(--gold-soft); } + tr.row-filtered td { opacity: 0.45; } + tr.row-filtered .name-cell::after { + content: 'FILTERED'; margin-left: 8px; font-size: 0.65rem; letter-spacing: 0.1em; + color: var(--warn); border: 1px solid rgba(243,156,18,0.5); + padding: 1px 6px; border-radius: 999px; vertical-align: middle; + } + .row-action-btn { + padding: 4px 10px; font-size: 0.7rem; letter-spacing: 0.08em; + border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); + background: rgba(255,255,255,0.04); color: var(--text); cursor: pointer; + text-transform: uppercase; + } + .row-action-btn:hover { border-color: rgba(212,175,55,0.6); } + .row-action-btn.unhide { border-color: rgba(46,204,113,0.6); color: #c9f7dc; } + + .show-filtered-toggle { + display: inline-flex; align-items: center; gap: 6px; + font-size: 0.78rem; color: var(--muted); + letter-spacing: 0.08em; text-transform: uppercase; + } + .show-filtered-toggle input { min-width: 0; width: auto; margin: 0; } + .filtered-btn { + border-color: rgba(243,156,18,0.6); + background: radial-gradient(circle at top, rgba(243,156,18,0.18), rgba(2,2,4,0.95)); + } + .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 100; align-items: center; justify-content: center; } .modal-overlay.open { display: flex; } .modal { background: var(--bg-card); border: 1px solid rgba(212,175,55,0.3); border-radius: 16px; padding: 24px; max-width: 720px; width: 92%; box-shadow: 0 24px 60px rgba(0,0,0,0.8); max-height: 90vh; overflow-y: auto; } @@ -144,7 +170,8 @@ header { flex-direction: column; align-items: flex-start; } .controls { flex-direction: column; align-items: stretch; } input, select, button { width: 100%; } - th:nth-child(5), td:nth-child(5) { display: none; } + th:nth-child(5), td:nth-child(5), + th:nth-child(6), td:nth-child(6) { display: none; } .form-grid { grid-template-columns: 1fr; } .ctrl-row { grid-template-columns: 1fr; } .ctrl-actions { justify-content: flex-start; } @@ -174,10 +201,16 @@ +
+ +
+
@@ -200,10 +233,11 @@ Latest Badge In Actor ID Status + Actions - No data yet. Badge into a door and press Refresh. + No data yet. Badge into a door and press Refresh. @@ -265,6 +299,23 @@ + + +