Jul 10 - Update codes to catch up with the single tenant project
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Audience:** AI assistants and developers working on this codebase.
|
||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||||
> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; MT-9 iOS pending**)
|
||||
> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); MT-9 iOS pending**)
|
||||
|
||||
---
|
||||
|
||||
@@ -333,9 +333,15 @@ issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity
|
||||
mobile_local_id VARCHAR(64) nullable indexed, ← Phase B
|
||||
vendor_name VARCHAR(100) nullable, ← Phase 26
|
||||
vendor_contact VARCHAR(200) nullable, ← Phase 26
|
||||
vendor_notes TEXT nullable ← Phase 26
|
||||
vendor_notes TEXT nullable, ← Phase 26
|
||||
handler_type ENUM('internal','facility','vendor') nullable, ← phase39
|
||||
facility_handler_name VARCHAR(100) nullable, ← phase39
|
||||
facility_handler_contact VARCHAR(200) nullable, ← phase39
|
||||
facility_handler_notes TEXT nullable ← phase39
|
||||
```
|
||||
|
||||
**`handler_type` (phase39):** Who is responsible for resolving the issue. `NULL` and `'internal'` both mean janitorial staff (the default). `'facility'` activates the `facility_handler_*` sub-fields (contact at the building). `'vendor'` indicates an external contractor and cross-references the existing `vendor_*` fields and work orders. Editable by admin/director/project_manager on the issue update form. Dashboard shows a three-card handler breakdown for staff roles. Issues list accepts `?handler_type=` filter. `Issue.HANDLER_LABELS` maps enum values to display names.
|
||||
|
||||
**Photo columns — three distinct fields with different semantics:**
|
||||
|
||||
| Column | Type | Populated by | Displayed as |
|
||||
@@ -818,7 +824,18 @@ limiter = Limiter(
|
||||
→ phase35_user_mfa
|
||||
→ phase36_issue_work_orders
|
||||
→ phase37_contract_recipients
|
||||
→ phase38_facility_qr ← HEAD
|
||||
→ phase38_facility_qr
|
||||
→ phase39_issue_handler_type ← HEAD
|
||||
```
|
||||
|
||||
### phase39_issue_handler_type
|
||||
|
||||
Adds four nullable columns to `issues`: `handler_type ENUM('internal','facility','vendor')`, `facility_handler_name VARCHAR(100)`, `facility_handler_contact VARCHAR(200)`, `facility_handler_notes TEXT`. Also enables the `POST /f/<token>/report` occupant issue reporting endpoint on the `facility_qr` blueprint (no schema change needed — reuses the `issues` table). Guarded with `INFORMATION_SCHEMA` existence checks — safe to re-run.
|
||||
|
||||
**Deploy order:**
|
||||
```bash
|
||||
flask db upgrade
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
### phase38_facility_qr
|
||||
@@ -1350,6 +1367,9 @@ set -a; . /etc/jqc/control.env; set +a
|
||||
| 88 | **Password strength enforced by one shared `strong_password()` validator** | Lives in `app/utils/forms.py`: ≥8 chars, at least one letter AND one digit, and not in a small common-password blocklist. Applied to every password-setting form — `ProfileForm`, `UserForm`, `CustomerForm`, `ResetPasswordForm`, `SetPasswordForm`, and `signup.SignupForm` (imports it). Sits after `Optional()` on edit forms (skips blank = "leave unchanged"). Do not re-introduce ad-hoc `Length(min=6)` password rules — route new password fields through `strong_password()` so the policy stays consistent. |
|
||||
| 90 | **Per-contract recipients dispatch INSIDE `notify_by_matrix()` — never call `_notify_project_recipients()` from routes** | phase37. Contract-scoped recipients (`ProjectNotificationRecipient`) are dispatched automatically at the end of `notify_by_matrix()`, after matrix roles + global custom emails, with dedup against both. The contract is resolved from `facility_id` arg → `issue.resolved_facility` → `inspection.facility_id`; events fired without any facility context reach matrix recipients only. New `notify_by_matrix()` call sites should pass `facility_id` (or `issue_id`/`inspection_id`) so contract recipients fire. The `score_alert` cron call in `sla.py` now passes `facility_id=fid` for this reason (side effect: if the matrix ever enables `customer` for `score_alert`, customers are facility-scoped instead of org-wide — a strict improvement). Staff recipients use `respect_preferences=False` (contract config is the authority, same as matrix broadcasts). |
|
||||
| 91 | **Facility QR scan page is public and token-authorized — counts + scores ONLY** | phase38, same authorization class as rule 89: `GET /f/<token>` has NO `@login_required`; the unguessable `facilities.qr_token` is the sole credential, because QR posters hang in public hallways. The page must NEVER render free-text issue descriptions, inspector/staff names, photos, or comments — only aggregate counts, scores, dates, template names, and severity/SLA counts. Rate-limited `60/hr`. Inactive facilities 404. The hybrid full-view button appears only when `current_user` is authenticated AND their role scope covers the facility (`_can_view_full()` — staff always; inspector/customer via scope utils); the internal page re-enforces scope anyway. QR URLs are built from `request.host_url` (rule 64 pattern) so each tenant's posters carry its own domain — the route resolves by Host and is NOT tenant-exempt. `qr_svg()` lives in `app/utils/qr.py` (general-purpose; the TOTP-specific `mfa.qr_svg()` mirrors stay untouched). Rotate a leaked poster with `POST /facilities/<id>/qr/regenerate`. |
|
||||
| 92 | **`POST /f/<token>/report` creates issues with `reported_by=None` — honeypot protects it** | phase39. The occupant report endpoint shares the same authorization model as rule 91 (token = credential, no login). The honeypot field (`name="website"`, CSS-hidden, `position:absolute;left:-9999px`) silently drops bot submissions by redirecting to the success URL without creating an issue. Rate-limited `5/hr` per IP. The notification fires `notify_by_matrix('issue_created', issue_id=..., facility_id=...)` so admins are notified via the standard matrix. Do not add login gates, photo upload, or internal fields (assignee, comments) to this form — it is intentionally minimal. |
|
||||
| 93 | **ProxyFix must wrap `app.wsgi_app` — without it, rate limiting and fail2ban are broken** | `app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)` reads `X-Forwarded-For` set by Nginx. Without it, `get_remote_address()` returns `127.0.0.1` for every request — Flask-Limiter shares one counter across all users and fail2ban can never ban an attacker's real IP. Always set in `create_app()` immediately after `app = Flask(__name__)`. |
|
||||
| 94 | **`handler_type` NULL and `'internal'` are equivalent** | NULL means the column was not set (pre-phase39 row or unmodified new row); the application treats both as "Janitorial Staff". The dashboard `handler_breakdown['internal']` counter and the `?handler_type=internal` issues-list filter both use `db.or_(Issue.handler_type == 'internal', Issue.handler_type.is_(None))`. Never coerce NULL to 'internal' at the DB layer — the nullable default is intentional for backwards compatibility. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ def create_app(config_name='default'):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees
|
||||
# the real client IP (needed for rate limiting and fail2ban logging) and
|
||||
# the real scheme (needed for HTTPS URL generation in emails).
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
||||
@@ -84,6 +84,20 @@ class Issue(db.Model):
|
||||
vendor_contact = db.Column(db.String(200), nullable=True) # phone or email
|
||||
vendor_notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Handler type — who is responsible for resolving the issue (phase39).
|
||||
# NULL and 'internal' both mean janitorial staff (the default); 'facility'
|
||||
# unlocks the facility_handler_* sub-fields; 'vendor' points to vendor_*.
|
||||
handler_type = db.Column(db.Enum('internal', 'facility', 'vendor'), nullable=True)
|
||||
facility_handler_name = db.Column(db.String(100), nullable=True)
|
||||
facility_handler_contact = db.Column(db.String(200), nullable=True)
|
||||
facility_handler_notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
HANDLER_LABELS = {
|
||||
'internal': 'Janitorial Staff',
|
||||
'facility': 'Facility Staff',
|
||||
'vendor': 'External Vendor',
|
||||
}
|
||||
|
||||
# Relationships
|
||||
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
|
||||
# Do NOT add a second explicit db.relationship('Area') here — it conflicts
|
||||
|
||||
@@ -95,6 +95,12 @@ def index():
|
||||
'medium': sum(1 for i in open_issues_all if i.severity == 'medium'),
|
||||
'low': sum(1 for i in open_issues_all if i.severity == 'low'),
|
||||
}
|
||||
# Handler breakdown (phase39) — NULL and 'internal' both mean janitorial staff
|
||||
handler_breakdown = {
|
||||
'internal': sum(1 for i in open_issues_all if not i.handler_type or i.handler_type == 'internal'),
|
||||
'facility': sum(1 for i in open_issues_all if i.handler_type == 'facility'),
|
||||
'vendor': sum(1 for i in open_issues_all if i.handler_type == 'vendor'),
|
||||
}
|
||||
|
||||
# ── Issues resolved today ─────────────────────────────────────────────────
|
||||
resolved_today_q = Issue.query.filter(
|
||||
@@ -343,6 +349,7 @@ def index():
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
severity_breakdown = severity_breakdown,
|
||||
handler_breakdown = handler_breakdown,
|
||||
resolved_today = resolved_today,
|
||||
pending_followups = pending_followups,
|
||||
issues_opened_today = issues_opened_today,
|
||||
|
||||
@@ -25,7 +25,7 @@ In multi-tenant mode the printed URL is built from the tenant's own domain
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Blueprint, render_template, abort
|
||||
from flask import Blueprint, render_template, redirect, request, url_for, abort
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
@@ -135,6 +135,7 @@ def scan(token):
|
||||
|
||||
return render_template(
|
||||
'facility_qr/view.html',
|
||||
token = token,
|
||||
facility = facility,
|
||||
contract = facility.project,
|
||||
total_90 = total_90,
|
||||
@@ -154,3 +155,52 @@ def scan(token):
|
||||
can_view_full = _can_view_full(facility),
|
||||
generated_at = now,
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<token>/report', methods=['POST'])
|
||||
@limiter.limit('5 per hour')
|
||||
def report(token):
|
||||
"""Public occupant issue report submitted from the QR scan page.
|
||||
|
||||
No login required — the unguessable QR token is the sole authorization.
|
||||
A honeypot field silently rejects bot submissions. Creates an Issue with
|
||||
reported_by=None so staff know it came from a public form.
|
||||
"""
|
||||
facility = Facility.query.filter_by(qr_token=token).first()
|
||||
if facility is None or not facility.active:
|
||||
abort(404)
|
||||
|
||||
# Honeypot — bots fill this field, humans leave it blank
|
||||
if request.form.get('website', '').strip():
|
||||
logger.warning('FACILITY QR REPORT | honeypot triggered | facility_id=%s', facility.id)
|
||||
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
|
||||
|
||||
description = request.form.get('description', '').strip()
|
||||
severity = request.form.get('severity', 'medium')
|
||||
|
||||
if not description:
|
||||
return redirect(url_for('facility_qr.scan', token=token))
|
||||
if severity not in ('low', 'medium', 'high'):
|
||||
severity = 'medium'
|
||||
|
||||
issue = Issue(
|
||||
facility_id = facility.id,
|
||||
severity = severity,
|
||||
description = description,
|
||||
status = 'open',
|
||||
reported_by = None, # anonymous public submission
|
||||
)
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s',
|
||||
facility.id, issue.id, severity)
|
||||
|
||||
# Notify staff via the notification matrix (same event as Issues → Create)
|
||||
try:
|
||||
from app.utils.notifications import notify_by_matrix
|
||||
notify_by_matrix('issue_created', issue_id=issue.id, facility_id=facility.id)
|
||||
except Exception as exc:
|
||||
logger.error('FACILITY QR REPORT | notify_failed | err=%s', exc)
|
||||
|
||||
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
|
||||
|
||||
+36
-9
@@ -235,15 +235,17 @@ def index():
|
||||
)
|
||||
)
|
||||
|
||||
issue_id_filter = request.args.get('issue_id', '').strip()
|
||||
severity_filter = request.args.get('severity', '')
|
||||
status_filter = request.args.get('status', '')
|
||||
sla_filter = request.args.get('sla', '')
|
||||
facility_filter = request.args.get('facility_id', '')
|
||||
contract_filter = request.args.get('contract_id', '')
|
||||
date_from_filter = request.args.get('date_from', '')
|
||||
date_to_filter = request.args.get('date_to', '')
|
||||
reporter_filter = request.args.get('reporter_id', '')
|
||||
issue_id_filter = request.args.get('issue_id', '').strip()
|
||||
severity_filter = request.args.get('severity', '')
|
||||
status_filter = request.args.get('status', '')
|
||||
sla_filter = request.args.get('sla', '')
|
||||
facility_filter = request.args.get('facility_id', '')
|
||||
contract_filter = request.args.get('contract_id', '')
|
||||
date_from_filter = request.args.get('date_from', '')
|
||||
date_to_filter = request.args.get('date_to', '')
|
||||
reporter_filter = request.args.get('reporter_id', '')
|
||||
handler_type_filter = request.args.get('handler_type', '')
|
||||
unassigned_filter = request.args.get('unassigned', '')
|
||||
|
||||
if issue_id_filter.isdigit():
|
||||
q = q.filter(Issue.id == int(issue_id_filter))
|
||||
@@ -280,6 +282,17 @@ def index():
|
||||
Area.facility_id == fid,
|
||||
)
|
||||
)
|
||||
if handler_type_filter:
|
||||
if handler_type_filter == 'internal':
|
||||
# NULL handler_type means 'internal' (the default)
|
||||
q = q.filter(db.or_(
|
||||
Issue.handler_type == 'internal',
|
||||
Issue.handler_type.is_(None),
|
||||
))
|
||||
else:
|
||||
q = q.filter(Issue.handler_type == handler_type_filter)
|
||||
if unassigned_filter:
|
||||
q = q.filter(Issue.assigned_to.is_(None))
|
||||
# SLA filter — SLA status is computed in Python (not a DB column).
|
||||
# When active: load all matching rows, filter in Python, wrap in a
|
||||
# single-page compatible object so the template interface is unchanged.
|
||||
@@ -353,6 +366,8 @@ def index():
|
||||
date_from_filter=date_from_filter,
|
||||
date_to_filter=date_to_filter,
|
||||
reporter_filter=reporter_filter,
|
||||
handler_type_filter=handler_type_filter,
|
||||
unassigned_filter=unassigned_filter,
|
||||
facilities=facilities,
|
||||
projects=projects,
|
||||
staff=staff,
|
||||
@@ -439,6 +454,18 @@ def view(issue_id):
|
||||
issue.vendor_contact = form.vendor_contact.data.strip() or None
|
||||
issue.vendor_notes = form.vendor_notes.data.strip() or None
|
||||
|
||||
# Handler type (phase39)
|
||||
ht = form.handler_type.data or None
|
||||
issue.handler_type = ht
|
||||
if ht == 'facility':
|
||||
issue.facility_handler_name = form.facility_handler_name.data.strip() or None
|
||||
issue.facility_handler_contact = form.facility_handler_contact.data.strip() or None
|
||||
issue.facility_handler_notes = form.facility_handler_notes.data.strip() or None
|
||||
else:
|
||||
issue.facility_handler_name = None
|
||||
issue.facility_handler_contact = None
|
||||
issue.facility_handler_notes = None
|
||||
|
||||
from app.routes.inspections import _save_photo
|
||||
new_photos = []
|
||||
for file_obj in request.files.getlist('result_photos'):
|
||||
|
||||
@@ -178,6 +178,54 @@
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Open Issues by Handler (phase39) — staff only ──────────────────────── #}
|
||||
{% if current_user.role not in ['customer'] %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<a href="{{ url_for('issues.index', status='open', handler_type='internal') }}" class="text-decoration-none">
|
||||
<div class="card h-100 border-0" style="background:#e0f2fe;">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="small fw-semibold text-muted text-uppercase mb-1" style="font-size:.7rem;">Janitorial Staff</div>
|
||||
<div class="fs-2 fw-bold" style="color:#0369a1;">{{ handler_breakdown.internal }}</div>
|
||||
<div class="small text-muted">Open issues — our team</div>
|
||||
</div>
|
||||
<i class="bi bi-people" style="font-size:1.8rem;color:#0ea5e9;opacity:.35;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<a href="{{ url_for('issues.index', status='open', handler_type='facility') }}" class="text-decoration-none">
|
||||
<div class="card h-100 border-0" style="background:#fef9c3;">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="small fw-semibold text-muted text-uppercase mb-1" style="font-size:.7rem;">Facility Staff</div>
|
||||
<div class="fs-2 fw-bold" style="color:#a16207;">{{ handler_breakdown.facility }}</div>
|
||||
<div class="small text-muted">Open issues — facility contact</div>
|
||||
</div>
|
||||
<i class="bi bi-building" style="font-size:1.8rem;color:#eab308;opacity:.35;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<a href="{{ url_for('issues.index', status='open', handler_type='vendor') }}" class="text-decoration-none">
|
||||
<div class="card h-100 border-0" style="background:#fce7f3;">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="small fw-semibold text-muted text-uppercase mb-1" style="font-size:.7rem;">External Vendor</div>
|
||||
<div class="fs-2 fw-bold" style="color:#9d174d;">{{ handler_breakdown.vendor }}</div>
|
||||
<div class="small text-muted">Open issues — contractor</div>
|
||||
</div>
|
||||
<i class="bi bi-person-gear" style="font-size:1.8rem;color:#ec4899;opacity:.35;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── SLA Summary ─────────────────────────────────────────────────────────── #}
|
||||
{% if sla_breached > 0 or sla_at_risk > 0 %}
|
||||
<div class="row g-3 mb-4">
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
<body>
|
||||
<div class="fq-wrap">
|
||||
|
||||
{% if request.args.get('reported') == '1' %}
|
||||
<div class="alert alert-success d-flex align-items-center gap-2 mb-3" role="alert">
|
||||
<i class="bi bi-check-circle-fill fs-5"></i>
|
||||
<div><strong>Report submitted.</strong> Our team has been notified and will follow up.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-building fs-3 text-primary"></i>
|
||||
<div>
|
||||
@@ -170,6 +177,42 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# ── Report a problem ── #}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white fw-semibold py-2">
|
||||
<i class="bi bi-megaphone text-danger"></i> Report a Problem
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
See something that needs attention? Let our team know and we'll take care of it.
|
||||
</p>
|
||||
<form method="POST" action="{{ url_for('facility_qr.report', token=token) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{# Honeypot — invisible to humans, filled by bots #}
|
||||
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
|
||||
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">What did you observe? <span class="text-danger">*</span></label>
|
||||
<textarea name="description" class="form-control form-control-sm" rows="3"
|
||||
placeholder="Describe the issue (e.g. restroom out of paper towels, spill in lobby…)"
|
||||
required maxlength="2000"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Urgency</label>
|
||||
<select name="severity" class="form-select form-select-sm">
|
||||
<option value="low">Low — not urgent</option>
|
||||
<option value="medium" selected>Medium — needs attention soon</option>
|
||||
<option value="high">High — urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger btn-sm w-100">
|
||||
<i class="bi bi-send me-1"></i> Submit Report
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-muted small mt-2 mb-1">
|
||||
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
|
||||
Snapshot generated {{ generated_at.strftime('%b %d, %Y %I:%M %p') }} ET
|
||||
|
||||
@@ -82,6 +82,24 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if current_user.role not in ['customer'] %}
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Handled By</label>
|
||||
<select name="handler_type" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
<option value="internal" {{ 'selected' if handler_type_filter == 'internal' }}>Janitorial Staff</option>
|
||||
<option value="facility" {{ 'selected' if handler_type_filter == 'facility' }}>Facility Staff</option>
|
||||
<option value="vendor" {{ 'selected' if handler_type_filter == 'vendor' }}>External Vendor</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="unassigned" value="1" id="unassignedCheck"
|
||||
{{ 'checked' if unassigned_filter }}>
|
||||
<label class="form-check-label small" for="unassignedCheck">Unassigned only</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-auto d-flex align-items-end gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||
|
||||
@@ -76,6 +76,28 @@
|
||||
<dt class="col-sm-3">Assigned To</dt>
|
||||
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
|
||||
|
||||
{% if issue.handler_type and issue.handler_type != 'internal' %}
|
||||
<dt class="col-sm-3">Handled By</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if issue.handler_type == 'facility' %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="bi bi-building me-1"></i>Facility Staff
|
||||
</span>
|
||||
{% if issue.facility_handler_name %}
|
||||
<span class="ms-2 fw-semibold">{{ issue.facility_handler_name }}</span>
|
||||
{% if issue.facility_handler_contact %}
|
||||
<span class="text-muted ms-2">{{ issue.facility_handler_contact }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if issue.facility_handler_notes %}
|
||||
<div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.facility_handler_notes }}</div>
|
||||
{% endif %}
|
||||
{% elif issue.handler_type == 'vendor' %}
|
||||
<span class="badge bg-dark"><i class="bi bi-person-gear me-1"></i>External Vendor</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if issue.vendor_name %}
|
||||
<dt class="col-sm-3">Contractor</dt>
|
||||
<dd class="col-sm-9">
|
||||
@@ -366,6 +388,46 @@
|
||||
</div>
|
||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
||||
<hr class="my-3">
|
||||
<p class="fw-semibold small mb-2">
|
||||
<i class="bi bi-person-check me-1 text-secondary"></i>Handler / Ownership
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
{{ form.handler_type.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.handler_type(class="form-select form-select-sm",
|
||||
id="handlerTypeSelect") }}
|
||||
</div>
|
||||
<div id="facilityHandlerFields"
|
||||
style="{{ '' if issue.handler_type == 'facility' else 'display:none;' }}">
|
||||
<div class="mb-2">
|
||||
{{ form.facility_handler_name.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.facility_handler_name(class="form-control form-control-sm",
|
||||
placeholder="Contact name at the facility",
|
||||
value=issue.facility_handler_name or '') }}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
{{ form.facility_handler_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.facility_handler_contact(class="form-control form-control-sm",
|
||||
placeholder="Phone or email",
|
||||
value=issue.facility_handler_contact or '') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.facility_handler_notes.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.facility_handler_notes(class="form-control form-control-sm", rows=2,
|
||||
placeholder="Notes about what they are handling…") }}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var sel = document.getElementById('handlerTypeSelect');
|
||||
var box = document.getElementById('facilityHandlerFields');
|
||||
if(sel && box){
|
||||
sel.addEventListener('change', function(){
|
||||
box.style.display = (this.value === 'facility') ? '' : 'none';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<hr class="my-3">
|
||||
<p class="fw-semibold small mb-2">
|
||||
<i class="bi bi-person-gear me-1 text-secondary"></i>External Contractor
|
||||
</p>
|
||||
|
||||
@@ -223,6 +223,16 @@ class IssueUpdateForm(FlaskForm):
|
||||
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
|
||||
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
|
||||
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
|
||||
# Handler type (phase39)
|
||||
handler_type = SelectField('Handled By', choices=[
|
||||
('', '— Select —'),
|
||||
('internal', 'Janitorial Staff'),
|
||||
('facility', 'Facility Staff'),
|
||||
('vendor', 'External Vendor'),
|
||||
], validators=[Optional()])
|
||||
facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)])
|
||||
facility_handler_contact = StringField('Facility Contact Phone/Email', validators=[Optional(), Length(max=200)])
|
||||
facility_handler_notes = TextAreaField('Facility Handler Notes', validators=[Optional(), Length(max=1000)])
|
||||
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Add handler_type and facility-handler fields to issues (phase39).
|
||||
|
||||
Tracks who is responsible for resolving an issue:
|
||||
internal — janitorial staff (default / NULL)
|
||||
facility — building / facility staff (adds name/contact/notes sub-fields)
|
||||
vendor — external contractor (already tracked via vendor_* columns)
|
||||
|
||||
All columns are nullable so existing rows are unaffected (NULL == 'internal').
|
||||
Uses INFORMATION_SCHEMA existence checks — safe to re-run.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'phase39_issue_handler_type'
|
||||
down_revision = 'phase38_facility_qr'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _col_exists(conn, table, column):
|
||||
row = conn.execute(sa.text("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = :t
|
||||
AND COLUMN_NAME = :c
|
||||
"""), {'t': table, 'c': column}).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
if not _col_exists(conn, 'issues', 'handler_type'):
|
||||
op.add_column('issues', sa.Column(
|
||||
'handler_type',
|
||||
sa.Enum('internal', 'facility', 'vendor'),
|
||||
nullable=True,
|
||||
))
|
||||
|
||||
if not _col_exists(conn, 'issues', 'facility_handler_name'):
|
||||
op.add_column('issues', sa.Column(
|
||||
'facility_handler_name', sa.String(100), nullable=True))
|
||||
|
||||
if not _col_exists(conn, 'issues', 'facility_handler_contact'):
|
||||
op.add_column('issues', sa.Column(
|
||||
'facility_handler_contact', sa.String(200), nullable=True))
|
||||
|
||||
if not _col_exists(conn, 'issues', 'facility_handler_notes'):
|
||||
op.add_column('issues', sa.Column(
|
||||
'facility_handler_notes', sa.Text, nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('issues', 'facility_handler_notes')
|
||||
op.drop_column('issues', 'facility_handler_contact')
|
||||
op.drop_column('issues', 'facility_handler_name')
|
||||
op.drop_column('issues', 'handler_type')
|
||||
Reference in New Issue
Block a user