Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
321965038d | ||
|
|
c706489480 | ||
|
|
8d9730e3df | ||
|
|
d44d761706 | ||
|
|
253291d5a4 | ||
|
|
02b030b1d2 | ||
|
|
a60afc6c41 | ||
|
|
8ff38578ad | ||
|
|
99d966e6ad | ||
|
|
5bd30aeaa0 |
@@ -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; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); support chat persistence + knowledge base (phase40); 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); support chat persistence + knowledge base (phase40); Reports R1+R2 contract cascade filter; billing emails branded From address; MT-9 iOS pending**)
|
||||
|
||||
---
|
||||
|
||||
@@ -116,7 +116,7 @@ lt_janitorial_quality_control/
|
||||
│ ├── models/
|
||||
│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B)
|
||||
│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
|
||||
│ │ ├── support.py # SupportTicket, SupportTicketReply (Phase 23)
|
||||
│ │ ├── support.py # SupportChatSession, SupportChatMessage, SupportKnowledge (phase40) + SupportTicket, SupportTicketReply (Phase 23)
|
||||
│ │ ├── tenant_settings.py # MT-7: TenantSettings — per-tenant branding (one row per tenant DB)
|
||||
│ │ └── ...
|
||||
│ ├── routes/
|
||||
@@ -1106,11 +1106,13 @@ Always use `user.display_name` in templates — never `.username` for display pu
|
||||
|
||||
The Contract selector is always a plain HTML `<select>` (never a WTForms field). On `change` it calls `GET /inspections/facilities_for_project/<project_id>` and replaces the Facility `<option>` list. When the Contract is cleared it restores the "All Facilities" placeholder. The filter bars auto-narrow the server-side facility dropdown on page load when `contract_id` is in the query string.
|
||||
|
||||
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar).
|
||||
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar), `reports/issues_aging.html` (filter bar), `reports/sla_compliance.html` (filter bar).
|
||||
|
||||
The issues list and inspections list both accept a `contract_id` query param that filters the DB query to facilities belonging to that contract (`facility.project_id == contract_id`) and narrows the facility dropdown in the rendered HTML.
|
||||
|
||||
**Customer role — contract filter scoping:** In `inspections.index()` and `issues.index()`, the `projects` list passed to the template is scoped to contracts the customer is assigned to via `CustomerAssignment`. Non-customer roles still receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
|
||||
The **Reports R1 (Issues Aging) and R2 (SLA Compliance)** filter bars include a Contract cascade dropdown that is **client-side only** — selecting a contract calls `GET /inspections/facilities_for_project/<id>` to narrow the facility list in the browser; the actual DB filter still uses only `facility_id`. The route passes `projects` to the template (all active projects, scoped to the customer's assigned facilities when the role is `customer`). The cascade JS is guarded by `{% if projects %}` so it is omitted for empty lists (e.g. a customer with no facility assignments).
|
||||
|
||||
**Customer role — contract filter scoping:** In `inspections.index()`, `issues.index()`, `reports.issues_aging()`, and `reports.sla_compliance()`, the `projects` list passed to the template is scoped to contracts whose facilities overlap the customer's assigned facility set. Non-customer roles receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
|
||||
|
||||
### Customer Dashboard — "Your Facilities" Panel
|
||||
|
||||
@@ -1196,6 +1198,8 @@ All report pages include `{% include 'reports/_subnav.html' %}` as the first ele
|
||||
|
||||
Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `1–3 days`, `3–7 days`, `1–4 weeks`, `>4 weeks`). SLA status computed per-issue via `sla_status()`. Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope).
|
||||
|
||||
**Contract cascade filter:** A client-side Contract `<select>` (no name attribute — not submitted) appears above the Facility dropdown. On change, JS calls `GET /inspections/facilities_for_project/<id>` to narrow the Facility list in-browser; clearing the contract restores all options. The route passes `projects` (all active, or scoped to customer facility set). The actual DB filter uses only `facility_id`.
|
||||
|
||||
Excel export: `GET /reports/export/issues-aging` — one sheet, color-coded severity and SLA columns.
|
||||
|
||||
Helper: `_load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter)` — extracted so both the HTML route and the Excel export share identical query logic.
|
||||
@@ -1207,6 +1211,8 @@ Loads resolved issues in the date range, computes `within_sla()` per issue (comp
|
||||
- `by_severity` — dict with `total`, `met`, `pct`, `sla_hours` per severity tier
|
||||
- `by_facility` — list sorted by compliance % descending
|
||||
|
||||
**Contract cascade filter:** Same client-side Contract → Facility cascade as R1. Route passes `projects`; DB filter uses only `facility_id`.
|
||||
|
||||
Helper: `_sla_within(issue)` — used by both the HTML route and the Excel export.
|
||||
|
||||
Excel export: `GET /reports/export/sla-compliance` — 2 sheets: **By Severity** (with totals row) and **By Facility**.
|
||||
@@ -1227,12 +1233,15 @@ A **PDF Summary** button was added to `reports/scorecard.html` alongside the exi
|
||||
|
||||
### Support Chat — Customer UX
|
||||
|
||||
`GET /support/chat` — customer only. Renders:
|
||||
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks).
|
||||
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag.
|
||||
- Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns.
|
||||
`GET /support/chat` — customer only. Accepts optional `?session_id=N` to reload a prior conversation.
|
||||
|
||||
Renders:
|
||||
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks). Greeting is hidden when loading a prior session (`{% if not db_history %}`).
|
||||
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. Hidden when a prior session is loaded. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag.
|
||||
- **Chat history is DB-backed (phase40).** Prior turns are rendered server-side on page load from `db_history` (list of `SupportChatMessage`). The JS variable `let session_id` is seeded from `chat_session.id` (null for new chats). AJAX sends only `{ message, session_id }` — **no history array** (rule 95). Server returns `{ reply, session_id }` and the JS stores/reuses `session_id` across subsequent messages.
|
||||
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown.
|
||||
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
|
||||
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from `last_user_msg` JS variable (last message typed, not scanned from history array).
|
||||
- "History" button links to `support.my_conversations` (list of all past sessions). "New Chat" link starts a fresh session (`/support/chat` with no `session_id`).
|
||||
|
||||
### Inspection Execute Page — UX Patterns
|
||||
|
||||
@@ -1381,7 +1390,7 @@ set -a; . /etc/jqc/control.env; set +a
|
||||
| 60 | **`flag_issue` offcanvas form must include `<input type="hidden" name="facility_id">`** | `IssueForm.facility_id` has `DataRequired()`. The hand-written offcanvas form in `execute.html` is not rendered by WTForms, so it must explicitly send `facility_id`. Without it, `form.validate_on_submit()` silently returns `False`, the server responds `200 OK` with the `flag_issue.html` template, and the JS treats `res.ok` as success — no issue is ever saved. Fix: `<input type="hidden" name="facility_id" value="{{ inspection.facility_id }}">` inside `#flagIssueForm`. |
|
||||
| 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. |
|
||||
| 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. |
|
||||
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()` and `issues.index()` build the `projects` list differently for `customer` role: query `CustomerAssignment.query.filter_by(user_id=current_user.id)` to get assigned `project_id` values, then filter `Project` to that set. All other roles still receive all active projects. Pattern mirrors the existing inspector scoping in `inspections.start()`. |
|
||||
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()`, `issues.index()`, `reports.issues_aging()`, and `reports.sla_compliance()` build the `projects` list differently for `customer` role. Inspections/issues use `CustomerAssignment` to get assigned `project_id` values. Reports use a join: `Project.query.join(Facility).filter(Facility.id.in_(customer_facility_ids))`. All other roles receive all active projects. This prevents customers from seeing contracts they have no assignment to in any Contract filter dropdown. |
|
||||
| 64 | **Invitation email sender and link domain are derived from `request.host_url`** | `_send_invite_email(user, token, base_url=None)` in `customers.py` accepts an optional `base_url`. Both call sites (`invite` and `resend_invite`) pass `request.host_url`. Inside the function, `effective_base` is built from that value (falling back to `APP_BASE_URL`); `setup_link` uses `effective_base`; `sender` is `noreply@<netloc>` parsed from `effective_base`. The SMTP server and credentials are unchanged — only the `From` address and link URL vary per domain. |
|
||||
| 65 | **Customer "Your Facilities" uses a card grid, not a table** | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is `VISIBLE = 9`; the search input threshold is `> 6`. Both thresholds live as JS/Jinja constants in `dashboard.html` and can be adjusted together if needed. |
|
||||
| 66 | **FAQ chip text must use `data-faq` attribute, not `onclick` with `\| tojson`** | `\| tojson` emits `"text"` (double-quoted) inside `onclick="..."` (also double-quoted), breaking HTML parsing and silently truncating the `<script>` block. Use `data-faq="{{ text \| e }}"` and read via `btn.dataset.faq` in JS. |
|
||||
@@ -1408,6 +1417,8 @@ set -a; . /etc/jqc/control.env; set +a
|
||||
| 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. |
|
||||
| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. |
|
||||
| 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. |
|
||||
| 97 | **`send_billing_email()` derives the From address from `APP_BASE_URL` — same pattern as rule 64** | `urlparse(app.config['APP_BASE_URL']).netloc` is extracted before the background thread starts and passed as `sender=f'noreply@{netloc}'` to `Message()`. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is absent or yields an empty netloc (`sender=None` triggers Flask-Mail's default). Do not hardcode a sender string or duplicate the derivation logic — extend via `send_billing_email()` only. |
|
||||
| 98 | **Reports R1 + R2 contract cascade is client-side only — facility_id is the sole DB filter** | The Contract dropdown in `reports/issues_aging.html` and `reports/sla_compliance.html` has no `name` attribute and is never submitted. It exists only to narrow the Facility `<select>` in the browser via `GET /inspections/facilities_for_project/<id>`. The routes receive and filter on `facility_id`; `contract_id` plays no role server-side. Do not add server-side `contract_id` filtering to these routes — it would duplicate what `facility_id` already provides. |
|
||||
|
||||
---
|
||||
|
||||
@@ -1616,7 +1627,9 @@ Stripe-backed subscription billing. Controlled by `BILLING_ENABLED` env var (def
|
||||
|
||||
### Billing emails (`app/billing/emails.py`)
|
||||
|
||||
`send_billing_email(to_addr, event_type, context_dict)` sends multipart HTML + plain text.
|
||||
`send_billing_email(to_addr, event_type, context_dict)` sends multipart HTML + plain text in a background thread.
|
||||
|
||||
**Sender derivation:** Before launching the thread, `urlparse(app.config['APP_BASE_URL']).netloc` is extracted and the From address is set to `noreply@<netloc>`. This mirrors rule 64 (invitation emails) so billing emails carry the correct tenant domain in the From header rather than a hardcoded address. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is unset or unparseable (`sender=None` passes `None` to `Message()`, triggering Flask-Mail's default).
|
||||
|
||||
| `event_type` | Trigger | Required context keys |
|
||||
|---|---|---|
|
||||
|
||||
+20
-1
@@ -122,6 +122,11 @@ def create_app(config_name='default'):
|
||||
app.jinja_env.globals['sla_hours_remaining'] = sla_hours_remaining
|
||||
app.jinja_env.globals['SLA_HOURS'] = SLA_HOURS
|
||||
|
||||
# Photo URL resolver — routes through the active storage backend so templates
|
||||
# work unchanged when the backend flips from local to R2 (see utils/storage.py).
|
||||
from app.utils import storage as _storage
|
||||
app.jinja_env.globals['media_url'] = _storage.media_url
|
||||
|
||||
# ── Inject unread notification count into every template context ──────
|
||||
# This powers the red badge on the navbar bell icon without requiring
|
||||
# individual routes to pass the count manually.
|
||||
@@ -261,6 +266,7 @@ def create_app(config_name='default'):
|
||||
from app.api.notifications import bp as _api_notifications_bp
|
||||
from app.api.stats import bp as _api_stats_bp
|
||||
from app.api.comments import bp as _api_comments_bp
|
||||
from app.api.scheduled import bp as _api_scheduled_bp
|
||||
csrf.exempt(_api_auth_bp)
|
||||
csrf.exempt(_api_facilities_bp)
|
||||
csrf.exempt(_api_templates_bp)
|
||||
@@ -270,11 +276,24 @@ def create_app(config_name='default'):
|
||||
csrf.exempt(_api_notifications_bp)
|
||||
csrf.exempt(_api_stats_bp)
|
||||
csrf.exempt(_api_comments_bp)
|
||||
csrf.exempt(_api_scheduled_bp)
|
||||
register_api(app)
|
||||
|
||||
# ── Security response headers ─────────────────────────────────────────
|
||||
# Applied to every response. Blocks clickjacking, MIME sniffing, and
|
||||
# obvious XSS vectors without breaking Bootstrap CDN / Google Fonts.
|
||||
# Allow R2 presigned photo URLs in the CSP img-src when the s3 storage
|
||||
# backend is configured. Derived from R2_ENDPOINT_URL (the presigned URL
|
||||
# host is the same R2 account endpoint), so nothing is hardcoded and the
|
||||
# local backend is unaffected.
|
||||
_r2_img_src = ''
|
||||
_r2_endpoint = app.config.get('R2_ENDPOINT_URL')
|
||||
if _r2_endpoint:
|
||||
from urllib.parse import urlparse
|
||||
_r2_host = urlparse(_r2_endpoint).netloc
|
||||
if _r2_host:
|
||||
_r2_img_src = f' https://{_r2_host}'
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
from flask import request as _request
|
||||
@@ -287,7 +306,7 @@ def create_app(config_name='default'):
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||
"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com; "
|
||||
f"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com{_r2_img_src}; "
|
||||
"connect-src 'self' https://cdn.jsdelivr.net; "
|
||||
"frame-src https://maps.google.com https://www.google.com; "
|
||||
# Hardening directives that don't affect existing inline scripts/styles:
|
||||
|
||||
@@ -50,6 +50,10 @@ def register_api(app):
|
||||
from app.api.comments import bp as comments_bp
|
||||
api_bp.register_blueprint(comments_bp)
|
||||
|
||||
# phase43: Planned inspection assignments (plan-mode schedules)
|
||||
from app.api.scheduled import bp as scheduled_bp
|
||||
api_bp.register_blueprint(scheduled_bp)
|
||||
|
||||
# NOTE: device registration lives on the auth blueprint
|
||||
# (POST /api/v1/devices/register in app/api/auth.py) and writes to the
|
||||
# canonical api_device_tokens table (model DeviceToken). A former duplicate
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_comments', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _comment_payload(comment: IssueComment) -> dict:
|
||||
|
||||
@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_inspections', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
||||
|
||||
+97
-1
@@ -42,9 +42,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_issues', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
||||
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
||||
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
|
||||
_UUID_RE = re.compile(
|
||||
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
|
||||
re.IGNORECASE,
|
||||
@@ -82,6 +83,18 @@ def _issue_payload(issue):
|
||||
'area_name': issue.area.name if issue.area else None,
|
||||
# Assigned-to display name — set when a director assigns the issue to a user.
|
||||
'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None,
|
||||
# ── Handler ("Handled By", phase39) ───────────────────────────────
|
||||
# handler_type categorises WHO resolves the issue:
|
||||
# internal = our staff (assigned_to) facility = facility's own staff
|
||||
# vendor = external contractor
|
||||
'handler_type': issue.handler_type or 'internal',
|
||||
'handler_label': issue.handler_label,
|
||||
'facility_handler_name': issue.facility_handler_name or None,
|
||||
'facility_handler_contact': issue.facility_handler_contact or None,
|
||||
'facility_handler_notes': issue.facility_handler_notes or None,
|
||||
'vendor_name': issue.vendor_name or None,
|
||||
'vendor_contact': issue.vendor_contact or None,
|
||||
'vendor_notes': issue.vendor_notes or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -497,3 +510,86 @@ def update_issue_result_photos(issue_id):
|
||||
issue.id, len(new_photos), user.username)
|
||||
|
||||
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
|
||||
|
||||
|
||||
# ── Update Issue Handler ("Handled By") ───────────────────────────────────────
|
||||
|
||||
@bp.route('/issues/<int:issue_id>/handler', methods=['PATCH'])
|
||||
@jwt_required
|
||||
def update_issue_handler(issue_id):
|
||||
"""
|
||||
Set who handles an issue ("Handled By") from the mobile app.
|
||||
|
||||
Unlike the web form (which limits handler edits to admin/director/PM/auditor),
|
||||
the iPad allows the assigned inspector to set the handler from the field,
|
||||
scoped to issues at their assigned facilities. This divergence is deliberate:
|
||||
the inspector is the one standing in the building who knows whether the
|
||||
facility's own staff or a vendor should take it.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{
|
||||
"handler_type": "internal" | "facility" | "vendor",
|
||||
"facility_handler_name": "...", // optional (facility handler)
|
||||
"facility_handler_contact": "...", // optional
|
||||
"facility_handler_notes": "...", // optional
|
||||
"vendor_name": "...", // optional (vendor handler)
|
||||
"vendor_contact": "...", // optional
|
||||
"vendor_notes": "..." // optional
|
||||
}
|
||||
|
||||
Only keys present in the body are updated; empty strings clear a field.
|
||||
handler_type is required.
|
||||
|
||||
Access:
|
||||
- admin / director / project_manager / auditor : any issue
|
||||
- inspector : only issues at their assigned facilities
|
||||
- customer : denied
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
handler = (data.get('handler_type') or '').strip().lower()
|
||||
|
||||
if handler not in _VALID_HANDLERS:
|
||||
return api_error(
|
||||
f'handler_type must be one of: {", ".join(sorted(_VALID_HANDLERS))}', 400
|
||||
)
|
||||
|
||||
old_handler = issue.handler_type or 'internal'
|
||||
issue.handler_type = handler
|
||||
|
||||
# Update only the detail fields that were supplied. Empty string clears
|
||||
# the field (stored as NULL); a missing key leaves the field untouched.
|
||||
_text_fields = (
|
||||
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
|
||||
'vendor_name', 'vendor_contact', 'vendor_notes',
|
||||
)
|
||||
for field in _text_fields:
|
||||
if field in data:
|
||||
val = (data.get(field) or '').strip()
|
||||
setattr(issue, field, val or None)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id,
|
||||
f'handler {old_handler} → {handler}',
|
||||
f'source=mobile; updated_by={user.username}')
|
||||
|
||||
logger.info('API ISSUES | handler_updated | issue_id=%d | %s→%s | user=%s',
|
||||
issue.id, old_handler, handler, user.username)
|
||||
|
||||
return api_ok({'issue_id': issue.id, 'handler_type': issue.handler_type,
|
||||
'handler_label': issue.handler_label})
|
||||
|
||||
+5
-11
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('api_photos', __name__)
|
||||
|
||||
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _allowed_file(filename: str) -> bool:
|
||||
@@ -85,16 +85,10 @@ def upload_photo():
|
||||
else:
|
||||
subfolder = 'inspection_photos'
|
||||
|
||||
ext = file_obj.filename.rsplit('.', 1)[-1].lower()
|
||||
filename = f'{uuid.uuid4().hex}.{ext}'
|
||||
|
||||
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
dest_path = os.path.join(dest_dir, filename)
|
||||
file_obj.save(dest_path)
|
||||
|
||||
server_path = f'uploads/{subfolder}/{filename}'
|
||||
# Write via the active storage backend (local disk or R2). Key format
|
||||
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
|
||||
from app.utils import storage
|
||||
server_path = storage.save(file_obj, subfolder)
|
||||
|
||||
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
|
||||
entity_type, server_path, user.username)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
app/api/scheduled.py
|
||||
--------------------
|
||||
Mobile API endpoint for planned inspection assignments (phase43).
|
||||
|
||||
GET /api/v1/scheduled-inspections
|
||||
Returns ACTIVE, PLAN-MODE schedules the caller is responsible for.
|
||||
- inspector : only schedules where inspector_id == the caller
|
||||
- admin / director / project_manager / auditor : all active plan schedules
|
||||
Powers the "Scheduled" section on the iPad Dashboard and My Inspections
|
||||
lists. The iPad taps "Start", which opens the normal new-inspection flow
|
||||
with the facility + template preselected (client-side); the schedule
|
||||
lifecycle (fulfil / roll-forward) continues to be driven by the web app.
|
||||
|
||||
Why plan-mode only
|
||||
------------------
|
||||
`mode='auto'` schedules materialise themselves into a real Inspection at
|
||||
next_run_at, which the iPad already fetches via /api/v1/inspections. Returning
|
||||
them here too would show the same work twice, and "Start" is meaningless for a
|
||||
schedule that starts itself. This mirrors the web dashboard panel (phase43).
|
||||
|
||||
A plan-mode schedule is a PLAN, not an inspection — see
|
||||
app/models/inspection_schedule.py for the full lifecycle.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_scheduled', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _scheduled_payload(s):
|
||||
"""Serialise an InspectionSchedule to the dict returned in list responses.
|
||||
|
||||
`next_due_date` is the date part of next_run_at — MT reuses next_run_at as
|
||||
the due datetime for both modes (see phase43).
|
||||
"""
|
||||
return {
|
||||
'id': s.id,
|
||||
'name': s.name,
|
||||
'facility_id': s.facility_id,
|
||||
'facility_name': s.facility.name if s.facility else None,
|
||||
'area_id': s.area_id,
|
||||
'area_name': s.area.name if s.area else None,
|
||||
'template_id': s.template_id,
|
||||
'template_name': s.template.name if s.template else None,
|
||||
'inspector_id': s.inspector_id,
|
||||
'frequency': s.frequency,
|
||||
'frequency_label': s.frequency_label,
|
||||
'mode': s.mode,
|
||||
'next_due_date': s.next_run_at.date().isoformat() if s.next_run_at else None,
|
||||
'is_overdue': s.is_overdue(),
|
||||
'notes': s.notes or None,
|
||||
}
|
||||
|
||||
|
||||
# ── List Scheduled Inspections ────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/scheduled-inspections', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_scheduled():
|
||||
"""
|
||||
Return active plan-mode scheduled inspections for the authenticated user.
|
||||
|
||||
Query parameters
|
||||
----------------
|
||||
limit int Default 100, max 200.
|
||||
offset int Default 0.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"scheduled": [...],
|
||||
"total": 3,
|
||||
"limit": 100,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
try:
|
||||
limit = min(int(request.args.get('limit', 100)), 200)
|
||||
offset = max(int(request.args.get('offset', 0)), 0)
|
||||
except (TypeError, ValueError):
|
||||
return api_error('limit and offset must be integers', 400)
|
||||
|
||||
query = InspectionSchedule.query.filter(
|
||||
InspectionSchedule.active.is_(True),
|
||||
InspectionSchedule.mode == 'plan',
|
||||
)
|
||||
|
||||
if user.role == 'inspector':
|
||||
# Inspectors only see schedules assigned directly to them.
|
||||
query = query.filter(InspectionSchedule.inspector_id == user.id)
|
||||
|
||||
total = query.count()
|
||||
rows = (
|
||||
query
|
||||
.order_by(InspectionSchedule.next_run_at.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
payload = [_scheduled_payload(s) for s in rows]
|
||||
|
||||
logger.info('API SCHEDULED | list | user=%s | count=%d | total=%d',
|
||||
user.username, len(payload), total)
|
||||
|
||||
return api_ok({'scheduled': payload, 'total': total,
|
||||
'limit': limit, 'offset': offset})
|
||||
+1
-1
@@ -40,7 +40,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_stats', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
|
||||
|
||||
@bp.route('/stats/dashboard', methods=['GET'])
|
||||
|
||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('api_templates', __name__)
|
||||
|
||||
# Customer role cannot access template data — inspectors and above only
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
||||
|
||||
@@ -91,6 +91,17 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
# Branded From: the display NAME tracks the tenant, the ADDRESS stays the
|
||||
# authenticated identity unless the domain is DNS-authorized. Resolved HERE,
|
||||
# in the request context, because _send() runs on a background thread where
|
||||
# g.tenant no longer exists. The previous `noreply@{netloc}` sent from the
|
||||
# tenant's host regardless of SPF authorization. See app/utils/mail_utils.py.
|
||||
try:
|
||||
from app.utils.mail_utils import branded_sender
|
||||
_sender = branded_sender(app.config.get('APP_BASE_URL', ''))
|
||||
except Exception:
|
||||
_sender = app.config.get('MAIL_DEFAULT_SENDER', '')
|
||||
|
||||
def _send():
|
||||
try:
|
||||
with app.app_context():
|
||||
@@ -105,6 +116,7 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
|
||||
msg = Message(
|
||||
subject=subject,
|
||||
recipients=[to_addr],
|
||||
sender=_sender or None,
|
||||
body=plain_body,
|
||||
html=html_body,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,18 @@ class Area(db.Model):
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
area_type = db.Column(db.String(50))
|
||||
|
||||
# phase42: unguessable token behind the public area scan page (/f/area/<token>).
|
||||
# NULL until first requested — ensure_qr_token() generates it lazily, exactly
|
||||
# like Facility.qr_token above.
|
||||
qr_token = db.Column(db.String(64), unique=True, nullable=True)
|
||||
|
||||
def ensure_qr_token(self):
|
||||
"""Generate the QR token on first use. Caller commits."""
|
||||
if not self.qr_token:
|
||||
import secrets
|
||||
self.qr_token = secrets.token_urlsafe(32)
|
||||
return self.qr_token
|
||||
|
||||
# Relationships
|
||||
inspections = db.relationship('Inspection', backref='area', lazy='dynamic')
|
||||
issues = db.relationship('Issue', backref='area', lazy='dynamic')
|
||||
|
||||
@@ -57,6 +57,13 @@ class Inspection(db.Model):
|
||||
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False)
|
||||
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
|
||||
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'))
|
||||
# phase43: set when this inspection was started from / materialised by a
|
||||
# schedule. ON DELETE SET NULL — deleting a schedule never deletes history.
|
||||
inspection_schedule_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('inspection_schedules.id', ondelete='SET NULL'),
|
||||
nullable=True, index=True
|
||||
)
|
||||
inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
overall_score = db.Column(db.Numeric(5, 2))
|
||||
|
||||
@@ -13,6 +13,20 @@ their queue and fills it out through the normal execute flow.
|
||||
|
||||
This is purely additive: no existing inspection behaviour changes. A schedule is
|
||||
just an automated `inspections.start()`.
|
||||
|
||||
phase43 adds the single-tenant "plan" semantics alongside that:
|
||||
|
||||
mode='auto' (default, phase34 behaviour)
|
||||
Cron materialises the Inspection at next_run_at and notifies the inspector.
|
||||
|
||||
mode='plan' (ST behaviour)
|
||||
Nothing is materialised. The schedule is a commitment with a due date; the
|
||||
assigned inspector clicks "Start", which creates the Inspection linked back
|
||||
via Inspection.inspection_schedule_id. Reminders fire in advance / on the
|
||||
due date / once overdue. Completing the inspection calls fulfill(), which
|
||||
deactivates a one-time schedule or rolls a recurring one forward.
|
||||
|
||||
`next_run_at` is the due datetime for both modes.
|
||||
"""
|
||||
|
||||
from app import db
|
||||
@@ -49,6 +63,11 @@ class InspectionSchedule(db.Model):
|
||||
)
|
||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
# phase43: 'auto' = cron materialises the inspection (phase34 behaviour,
|
||||
# the default for every pre-existing row); 'plan' = the inspector starts it.
|
||||
mode = db.Column(db.Enum('auto', 'plan'), nullable=False, default='auto')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
created_by = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True
|
||||
@@ -57,6 +76,13 @@ class InspectionSchedule(db.Model):
|
||||
last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation
|
||||
next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due
|
||||
|
||||
# phase43 — plan mode bookkeeping
|
||||
last_completed_at = db.Column(db.DateTime, nullable=True)
|
||||
# Per-occurrence reminder de-dup flags; reset when a recurring schedule rolls forward.
|
||||
advance_notified = db.Column(db.Boolean, nullable=False, default=False)
|
||||
due_notified = db.Column(db.Boolean, nullable=False, default=False)
|
||||
overdue_notified = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
# Relationships — explicit foreign_keys because two columns point at users.id.
|
||||
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
|
||||
facility = db.relationship('Facility', foreign_keys=[facility_id])
|
||||
@@ -64,5 +90,45 @@ class InspectionSchedule(db.Model):
|
||||
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
|
||||
FREQUENCY_LABELS = {
|
||||
'daily': 'Daily',
|
||||
'weekly': 'Weekly',
|
||||
'monthly': 'Monthly',
|
||||
'quarterly': 'Quarterly',
|
||||
}
|
||||
|
||||
@property
|
||||
def frequency_label(self):
|
||||
return self.FREQUENCY_LABELS.get(self.frequency, self.frequency)
|
||||
|
||||
@property
|
||||
def due_date(self):
|
||||
"""The due date (date part of next_run_at), or None."""
|
||||
return self.next_run_at.date() if self.next_run_at else None
|
||||
|
||||
def is_overdue(self, today=None):
|
||||
"""True when an active schedule's due date has passed."""
|
||||
if not self.active or self.next_run_at is None:
|
||||
return False
|
||||
today = today or now_eastern().date()
|
||||
return self.next_run_at.date() < today
|
||||
|
||||
def fulfill(self, next_run_fn=None):
|
||||
"""Mark this occurrence complete. Caller commits.
|
||||
|
||||
Recurring schedules roll their due date forward past today and reset the
|
||||
reminder flags; MT has no 'once' frequency, so a schedule stays active.
|
||||
`next_run_fn(frequency, from_dt)` computes the next due datetime — the
|
||||
route passes `_compute_next_run` so the cadence math lives in one place.
|
||||
"""
|
||||
now = now_eastern()
|
||||
self.last_completed_at = now
|
||||
if next_run_fn is not None:
|
||||
self.next_run_at = next_run_fn(self.frequency, now)
|
||||
self.advance_notified = False
|
||||
self.due_notified = False
|
||||
self.overdue_notified = False
|
||||
|
||||
def __repr__(self):
|
||||
return f'<InspectionSchedule {self.id} {self.name!r} {self.frequency}>'
|
||||
return (f'<InspectionSchedule {self.id} {self.name!r} '
|
||||
f'{self.frequency} mode={self.mode}>')
|
||||
|
||||
@@ -116,6 +116,20 @@ class Issue(db.Model):
|
||||
"""Return True if the given user is currently following this issue."""
|
||||
return self.followers.filter_by(user_id=user.id).first() is not None
|
||||
|
||||
# Display labels for handler_type. The web templates hardcode these inline;
|
||||
# this mapping exists so the mobile API can return a human-readable label
|
||||
# without the client duplicating the strings. (phase43)
|
||||
HANDLER_LABELS = {
|
||||
'internal': 'Janitorial Staff',
|
||||
'facility': 'Facility Staff',
|
||||
'vendor': 'External Vendor',
|
||||
}
|
||||
|
||||
@property
|
||||
def handler_label(self):
|
||||
"""Human-readable label for handler_type; defaults to internal."""
|
||||
return self.HANDLER_LABELS.get(self.handler_type or 'internal', 'Janitorial Staff')
|
||||
|
||||
@property
|
||||
def resolved_facility(self):
|
||||
"""Returns the Facility for this issue regardless of which path was used to create it.
|
||||
|
||||
@@ -40,6 +40,7 @@ MATRIX_ROLES = [
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('auditor', 'Auditor'),
|
||||
('customer', 'Customer'),
|
||||
('custom', 'Custom Recipients'),
|
||||
]
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class User(UserMixin, db.Model):
|
||||
role = db.Column(
|
||||
# Phase 11 migration complete — 'supervisor' removed from both the DB
|
||||
# ENUM and this Python-side declaration. Director is the canonical role.
|
||||
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer'),
|
||||
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer', 'auditor'),
|
||||
nullable=False
|
||||
)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||
|
||||
@@ -179,7 +179,6 @@ def _send_invite_email(user, token, base_url=None):
|
||||
from flask import current_app, render_template_string
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from urllib.parse import urlparse
|
||||
import threading
|
||||
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
@@ -189,8 +188,14 @@ def _send_invite_email(user, token, base_url=None):
|
||||
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||
setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
|
||||
|
||||
host = urlparse(effective_base).netloc or 'janitorialqc.local'
|
||||
sender = f'noreply@{host}'
|
||||
# Branded From as a (display_name, address) tuple. The display NAME tracks
|
||||
# the tenant (e.g. "Gov Services QC"); the ADDRESS is branded only for
|
||||
# DNS-authorized domains and otherwise stays the authenticated identity so
|
||||
# the mail always delivers. The previous `noreply@{host}` sent from whatever
|
||||
# host the browser was on, which fails SPF/DMARC for any domain this mail
|
||||
# server isn't authorized for. See app/utils/mail_utils.py and rule 64.
|
||||
from app.utils.mail_utils import branded_sender
|
||||
sender = branded_sender(effective_base)
|
||||
|
||||
html_body = render_template_string("""<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
+27
-2
@@ -29,6 +29,7 @@ def index():
|
||||
is_privileged = current_user.role in ['admin', 'director']
|
||||
is_customer = current_user.role == 'customer'
|
||||
is_project_manager = current_user.role == 'project_manager'
|
||||
is_auditor = current_user.role == 'auditor'
|
||||
|
||||
# Resolve facility scope
|
||||
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
|
||||
@@ -299,9 +300,9 @@ def index():
|
||||
unassigned_q = unassigned_q.filter(False) # not relevant for customers
|
||||
unassigned_open = unassigned_q.count()
|
||||
|
||||
# ── Inspector activity today (admin / director / PM only) ─────────────────
|
||||
# ── Inspector activity today (admin / director / PM / auditor only) ───────
|
||||
inspector_activity = []
|
||||
if is_privileged or is_project_manager:
|
||||
if is_privileged or is_project_manager or is_auditor:
|
||||
active_inspectors = (
|
||||
User.query
|
||||
.filter_by(role='inspector', active=True)
|
||||
@@ -343,8 +344,32 @@ def index():
|
||||
.all()
|
||||
)
|
||||
|
||||
# ── Scheduled inspections (phase43): upcoming / overdue ───────────────────
|
||||
# Plan-mode only: 'auto' schedules materialise themselves into the
|
||||
# inspections list, so surfacing them here would double-report the work.
|
||||
sched_upcoming = []
|
||||
sched_overdue_count = 0
|
||||
if not is_customer:
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
_today = now_eastern().date()
|
||||
_sq = InspectionSchedule.query.filter(
|
||||
InspectionSchedule.active.is_(True),
|
||||
InspectionSchedule.mode == 'plan',
|
||||
)
|
||||
if is_inspector:
|
||||
_sq = _sq.filter(InspectionSchedule.inspector_id == current_user.id)
|
||||
_all_sched = _sq.order_by(InspectionSchedule.next_run_at.asc()).all()
|
||||
sched_overdue_count = sum(1 for s in _all_sched if s.is_overdue(_today))
|
||||
# Upcoming = due today through the next 7 days (overdue shown separately)
|
||||
sched_upcoming = [
|
||||
s for s in _all_sched
|
||||
if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7)
|
||||
][:8]
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
sched_upcoming = sched_upcoming,
|
||||
sched_overdue_count = sched_overdue_count,
|
||||
today_inspections = today_inspections,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
|
||||
+302
-12
@@ -6,7 +6,8 @@ from app.models.facility import Facility, Area
|
||||
from app.models.project import Project
|
||||
from app.utils.forms import FacilityForm, AreaForm
|
||||
from app.utils.decorators import supervisor_required, admin_required, project_manager_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.audit import (log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE,
|
||||
ACTION_EXPORT)
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
from app.tenancy.gates import quota_soft_check
|
||||
|
||||
@@ -244,20 +245,70 @@ def delete_area(area_id):
|
||||
|
||||
def _qr_scan_url(facility):
|
||||
"""Absolute public scan URL, built from the current host (rule 64 pattern)."""
|
||||
facility.ensure_qr_token()
|
||||
return request.host_url.rstrip('/') + url_for('facility_qr.scan',
|
||||
token=facility.qr_token)
|
||||
|
||||
|
||||
def _facility_for_qr_or_403(facility_id):
|
||||
"""Load a facility for a QR action, enforcing customer facility scope.
|
||||
|
||||
Customers may only touch QR codes for facilities they are assigned to; all
|
||||
other roles have unrestricted QR access. Replaces the previous
|
||||
@project_manager_required gate so a customer can print (and rotate) the
|
||||
codes posted in their own building.
|
||||
"""
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
|
||||
if current_user.role == 'inspector':
|
||||
abort(403)
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
if facility.id not in cids:
|
||||
abort(403)
|
||||
return facility
|
||||
|
||||
|
||||
def _qr_png_bytes(url):
|
||||
"""Return PNG bytes for a QR code encoding *url*."""
|
||||
import io as _io
|
||||
import qrcode
|
||||
img = qrcode.make(url, box_size=10, border=2)
|
||||
buf = _io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@bp.route('/<int:facility_id>/qr.png')
|
||||
@login_required
|
||||
def facility_qr_png(facility_id):
|
||||
"""Return the facility's QR code as a PNG image.
|
||||
|
||||
The printable card renders inline SVG; this PNG endpoint exists for the
|
||||
print-all grid and is the same image the PDF export embeds.
|
||||
"""
|
||||
facility = _facility_for_qr_or_403(facility_id)
|
||||
|
||||
created = not facility.qr_token
|
||||
url = _qr_scan_url(facility)
|
||||
if created:
|
||||
db.session.commit()
|
||||
|
||||
from flask import Response
|
||||
return Response(_qr_png_bytes(url), mimetype='image/png', headers={
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/<int:facility_id>/qr')
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def qr_card(facility_id):
|
||||
"""Printable QR card for one facility. Generates the token on first use."""
|
||||
from app.utils.qr import qr_svg
|
||||
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
facility = _facility_for_qr_or_403(facility_id)
|
||||
|
||||
if not facility.qr_token:
|
||||
facility.ensure_qr_token()
|
||||
@@ -274,12 +325,20 @@ def qr_card(facility_id):
|
||||
|
||||
@bp.route('/qr-sheet')
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def qr_sheet():
|
||||
"""Bulk print sheet — one labeled QR card per active facility."""
|
||||
from app.utils.qr import qr_svg
|
||||
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
if current_user.role == 'inspector':
|
||||
abort(403)
|
||||
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
facilities = (Facility.query
|
||||
.filter(Facility.id.in_(cids), Facility.active == True)
|
||||
.order_by(Facility.name).all()) if cids else []
|
||||
else:
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
|
||||
generated = 0
|
||||
for f in facilities:
|
||||
@@ -297,14 +356,17 @@ def qr_sheet():
|
||||
|
||||
@bp.route('/<int:facility_id>/qr/regenerate', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def regenerate_qr(facility_id):
|
||||
"""Rotate the QR token — invalidates every previously printed poster."""
|
||||
"""Rotate the QR token — invalidates every previously printed poster.
|
||||
|
||||
Allowed for admin/director, and for customers on their own assigned
|
||||
facilities. Project managers, auditors and inspectors cannot regenerate.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
if facility is None:
|
||||
abort(404)
|
||||
facility = _facility_for_qr_or_403(facility_id)
|
||||
if current_user.role not in ('admin', 'director', 'customer'):
|
||||
abort(403)
|
||||
|
||||
facility.qr_token = secrets.token_urlsafe(32)
|
||||
db.session.commit()
|
||||
@@ -315,3 +377,231 @@ def regenerate_qr(facility_id):
|
||||
flash('QR code regenerated. Previously printed posters no longer work — '
|
||||
'print and post the new code.', 'success')
|
||||
return redirect(url_for('facilities.qr_card', facility_id=facility_id))
|
||||
|
||||
# ── Public Area QR codes (phase42) ────────────────────────────────────────────
|
||||
# Mirrors the facility QR routes above, but scoped to a single area. Customer
|
||||
# scope is enforced via the area's parent facility.
|
||||
|
||||
def _area_qr_scan_url(area):
|
||||
"""Absolute public scan URL for an area, built from the current host."""
|
||||
area.ensure_qr_token()
|
||||
return request.host_url.rstrip('/') + url_for('facility_qr.area_scan',
|
||||
token=area.qr_token)
|
||||
|
||||
|
||||
def _area_for_qr_or_403(area_id):
|
||||
"""Load an area for a QR action, enforcing customer facility scope."""
|
||||
area = db.session.get(Area, area_id)
|
||||
if area is None:
|
||||
abort(404)
|
||||
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
|
||||
if current_user.role == 'inspector':
|
||||
abort(403)
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
if area.facility_id not in cids:
|
||||
abort(403)
|
||||
return area
|
||||
|
||||
|
||||
@bp.route('/areas/<int:area_id>/qr.png')
|
||||
@login_required
|
||||
def area_qr_png(area_id):
|
||||
"""Return the area's QR code as a PNG image."""
|
||||
area = _area_for_qr_or_403(area_id)
|
||||
|
||||
created = not area.qr_token
|
||||
url = _area_qr_scan_url(area)
|
||||
if created:
|
||||
db.session.commit()
|
||||
|
||||
from flask import Response
|
||||
return Response(_qr_png_bytes(url), mimetype='image/png', headers={
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/areas/<int:area_id>/qr')
|
||||
@login_required
|
||||
def area_qr_card(area_id):
|
||||
"""Printable page: area name + facility + QR + public URL + instructions."""
|
||||
from app.utils.qr import qr_svg
|
||||
|
||||
area = _area_for_qr_or_403(area_id)
|
||||
|
||||
created = not area.qr_token
|
||||
scan_url = _area_qr_scan_url(area)
|
||||
if created:
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | area_qr_token_created | user=%s | area_id=%s',
|
||||
current_user.username, area_id)
|
||||
|
||||
return render_template('facilities/area_qr.html',
|
||||
area=area,
|
||||
facility=area.facility,
|
||||
scan_url=scan_url,
|
||||
svg=qr_svg(scan_url))
|
||||
|
||||
|
||||
@bp.route('/areas/<int:area_id>/qr/regenerate', methods=['POST'])
|
||||
@login_required
|
||||
def regenerate_area_qr(area_id):
|
||||
"""Rotate an area's QR token — invalidates every previously printed poster.
|
||||
|
||||
Allowed for admin/director, and for customers on their own assigned
|
||||
facilities. Project managers, auditors and inspectors cannot regenerate.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
area = _area_for_qr_or_403(area_id)
|
||||
if current_user.role not in ('admin', 'director', 'customer'):
|
||||
abort(403)
|
||||
|
||||
area.qr_token = secrets.token_urlsafe(32)
|
||||
db.session.commit()
|
||||
logger.info('FACILITIES | area_qr_token_regenerated | user=%s | area_id=%s',
|
||||
current_user.username, area_id)
|
||||
log_action(ACTION_UPDATE, 'Area', area.id, area.name,
|
||||
'QR token regenerated — previously printed QR posters are now invalid')
|
||||
flash('QR code regenerated. Previously printed posters no longer work — '
|
||||
'print and post the new code.', 'warning')
|
||||
return redirect(url_for('facilities.area_qr_card', area_id=area.id))
|
||||
|
||||
|
||||
# ── Bulk QR print / export (phase42) ──────────────────────────────────────────
|
||||
|
||||
@bp.route('/qr/print-all')
|
||||
@login_required
|
||||
def qr_print_all():
|
||||
"""Printable / selectable sheet of the QR codes the user can see.
|
||||
|
||||
Query params (all optional):
|
||||
?contract_id=<id> — limit to one contract; narrows the facility dropdown
|
||||
?facility_id=<id> — limit to a single facility
|
||||
?include_areas=1 — also render each facility's per-area QR codes
|
||||
|
||||
Inspectors have no QR management (403); customers are scoped to their
|
||||
assigned facilities; managers see all active facilities.
|
||||
"""
|
||||
if current_user.role == 'inspector':
|
||||
abort(403)
|
||||
|
||||
contract_id = request.args.get('contract_id', type=int)
|
||||
facility_id = request.args.get('facility_id', type=int)
|
||||
include_areas = request.args.get('include_areas') in ('1', 'true', 'on')
|
||||
|
||||
# Facilities in the viewer's scope.
|
||||
if current_user.role == 'customer':
|
||||
fids = get_customer_scope(current_user) or []
|
||||
scoped = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
||||
else:
|
||||
scoped = Facility.query.filter(Facility.active == True)
|
||||
scoped_facilities = scoped.order_by(Facility.name).all()
|
||||
|
||||
# Contract dropdown — only contracts present among the scoped facilities.
|
||||
contract_ids = {f.project_id for f in scoped_facilities if f.project_id}
|
||||
contracts = (Project.query
|
||||
.filter(Project.id.in_(contract_ids))
|
||||
.order_by(Project.name).all()) if contract_ids else []
|
||||
|
||||
# Facility dropdown — narrowed by the selected contract.
|
||||
facility_options = [f for f in scoped_facilities
|
||||
if not contract_id or f.project_id == contract_id]
|
||||
|
||||
# The rendered grid — apply the contract + facility filters.
|
||||
grid_facilities = facility_options
|
||||
if facility_id:
|
||||
grid_facilities = [f for f in grid_facilities if f.id == facility_id]
|
||||
|
||||
# Ensure every rendered facility (and area, if requested) has a token so
|
||||
# its qr.png renders; collect areas keyed by facility id.
|
||||
changed = False
|
||||
areas_by_facility = {}
|
||||
for f in grid_facilities:
|
||||
if not f.qr_token:
|
||||
f.ensure_qr_token()
|
||||
changed = True
|
||||
if include_areas:
|
||||
fa = f.areas.order_by(Area.name).all()
|
||||
for a in fa:
|
||||
if not a.qr_token:
|
||||
a.ensure_qr_token()
|
||||
changed = True
|
||||
areas_by_facility[f.id] = fa
|
||||
if changed:
|
||||
db.session.commit()
|
||||
|
||||
selected_contract = db.session.get(Project, contract_id) if contract_id else None
|
||||
return render_template('facilities/qr_print_all.html',
|
||||
facilities=grid_facilities,
|
||||
areas_by_facility=areas_by_facility,
|
||||
include_areas=include_areas,
|
||||
contracts=contracts,
|
||||
facility_options=facility_options,
|
||||
selected_contract=selected_contract,
|
||||
selected_contract_id=contract_id,
|
||||
selected_facility_id=facility_id)
|
||||
|
||||
|
||||
@bp.route('/qr/export-pdf', methods=['POST'])
|
||||
@login_required
|
||||
def qr_export_pdf():
|
||||
"""Export the selected facility + area QR codes to a single PDF.
|
||||
|
||||
Selection arrives as repeated `facility_ids` / `area_ids` form fields.
|
||||
Scope is enforced per-id via the same helpers as the QR pages, so a
|
||||
customer can never export a code outside their assigned facilities.
|
||||
"""
|
||||
if current_user.role == 'inspector':
|
||||
abort(403)
|
||||
|
||||
facility_ids = request.form.getlist('facility_ids', type=int)
|
||||
area_ids = request.form.getlist('area_ids', type=int)
|
||||
|
||||
if not facility_ids and not area_ids:
|
||||
flash('Select at least one QR code to export.', 'warning')
|
||||
return redirect(request.referrer or url_for('facilities.qr_print_all'))
|
||||
|
||||
items = []
|
||||
for fid in facility_ids:
|
||||
facility = _facility_for_qr_or_403(fid) # 403 if out of scope
|
||||
url = _qr_scan_url(facility)
|
||||
items.append({
|
||||
'title': facility.name,
|
||||
'subtitle': facility.project.name if facility.project else None,
|
||||
'caption': 'Facility · Report a problem & view recent quality',
|
||||
'png': _qr_png_bytes(url),
|
||||
'_sort': ((facility.name or '').lower(), 0, ''),
|
||||
})
|
||||
for aid in area_ids:
|
||||
area = _area_for_qr_or_403(aid) # 403 if out of scope
|
||||
url = _area_qr_scan_url(area)
|
||||
fac_name = area.facility.name if area.facility else ''
|
||||
items.append({
|
||||
'title': area.name,
|
||||
'subtitle': fac_name or None,
|
||||
'caption': 'Area · Report a problem & view recent quality',
|
||||
'png': _qr_png_bytes(url),
|
||||
'_sort': (fac_name.lower(), 1, (area.name or '').lower()),
|
||||
})
|
||||
|
||||
# Persist any tokens minted by ensure_qr_token() above.
|
||||
db.session.commit()
|
||||
|
||||
# Group each facility with its own areas: facility card first, then areas.
|
||||
items.sort(key=lambda x: x['_sort'])
|
||||
|
||||
from app.utils.pdf_export import generate_qr_codes_pdf
|
||||
summary = f'{len(facility_ids)} facilit' + ('y' if len(facility_ids) == 1 else 'ies')
|
||||
summary += f', {len(area_ids)} area' + ('' if len(area_ids) == 1 else 's')
|
||||
pdf_bytes = generate_qr_codes_pdf(items, filter_summary=summary)
|
||||
|
||||
logger.info('FACILITIES | qr_export_pdf | user=%s | facilities=%s | areas=%s',
|
||||
current_user.username, len(facility_ids), len(area_ids))
|
||||
log_action(ACTION_EXPORT, 'Facility', 0, 'QR Codes',
|
||||
f'exported {len(facility_ids)} facility + {len(area_ids)} area QR codes to PDF')
|
||||
|
||||
from flask import Response
|
||||
return Response(pdf_bytes, mimetype='application/pdf', headers={
|
||||
'Content-Disposition': 'attachment; filename="qr_codes.pdf"',
|
||||
})
|
||||
|
||||
+238
-39
@@ -5,7 +5,13 @@ Public facility QR scan page (phase38).
|
||||
|
||||
`GET /f/<token>` — public, tokenized, NO login (same authorization model as
|
||||
vendor work orders, rule 89: the unguessable token IS the credential). Shows a
|
||||
read-only, counts-and-scores-only snapshot of one facility:
|
||||
read-only, counts-and-scores-only snapshot of one facility.
|
||||
|
||||
`GET /f/area/<token>` (phase42) — the same page scoped to a single area, so a
|
||||
code posted inside one restroom reports on that restroom. Metrics are scoped by
|
||||
`Inspection.area_id` / `Issue.area_id`.
|
||||
|
||||
Both pages show:
|
||||
|
||||
* summary stats (90 days): completed inspections, average score,
|
||||
resolved issues, last inspection date
|
||||
@@ -25,7 +31,8 @@ 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, redirect, request, url_for, abort
|
||||
from flask import (Blueprint, render_template, redirect, request, url_for,
|
||||
abort, flash)
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
@@ -33,6 +40,8 @@ from app import db, limiter
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.issue import Issue
|
||||
from app.utils.forms import PublicIssueReportForm
|
||||
from app.utils.notifications import notify_by_matrix
|
||||
from app.utils.sla import sla_status
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
from app.utils.time_utils import now_eastern
|
||||
@@ -43,6 +52,72 @@ bp = Blueprint('facility_qr', __name__, url_prefix='/f')
|
||||
|
||||
SEVERITY_ORDER = ('critical', 'high', 'medium', 'low')
|
||||
|
||||
#: Maximum number of photos an occupant may attach to a public report.
|
||||
MAX_REPORT_PHOTOS = 5
|
||||
|
||||
|
||||
def _save_report_photos(file_list):
|
||||
"""Save up to MAX_REPORT_PHOTOS uploaded photos from a public report.
|
||||
|
||||
Returns (photo_path, extra_paths) where photo_path is the primary evidence
|
||||
photo (or None) and extra_paths is a list of the remaining paths (or None).
|
||||
Splitting this way mirrors the Issue photo model: the first photo lives in
|
||||
`photo_path`, the rest in `mobile_photo_paths` so they all render together
|
||||
under "Photo Evidence" on the web (rule 44 — never `result_photos`).
|
||||
|
||||
Writes go through `_save_photo`, which validates magic bytes and routes to
|
||||
the active storage backend (see app/utils/storage.py).
|
||||
"""
|
||||
from app.routes.inspections import _save_photo
|
||||
saved = []
|
||||
for f in (file_list or [])[:MAX_REPORT_PHOTOS]:
|
||||
path = _save_photo(f, subfolder='issue_photos')
|
||||
if path:
|
||||
saved.append(path)
|
||||
photo_path = saved[0] if saved else None
|
||||
extra_paths = saved[1:] if len(saved) > 1 else None
|
||||
return photo_path, extra_paths
|
||||
|
||||
|
||||
def _facility_by_token_or_404(token):
|
||||
"""Resolve an ACTIVE facility from its QR token, else 404."""
|
||||
if not token:
|
||||
abort(404)
|
||||
facility = Facility.query.filter_by(qr_token=token).first()
|
||||
if facility is None or not facility.active:
|
||||
abort(404)
|
||||
return facility
|
||||
|
||||
|
||||
def _area_by_token_or_404(token):
|
||||
"""Resolve an area (and its ACTIVE facility) from the area's QR token."""
|
||||
if not token:
|
||||
abort(404)
|
||||
area = Area.query.filter_by(qr_token=token).first()
|
||||
if area is None:
|
||||
abort(404)
|
||||
facility = db.session.get(Facility, area.facility_id)
|
||||
if facility is None or not facility.active:
|
||||
abort(404)
|
||||
return area, facility
|
||||
|
||||
|
||||
def _build_report_description(form, prefix):
|
||||
"""Fold optional reporter identity + location into the issue description.
|
||||
|
||||
The public reporter is not a User, so `reported_by` stays NULL and this is
|
||||
the only place their name/contact is recorded.
|
||||
"""
|
||||
parts = [prefix]
|
||||
if form.area_label.data:
|
||||
parts.append(f'Location: {form.area_label.data.strip()}')
|
||||
reporter_bits = [b for b in (form.reporter_name.data, form.reporter_contact.data) if b]
|
||||
if reporter_bits:
|
||||
parts.append('Reporter: ' + ' — '.join(b.strip() for b in reporter_bits))
|
||||
parts.append('')
|
||||
parts.append(form.description.data.strip())
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def _can_view_full(facility):
|
||||
"""True when the logged-in scanner's role scope covers this facility."""
|
||||
@@ -57,27 +132,37 @@ def _can_view_full(facility):
|
||||
return False
|
||||
|
||||
|
||||
@bp.route('/<token>')
|
||||
@limiter.limit('60 per hour')
|
||||
def scan(token):
|
||||
facility = Facility.query.filter_by(qr_token=token).first()
|
||||
if facility is None or not facility.active:
|
||||
abort(404)
|
||||
def _build_snapshot(facility, area=None):
|
||||
"""Assemble the public snapshot for a facility, or for one area of it.
|
||||
|
||||
When `area` is given every metric is scoped to that area via
|
||||
`Inspection.area_id` / `Issue.area_id`; otherwise the facility-wide math is
|
||||
used, unchanged from phase38. Returns the template context (minus `token`).
|
||||
"""
|
||||
now = now_eastern()
|
||||
d30 = now - timedelta(days=30)
|
||||
d60 = now - timedelta(days=60)
|
||||
d90 = now - timedelta(days=90)
|
||||
|
||||
if area is not None:
|
||||
insp_scope = (Inspection.area_id == area.id,)
|
||||
issue_q = Issue.query.filter(Issue.area_id == area.id)
|
||||
else:
|
||||
insp_scope = (Inspection.facility_id == facility.id,)
|
||||
issue_q = (Issue.query
|
||||
.outerjoin(Area, Issue.area_id == Area.id)
|
||||
.filter(or_(Issue.facility_id == facility.id,
|
||||
Area.facility_id == facility.id)))
|
||||
|
||||
completed = Inspection.query.filter(
|
||||
Inspection.facility_id == facility.id,
|
||||
*insp_scope,
|
||||
Inspection.status == 'completed',
|
||||
)
|
||||
|
||||
# ── Summary stats (90 days) ───────────────────────────────────────────
|
||||
total_90 = completed.filter(Inspection.inspection_date >= d90).count()
|
||||
avg_90 = db.session.query(func.avg(Inspection.overall_score)).filter(
|
||||
Inspection.facility_id == facility.id,
|
||||
*insp_scope,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= d90,
|
||||
Inspection.overall_score.isnot(None),
|
||||
@@ -91,7 +176,7 @@ def scan(token):
|
||||
# ── Score trend: last 30 days vs prior 30 (mirrors send_score_alerts) ─
|
||||
def _avg_between(start, end):
|
||||
return db.session.query(func.avg(Inspection.overall_score)).filter(
|
||||
Inspection.facility_id == facility.id,
|
||||
*insp_scope,
|
||||
Inspection.status == 'completed',
|
||||
Inspection.overall_score.isnot(None),
|
||||
Inspection.inspection_date >= start,
|
||||
@@ -104,11 +189,6 @@ def scan(token):
|
||||
if (avg_cur is not None and avg_prior is not None) else None
|
||||
|
||||
# ── Open issues: counts by severity + SLA state (counts only) ─────────
|
||||
issue_q = (Issue.query
|
||||
.outerjoin(Area, Issue.area_id == Area.id)
|
||||
.filter(or_(Issue.facility_id == facility.id,
|
||||
Area.facility_id == facility.id)))
|
||||
|
||||
open_issues = issue_q.filter(
|
||||
Issue.status.in_(('open', 'in_progress'))).all()
|
||||
severity_counts = {s: 0 for s in SEVERITY_ORDER}
|
||||
@@ -130,13 +210,9 @@ def scan(token):
|
||||
Issue.resolved_at >= d90,
|
||||
).count()
|
||||
|
||||
logger.info('FACILITY QR SCAN | facility_id=%s | authenticated=%s',
|
||||
facility.id, current_user.is_authenticated)
|
||||
|
||||
return render_template(
|
||||
'facility_qr/view.html',
|
||||
token = token,
|
||||
return dict(
|
||||
facility = facility,
|
||||
area = area,
|
||||
contract = facility.project,
|
||||
total_90 = total_90,
|
||||
avg_90 = float(avg_90) if avg_90 is not None else None,
|
||||
@@ -146,7 +222,7 @@ def scan(token):
|
||||
avg_cur = float(avg_cur) if avg_cur is not None else None,
|
||||
avg_prior = float(avg_prior) if avg_prior is not None else None,
|
||||
open_total = len(open_issues),
|
||||
severity_counts = severity_counts,
|
||||
severity_counts = severity_counts,
|
||||
severity_order = SEVERITY_ORDER,
|
||||
sla_at_risk = sla_at_risk,
|
||||
sla_breached = sla_breached,
|
||||
@@ -157,6 +233,39 @@ def scan(token):
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<token>')
|
||||
@limiter.limit('60 per hour')
|
||||
def scan(token):
|
||||
facility = _facility_by_token_or_404(token)
|
||||
|
||||
logger.info('FACILITY QR SCAN | facility_id=%s | authenticated=%s',
|
||||
facility.id, current_user.is_authenticated)
|
||||
|
||||
return render_template(
|
||||
'facility_qr/view.html',
|
||||
token = token,
|
||||
form = PublicIssueReportForm(),
|
||||
**_build_snapshot(facility),
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/area/<token>')
|
||||
@limiter.limit('60 per hour')
|
||||
def area_scan(token):
|
||||
"""Public snapshot for a single area (phase42)."""
|
||||
area, facility = _area_by_token_or_404(token)
|
||||
|
||||
logger.info('AREA QR SCAN | area_id=%s | facility_id=%s | authenticated=%s',
|
||||
area.id, facility.id, current_user.is_authenticated)
|
||||
|
||||
return render_template(
|
||||
'facility_qr/area.html',
|
||||
token = token,
|
||||
form = PublicIssueReportForm(),
|
||||
**_build_snapshot(facility, area=area),
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<token>/report', methods=['POST'])
|
||||
@limiter.limit('5 per hour')
|
||||
def report(token):
|
||||
@@ -165,42 +274,132 @@ def report(token):
|
||||
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.
|
||||
|
||||
phase42: accepts up to 5 photos, an optional location label, and optional
|
||||
reporter identity, on top of the description + severity taken previously.
|
||||
"""
|
||||
facility = Facility.query.filter_by(qr_token=token).first()
|
||||
if facility is None or not facility.active:
|
||||
abort(404)
|
||||
facility = _facility_by_token_or_404(token)
|
||||
form = PublicIssueReportForm()
|
||||
|
||||
# Honeypot — bots fill this field, humans leave it blank
|
||||
if request.form.get('website', '').strip():
|
||||
if form.website.data:
|
||||
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 form.validate_on_submit():
|
||||
# Re-render with validation errors and the snapshot intact.
|
||||
return render_template(
|
||||
'facility_qr/view.html',
|
||||
token = token,
|
||||
form = form,
|
||||
**_build_snapshot(facility),
|
||||
), 400
|
||||
|
||||
if not description:
|
||||
return redirect(url_for('facility_qr.scan', token=token))
|
||||
severity = form.severity.data or 'medium'
|
||||
if severity not in ('low', 'medium', 'high'):
|
||||
severity = 'medium'
|
||||
|
||||
photo_path, extra_photos = _save_report_photos(form.photos.data)
|
||||
description = _build_report_description(form, '[Reported via facility QR code]')
|
||||
|
||||
issue = Issue(
|
||||
facility_id = facility.id,
|
||||
area_id = None,
|
||||
severity = severity,
|
||||
description = description,
|
||||
photo_path = photo_path,
|
||||
mobile_photo_paths = extra_photos,
|
||||
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)
|
||||
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
|
||||
logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s photos=%s',
|
||||
facility.id, issue.id, severity, _photo_count)
|
||||
|
||||
# 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)
|
||||
# Notify staff via the notification matrix (same event as Issues → Create).
|
||||
# NOTE: title/body are REQUIRED positional args. The phase38 call omitted
|
||||
# them, so every public QR report raised TypeError into the except below and
|
||||
# nobody was ever notified — see MT3_DEPLOY.md §1.
|
||||
_snippet = form.description.data.strip()
|
||||
notify_by_matrix(
|
||||
event_type = 'issue_created',
|
||||
title = f'New Issue #{issue.id} at {facility.name} (QR report)',
|
||||
body = (
|
||||
f'A problem was reported at {facility.name} via the facility QR code. '
|
||||
f'Description: {_snippet[:120]}{"…" if len(_snippet) > 120 else ""}'
|
||||
),
|
||||
link = url_for('issues.view', issue_id=issue.id),
|
||||
issue_id = issue.id,
|
||||
facility_id = facility.id,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
|
||||
|
||||
|
||||
@bp.route('/area/<token>/report', methods=['POST'])
|
||||
@limiter.limit('5 per hour')
|
||||
def area_report(token):
|
||||
"""Public occupant issue report submitted from an area QR page (phase42).
|
||||
|
||||
The area is known from the token, so `area_id` is set directly — staff see
|
||||
exactly which room the report came from without the occupant describing it.
|
||||
"""
|
||||
area, facility = _area_by_token_or_404(token)
|
||||
form = PublicIssueReportForm()
|
||||
|
||||
if form.website.data:
|
||||
logger.warning('AREA QR REPORT | honeypot triggered | area_id=%s', area.id)
|
||||
return redirect(url_for('facility_qr.area_scan', token=token) + '?reported=1')
|
||||
|
||||
if not form.validate_on_submit():
|
||||
return render_template(
|
||||
'facility_qr/area.html',
|
||||
token = token,
|
||||
form = form,
|
||||
**_build_snapshot(facility, area=area),
|
||||
), 400
|
||||
|
||||
severity = form.severity.data or 'medium'
|
||||
if severity not in ('low', 'medium', 'high'):
|
||||
severity = 'medium'
|
||||
|
||||
photo_path, extra_photos = _save_report_photos(form.photos.data)
|
||||
description = _build_report_description(
|
||||
form, f'[Reported via area QR code — {area.name}]')
|
||||
|
||||
issue = Issue(
|
||||
facility_id = facility.id,
|
||||
area_id = area.id,
|
||||
severity = severity,
|
||||
description = description,
|
||||
photo_path = photo_path,
|
||||
mobile_photo_paths = extra_photos,
|
||||
status = 'open',
|
||||
reported_by = None, # anonymous public submission
|
||||
)
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
|
||||
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
|
||||
logger.info('AREA QR REPORT | area_id=%s facility_id=%s issue_id=%s severity=%s photos=%s',
|
||||
area.id, facility.id, issue.id, severity, _photo_count)
|
||||
|
||||
_snippet = form.description.data.strip()
|
||||
notify_by_matrix(
|
||||
event_type = 'issue_created',
|
||||
title = f'New Issue #{issue.id} at {facility.name} — {area.name} (QR report)',
|
||||
body = (
|
||||
f'A problem was reported in {area.name} at {facility.name} via the area '
|
||||
f'QR code. Description: {_snippet[:120]}{"…" if len(_snippet) > 120 else ""}'
|
||||
),
|
||||
link = url_for('issues.view', issue_id=issue.id),
|
||||
issue_id = issue.id,
|
||||
facility_id = facility.id,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('facility_qr.area_scan', token=token) + '?reported=1')
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
app/routes/inspection_schedules.py
|
||||
-----------------------------------
|
||||
CRUD management for recurring InspectionSchedule configs + a cron-triggered
|
||||
materialisation endpoint (phase34).
|
||||
materialisation/reminder endpoint (phase34, extended phase43).
|
||||
|
||||
Two modes per schedule (phase43):
|
||||
auto — cron materialises an in_progress Inspection at next_run_at and notifies
|
||||
the inspector. This is phase34's behaviour and remains the default.
|
||||
plan — nothing is materialised; the assigned inspector clicks "Start" on the
|
||||
schedules page, which creates the Inspection linked back to the
|
||||
schedule. Reminders fire the day before, on the due date, and once
|
||||
overdue (to admin/director). Completing it rolls the schedule forward.
|
||||
|
||||
The /run cron endpoint does both: it materialises due `auto` schedules AND
|
||||
dispatches reminders for `plan` schedules, so the existing crontab line needs no
|
||||
change.
|
||||
|
||||
Management is admin / director / project_manager (@project_manager_required),
|
||||
mirroring who may start inspections. The /run route is token-protected with the
|
||||
@@ -38,6 +50,7 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('inspection_schedules', __name__, url_prefix='/inspection-schedules')
|
||||
|
||||
_FREQUENCIES = ('daily', 'weekly', 'monthly', 'quarterly')
|
||||
_MODES = ('auto', 'plan')
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -69,6 +82,29 @@ def _active_inspectors():
|
||||
).order_by(User.full_name, User.username).all()
|
||||
|
||||
|
||||
def _notify_assignee(schedule: InspectionSchedule, reassigned: bool = False):
|
||||
"""Tell the assigned inspector a schedule was assigned (or reassigned) to them.
|
||||
|
||||
No-op when there is no active inspector. Caller commits. (phase43)
|
||||
"""
|
||||
inspector = schedule.inspector
|
||||
if not inspector or not inspector.active:
|
||||
return
|
||||
fac = schedule.facility.name if schedule.facility else '—'
|
||||
tpl = schedule.template.name if schedule.template else '—'
|
||||
verb = 'reassigned to you' if reassigned else 'assigned to you'
|
||||
due = schedule.next_run_at.strftime('%b %d, %Y') if schedule.next_run_at else 'soon'
|
||||
notify(
|
||||
inspector,
|
||||
title = f'Scheduled inspection {verb} — {fac}',
|
||||
body = (f'A "{tpl}" inspection at {fac} has been {verb} '
|
||||
f'({schedule.frequency_label.lower()}), due {due}.'),
|
||||
link = url_for('inspection_schedules.index'),
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
)
|
||||
|
||||
|
||||
def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
||||
"""Create an in_progress Inspection from a schedule and notify the inspector.
|
||||
|
||||
@@ -82,7 +118,8 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
||||
inspector_id = schedule.inspector_id,
|
||||
inspection_date = when,
|
||||
status = 'in_progress',
|
||||
notes = None,
|
||||
notes = schedule.notes,
|
||||
inspection_schedule_id = schedule.id, # phase43 — link back to the plan
|
||||
)
|
||||
db.session.add(inspection)
|
||||
db.session.flush() # assign inspection.id without committing
|
||||
@@ -106,13 +143,29 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def index():
|
||||
schedules = InspectionSchedule.query.order_by(
|
||||
InspectionSchedule.active.desc(), InspectionSchedule.name
|
||||
"""Schedule list.
|
||||
|
||||
phase43: no longer @project_manager_required — an inspector must be able to
|
||||
see and Start their own plan-mode schedules. Inspectors see ONLY their own;
|
||||
customers are barred; managers see everything, exactly as before. All
|
||||
mutating routes below keep @project_manager_required.
|
||||
"""
|
||||
if current_user.role == 'customer':
|
||||
abort(403)
|
||||
|
||||
q = InspectionSchedule.query
|
||||
if current_user.role == 'inspector':
|
||||
q = q.filter(InspectionSchedule.inspector_id == current_user.id)
|
||||
|
||||
schedules = q.order_by(
|
||||
InspectionSchedule.active.desc(),
|
||||
InspectionSchedule.next_run_at.asc(),
|
||||
InspectionSchedule.name,
|
||||
).all()
|
||||
now = now_eastern()
|
||||
return render_template('inspection_schedules/index.html',
|
||||
schedules=schedules, now=now_eastern())
|
||||
schedules=schedules, now=now, today=now.date())
|
||||
|
||||
|
||||
def _form_choices():
|
||||
@@ -135,6 +188,8 @@ def create():
|
||||
area_id = request.form.get('area_id', type=int) or None
|
||||
inspector_id = request.form.get('inspector_id', type=int)
|
||||
frequency = request.form.get('frequency', 'weekly')
|
||||
mode = request.form.get('mode', 'auto')
|
||||
notes = request.form.get('notes', '').strip() or None
|
||||
|
||||
errors = []
|
||||
if not name:
|
||||
@@ -147,6 +202,8 @@ def create():
|
||||
errors.append('Please choose a valid inspector.')
|
||||
if frequency not in _FREQUENCIES:
|
||||
errors.append('Invalid frequency.')
|
||||
if mode not in _MODES:
|
||||
errors.append('Invalid mode.')
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
@@ -163,6 +220,8 @@ def create():
|
||||
area_id = area_id,
|
||||
inspector_id = inspector_id,
|
||||
frequency = frequency,
|
||||
mode = mode,
|
||||
notes = notes,
|
||||
active = True,
|
||||
created_by = current_user.id,
|
||||
created_at = now_eastern(),
|
||||
@@ -171,7 +230,12 @@ def create():
|
||||
db.session.add(schedule)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'InspectionSchedule', schedule.id, schedule.name,
|
||||
f'frequency={frequency}; template_id={template_id}; facility_id={facility_id}')
|
||||
f'frequency={frequency}; mode={mode}; template_id={template_id}; '
|
||||
f'facility_id={facility_id}')
|
||||
# phase43: tell the inspector it's theirs (plan mode has no materialised
|
||||
# inspection to announce itself).
|
||||
_notify_assignee(schedule)
|
||||
db.session.commit()
|
||||
flash(f'Inspection schedule "{schedule.name}" created.', 'success')
|
||||
return redirect(url_for('inspection_schedules.index'))
|
||||
|
||||
@@ -191,6 +255,7 @@ def edit(schedule_id):
|
||||
templates, facilities, inspectors = _form_choices()
|
||||
|
||||
if request.method == 'POST':
|
||||
old_inspector_id = schedule.inspector_id
|
||||
schedule.name = request.form.get('name', '').strip() or schedule.name
|
||||
template_id = request.form.get('template_id', type=int)
|
||||
facility_id = request.form.get('facility_id', type=int)
|
||||
@@ -206,13 +271,27 @@ def edit(schedule_id):
|
||||
schedule.area_id = request.form.get('area_id', type=int) or None
|
||||
if frequency in _FREQUENCIES:
|
||||
schedule.frequency = frequency
|
||||
mode = request.form.get('mode', schedule.mode)
|
||||
if mode in _MODES:
|
||||
schedule.mode = mode
|
||||
schedule.notes = request.form.get('notes', '').strip() or None
|
||||
schedule.active = bool(request.form.get('active'))
|
||||
# Recompute the next run from now against the (possibly changed) cadence.
|
||||
schedule.next_run_at = _compute_next_run(schedule.frequency)
|
||||
# New occurrence -> the previous occurrence's reminders no longer apply.
|
||||
schedule.advance_notified = False
|
||||
schedule.due_notified = False
|
||||
schedule.overdue_notified = False
|
||||
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
|
||||
f'frequency={schedule.frequency}; active={schedule.active}')
|
||||
f'frequency={schedule.frequency}; mode={schedule.mode}; '
|
||||
f'active={schedule.active}')
|
||||
|
||||
# phase43: notify on (re)assignment to a different inspector.
|
||||
if schedule.active and schedule.inspector_id and schedule.inspector_id != old_inspector_id:
|
||||
_notify_assignee(schedule, reassigned=True)
|
||||
db.session.commit()
|
||||
flash(f'Inspection schedule "{schedule.name}" updated.', 'success')
|
||||
return redirect(url_for('inspection_schedules.index'))
|
||||
|
||||
@@ -258,6 +337,55 @@ def run_now(schedule_id):
|
||||
return redirect(url_for('inspection_schedules.index'))
|
||||
|
||||
|
||||
# ── Start (plan mode: create the planned inspection and open the execute flow) ─
|
||||
|
||||
@bp.route('/<int:schedule_id>/start')
|
||||
@login_required
|
||||
def start(schedule_id):
|
||||
"""Start the inspection this schedule plans for (phase43).
|
||||
|
||||
Deliberately NOT @project_manager_required: the whole point is that the
|
||||
assigned inspector starts their own scheduled work. Customers are barred;
|
||||
an inspector may only start their own schedule; managers may start any.
|
||||
"""
|
||||
schedule = db.session.get(InspectionSchedule, schedule_id)
|
||||
if schedule is None:
|
||||
abort(404)
|
||||
if current_user.role == 'customer':
|
||||
abort(403)
|
||||
if current_user.role == 'inspector' and schedule.inspector_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
if not schedule.active:
|
||||
flash('This schedule is no longer active.', 'warning')
|
||||
return redirect(url_for('inspection_schedules.index'))
|
||||
|
||||
template = schedule.template
|
||||
if template is None or not template.get_form_schema():
|
||||
flash('The template for this schedule has no form fields yet.', 'warning')
|
||||
return redirect(url_for('inspection_schedules.index'))
|
||||
|
||||
inspection = Inspection(
|
||||
template_id = schedule.template_id,
|
||||
facility_id = schedule.facility_id,
|
||||
area_id = schedule.area_id,
|
||||
inspector_id = current_user.id,
|
||||
inspection_date = now_eastern(),
|
||||
status = 'in_progress',
|
||||
notes = schedule.notes,
|
||||
inspection_schedule_id = schedule.id,
|
||||
)
|
||||
db.session.add(inspection)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'Inspection', inspection.id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
f'started from inspection_schedule_id={schedule.id}')
|
||||
logger.info('INSPECTION SCHEDULE | start | schedule=%s | inspection=%s | by=%s',
|
||||
schedule.id, inspection.id, current_user.username)
|
||||
flash('Inspection started from schedule. Complete and submit the form below.', 'info')
|
||||
return redirect(url_for('inspections.execute', inspection_id=inspection.id))
|
||||
|
||||
|
||||
# ── Cron endpoint ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/run', methods=['POST'])
|
||||
@@ -276,7 +404,10 @@ def run():
|
||||
|
||||
now = now_eastern()
|
||||
schedules = InspectionSchedule.query.filter_by(active=True).all()
|
||||
due = [s for s in schedules if s.next_run_at is None or s.next_run_at <= now]
|
||||
# Only 'auto' schedules materialise. 'plan' schedules wait for the inspector
|
||||
# to click Start; they get reminders instead (below).
|
||||
auto = [s for s in schedules if s.mode != 'plan']
|
||||
due = [s for s in auto if s.next_run_at is None or s.next_run_at <= now]
|
||||
|
||||
created = 0
|
||||
for schedule in due:
|
||||
@@ -295,5 +426,80 @@ def run():
|
||||
schedule.id, exc)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('INSPECTION SCHEDULES CRON | due=%s | created=%s', len(due), created)
|
||||
return jsonify({'ok': True, 'due': len(due), 'created': created})
|
||||
sent = _dispatch_reminders([s for s in schedules if s.mode == 'plan'], now)
|
||||
|
||||
logger.info('INSPECTION SCHEDULES CRON | due=%s | created=%s | reminders=%s',
|
||||
len(due), created, sent)
|
||||
return jsonify({'ok': True, 'due': len(due), 'created': created, 'reminders': sent})
|
||||
|
||||
|
||||
def _dispatch_reminders(plans, now):
|
||||
"""Advance / due / overdue reminders for plan-mode schedules (phase43).
|
||||
|
||||
Each fires at most once per occurrence via the *_notified flags, which reset
|
||||
when the schedule rolls forward in fulfill(). Commits.
|
||||
"""
|
||||
today = now.date()
|
||||
sent = {'advance': 0, 'due': 0, 'overdue': 0}
|
||||
|
||||
managers = User.query.filter(
|
||||
User.role.in_(['admin', 'director']), User.active.is_(True)
|
||||
).all()
|
||||
|
||||
for s in plans:
|
||||
if s.next_run_at is None:
|
||||
continue
|
||||
due_date = s.next_run_at.date()
|
||||
inspector = s.inspector
|
||||
link = url_for('inspection_schedules.index')
|
||||
fac_name = s.facility.name if s.facility else '—'
|
||||
tpl_name = s.template.name if s.template else '—'
|
||||
|
||||
# Advance reminder — the day before it's due
|
||||
if (not s.advance_notified and inspector and inspector.active
|
||||
and due_date == today + timedelta(days=1)):
|
||||
notify(
|
||||
inspector,
|
||||
title = f'Inspection due tomorrow — {fac_name}',
|
||||
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
|
||||
f'is scheduled for tomorrow ({due_date:%b %d, %Y}).'),
|
||||
link = link,
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
)
|
||||
s.advance_notified = True
|
||||
sent['advance'] += 1
|
||||
|
||||
# Due reminder — on/after the due date
|
||||
if (not s.due_notified and inspector and inspector.active
|
||||
and due_date <= today):
|
||||
notify(
|
||||
inspector,
|
||||
title = f'Inspection due today — {fac_name}',
|
||||
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
|
||||
f'({due_date:%b %d, %Y}). Please complete it.'),
|
||||
link = link,
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
)
|
||||
s.due_notified = True
|
||||
sent['due'] += 1
|
||||
|
||||
# Overdue alert — past due and still not fulfilled -> managers
|
||||
if not s.overdue_notified and due_date < today:
|
||||
for m in managers:
|
||||
notify(
|
||||
m,
|
||||
title = f'Overdue scheduled inspection — {fac_name}',
|
||||
body = (f'The "{tpl_name}" inspection at {fac_name} assigned to '
|
||||
f'{inspector.display_name if inspector else "—"} was due '
|
||||
f'{due_date:%b %d, %Y} and has not been completed.'),
|
||||
link = link,
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
)
|
||||
s.overdue_notified = True
|
||||
sent['overdue'] += 1
|
||||
|
||||
db.session.commit()
|
||||
return sent
|
||||
|
||||
+24
-14
@@ -59,11 +59,10 @@ def _save_photo(file_obj, subfolder='inspection_photos'):
|
||||
file_obj.seek(0)
|
||||
if not any(header.startswith(m) for m in _IMAGE_MAGIC):
|
||||
return None
|
||||
filename = f"{uuid.uuid4().hex}.{ext}"
|
||||
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
file_obj.save(os.path.join(dest_dir, filename))
|
||||
return f"uploads/{subfolder}/{filename}"
|
||||
# Write via the active storage backend (local disk or R2). Key format
|
||||
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
|
||||
from app.utils import storage
|
||||
return storage.save(file_obj, subfolder)
|
||||
|
||||
|
||||
def _collect_form_responses(form_fields, existing_responses=None):
|
||||
@@ -581,6 +580,20 @@ def execute(inspection_id):
|
||||
inspection_id = inspection.id,
|
||||
facility_id = inspection.facility_id,
|
||||
)
|
||||
# Fulfill the originating schedule, if any: roll a recurring plan's
|
||||
# due date forward and reset its reminder flags. Staged in the same
|
||||
# atomic commit below. (phase43)
|
||||
if inspection.inspection_schedule_id:
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.routes.inspection_schedules import _compute_next_run
|
||||
sched = db.session.get(InspectionSchedule, inspection.inspection_schedule_id)
|
||||
if sched is not None:
|
||||
sched.fulfill(next_run_fn=_compute_next_run)
|
||||
current_app.logger.info(
|
||||
'INSPECTION SCHEDULE | fulfilled | schedule=%s | inspection=%s | next_due=%s',
|
||||
sched.id, inspection.id, sched.next_run_at,
|
||||
)
|
||||
|
||||
db.session.commit() # Single atomic commit: inspection fields + notification rows
|
||||
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
@@ -596,7 +609,7 @@ def execute(inspection_id):
|
||||
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
||||
|
||||
staff_for_flag_issue = User.query.filter(
|
||||
User.role.in_(['admin', 'director', 'inspector', 'project_manager']),
|
||||
User.role.in_(['director', 'inspector', 'project_manager', 'auditor']),
|
||||
User.active == True,
|
||||
).order_by(User.full_name, User.username).all()
|
||||
|
||||
@@ -1323,15 +1336,12 @@ def delete(inspection_id):
|
||||
db.session.delete(inspection)
|
||||
db.session.commit()
|
||||
|
||||
# Remove orphaned photo files from the active storage backend — best-effort.
|
||||
# (Also fixes the previous double-'static' path that normalized to
|
||||
# app/static/static/... and never actually deleted anything.)
|
||||
from app.utils import storage
|
||||
for rel_path in photo_paths:
|
||||
abs_path = os.path.normpath(
|
||||
os.path.join(current_app.config['UPLOAD_FOLDER'], '..', 'static', rel_path)
|
||||
)
|
||||
try:
|
||||
if os.path.isfile(abs_path):
|
||||
os.remove(abs_path)
|
||||
except OSError:
|
||||
pass
|
||||
storage.delete(rel_path)
|
||||
|
||||
current_app.logger.info(
|
||||
'INSPECTION DELETED | id=%s | facility="%s" | template="%s" | '
|
||||
|
||||
+23
-20
@@ -15,7 +15,8 @@ from app.models.notification import (
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||
)
|
||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||
from app.utils.decorators import supervisor_required, project_manager_required
|
||||
from app.utils.decorators import (supervisor_required, project_manager_required,
|
||||
issue_manager_required)
|
||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||
from app.tenancy.gates import quota_soft_check
|
||||
@@ -346,7 +347,7 @@ def index():
|
||||
|
||||
# Staff for quick-assign dropdown — same roles as the full issue form
|
||||
staff = User.query.filter(
|
||||
User.role.in_(['admin', 'director', 'inspector']), User.active == True
|
||||
User.role.in_(['director', 'inspector', 'auditor']), User.active == True
|
||||
).order_by(User.username).all()
|
||||
|
||||
# Reporters dropdown — users who have actually filed at least one issue
|
||||
@@ -421,7 +422,14 @@ def view(issue_id):
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
|
||||
form = IssueUpdateForm(obj=issue)
|
||||
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
|
||||
# Preserve any pre-existing assignee who is no longer in the assignable set
|
||||
# (e.g. an admin assigned before admins were removed from the dropdown) so
|
||||
# saving the form doesn't silently unassign them.
|
||||
if issue.assigned_to and issue.assigned_to not in [u.id for u in staff]:
|
||||
current_assignee = db.session.get(User, issue.assigned_to)
|
||||
if current_assignee:
|
||||
staff.append(current_assignee)
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
||||
form.status.data = form.status.data or issue.status
|
||||
|
||||
@@ -431,7 +439,7 @@ def view(issue_id):
|
||||
|
||||
issue.status = form.status.data
|
||||
|
||||
if current_user.role in ['admin', 'director']:
|
||||
if current_user.role in ['admin', 'director', 'auditor']:
|
||||
issue.assigned_to = form.assigned_to.data or None
|
||||
|
||||
if form.status.data == 'resolved' and not issue.resolved_at:
|
||||
@@ -449,7 +457,7 @@ def view(issue_id):
|
||||
issue.result_notes = form.result_notes.data or None
|
||||
|
||||
# Vendor / contractor assignment — admin, director, project_manager only
|
||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
||||
if current_user.role in ('admin', 'director', 'project_manager', 'auditor'):
|
||||
issue.vendor_name = form.vendor_name.data.strip() or None
|
||||
issue.vendor_contact = form.vendor_contact.data.strip() or None
|
||||
issue.vendor_notes = form.vendor_notes.data.strip() or None
|
||||
@@ -695,7 +703,7 @@ def unfollow(issue_id):
|
||||
@login_required
|
||||
@quota_soft_check('issues')
|
||||
def create():
|
||||
if current_user.role not in ('admin', 'director', 'customer'):
|
||||
if current_user.role not in ('admin', 'director', 'customer', 'auditor'):
|
||||
abort(403)
|
||||
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
@@ -717,7 +725,7 @@ def create():
|
||||
else:
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
|
||||
|
||||
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
||||
@@ -803,7 +811,7 @@ def create():
|
||||
|
||||
@bp.route('/<int:issue_id>/verify', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
@issue_manager_required
|
||||
def verify(issue_id):
|
||||
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
@@ -837,7 +845,7 @@ def verify(issue_id):
|
||||
|
||||
@bp.route('/bulk-verify', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
@issue_manager_required
|
||||
def bulk_verify():
|
||||
"""Verify multiple pending-verification issues in a single action."""
|
||||
issue_ids = request.form.getlist('issue_ids', type=int)
|
||||
@@ -884,7 +892,7 @@ def request_verification(issue_id):
|
||||
|
||||
# Only the assignee, director, or admin can request verification
|
||||
can_act = (
|
||||
current_user.role in ['admin', 'director']
|
||||
current_user.role in ['admin', 'director', 'auditor']
|
||||
or issue.assigned_to == current_user.id
|
||||
)
|
||||
if not can_act:
|
||||
@@ -927,7 +935,7 @@ def request_verification(issue_id):
|
||||
|
||||
@bp.route('/verification-queue')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
@issue_manager_required
|
||||
def verification_queue():
|
||||
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
|
||||
from app.models.facility import Facility, Area
|
||||
@@ -1000,15 +1008,10 @@ def delete(issue_id):
|
||||
db.session.delete(issue)
|
||||
db.session.commit()
|
||||
|
||||
# Remove orphaned photo files — best-effort, never block on failure
|
||||
static_folder = current_app.root_path
|
||||
# Remove orphaned photo files from the active storage backend — best-effort.
|
||||
from app.utils import storage
|
||||
for rel_path in photo_paths:
|
||||
abs_path = os.path.normpath(os.path.join(static_folder, 'static', rel_path))
|
||||
try:
|
||||
if os.path.isfile(abs_path):
|
||||
os.remove(abs_path)
|
||||
except OSError:
|
||||
pass
|
||||
storage.delete(rel_path)
|
||||
|
||||
current_app.logger.info(
|
||||
'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s',
|
||||
@@ -1028,7 +1031,7 @@ def delete(issue_id):
|
||||
@login_required
|
||||
def quick_assign(issue_id):
|
||||
"""Inline assignee update from the issues list — returns JSON."""
|
||||
if current_user.role not in ('admin', 'director'):
|
||||
if current_user.role not in ('admin', 'director', 'auditor'):
|
||||
return jsonify({'ok': False, 'error': 'Permission denied'}), 403
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
|
||||
+38
-3
@@ -12,6 +12,7 @@ from app import db
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.issue import Issue
|
||||
from app.models.project import Project
|
||||
from app.models.user import User
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.scope import get_customer_scope
|
||||
@@ -141,9 +142,11 @@ def index():
|
||||
)
|
||||
avg_score = _scope_insp(avg_score).scalar()
|
||||
|
||||
# Scores by facility (for bar chart)
|
||||
# Scores by facility (for bar chart) — includes project_id so the report
|
||||
# can group/filter facilities by Contract client-side.
|
||||
fac_score_q = db.session.query(
|
||||
Facility.name,
|
||||
Facility.project_id,
|
||||
func.avg(Inspection.overall_score).label('avg_score'),
|
||||
func.count(Inspection.id).label('count'),
|
||||
).join(Inspection, Facility.id == Inspection.facility_id)\
|
||||
@@ -159,9 +162,16 @@ def index():
|
||||
fac_score_q = fac_score_q.filter(
|
||||
Facility.id.in_(customer_facility_ids) if customer_facility_ids else False
|
||||
)
|
||||
facility_scores = fac_score_q.group_by(Facility.id, Facility.name)\
|
||||
facility_scores = fac_score_q.group_by(Facility.id, Facility.name, Facility.project_id)\
|
||||
.order_by(func.avg(Inspection.overall_score).desc()).all()
|
||||
|
||||
# Resolve contract names for the facilities present.
|
||||
_proj_ids = {r.project_id for r in facility_scores if r.project_id}
|
||||
_proj_names = (
|
||||
{p.id: p.name for p in Project.query.filter(Project.id.in_(_proj_ids)).all()}
|
||||
if _proj_ids else {}
|
||||
)
|
||||
|
||||
# Prior-period facility scores for period-over-period delta badges
|
||||
period_len = end - start
|
||||
prior_end = start
|
||||
@@ -255,12 +265,24 @@ def index():
|
||||
inspectors = User.query.filter_by(role='inspector', active=True)\
|
||||
.order_by(User.full_name, User.username).all()
|
||||
|
||||
facility_scores_list = [{'name': r.name, 'avg_score': round(float(r.avg_score), 2), 'count': r.count} for r in facility_scores]
|
||||
facility_scores_list = [{
|
||||
'name': r.name,
|
||||
'avg_score': round(float(r.avg_score), 2),
|
||||
'count': r.count,
|
||||
'project_id': r.project_id or 0,
|
||||
'contract': _proj_names.get(r.project_id, 'No Contract'),
|
||||
} for r in facility_scores]
|
||||
# Attach prior avg and delta to each facility score dict for the template table
|
||||
for row in facility_scores_list:
|
||||
row['prior_avg'] = prior_scores_map.get(row['name'])
|
||||
row['delta'] = facility_deltas.get(row['name'])
|
||||
|
||||
# Distinct contracts present, for the "Avg Score by Facility" contract filter.
|
||||
score_contracts = sorted(
|
||||
{(r['project_id'], r['contract']) for r in facility_scores_list},
|
||||
key=lambda t: (t[1] or '').lower(),
|
||||
)
|
||||
|
||||
return render_template('reports/index.html',
|
||||
start=start, end=end,
|
||||
total_inspections=total_inspections,
|
||||
@@ -268,6 +290,7 @@ def index():
|
||||
flagged=flagged,
|
||||
avg_score=round(float(avg_score), 2) if avg_score else None,
|
||||
facility_scores=facility_scores_list,
|
||||
score_contracts=score_contracts,
|
||||
daily_scores=[{'day': str(r.day), 'avg': round(float(r.avg), 2), 'count': r.count} for r in daily_scores],
|
||||
issue_severity=[{'severity': r.severity, 'count': r.count} for r in issue_severity],
|
||||
issue_status=[{'status': r.status, 'count': r.count} for r in issue_status],
|
||||
@@ -1105,8 +1128,13 @@ def issues_aging():
|
||||
if customer_facility_ids is not None:
|
||||
facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True)
|
||||
.order_by(Facility.name).all()) if customer_facility_ids else []
|
||||
projects = (Project.query
|
||||
.join(Facility, Project.id == Facility.project_id)
|
||||
.filter(Facility.id.in_(customer_facility_ids), Project.active == True)
|
||||
.distinct().order_by(Project.name).all()) if customer_facility_ids else []
|
||||
else:
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
return render_template('reports/issues_aging.html',
|
||||
now=now,
|
||||
@@ -1118,6 +1146,7 @@ def issues_aging():
|
||||
severity_filter=severity_filter,
|
||||
facility_id_filter=facility_id_filter,
|
||||
facilities=facilities,
|
||||
projects=projects,
|
||||
)
|
||||
|
||||
|
||||
@@ -1282,8 +1311,13 @@ def sla_compliance():
|
||||
if customer_facility_ids is not None:
|
||||
facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True)
|
||||
.order_by(Facility.name).all()) if customer_facility_ids else []
|
||||
projects = (Project.query
|
||||
.join(Facility, Project.id == Facility.project_id)
|
||||
.filter(Facility.id.in_(customer_facility_ids), Project.active == True)
|
||||
.distinct().order_by(Project.name).all()) if customer_facility_ids else []
|
||||
else:
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
return render_template('reports/sla_compliance.html',
|
||||
start=start, end=end,
|
||||
@@ -1292,6 +1326,7 @@ def sla_compliance():
|
||||
by_facility=by_facility,
|
||||
facility_id_filter=facility_id_filter,
|
||||
facilities=facilities,
|
||||
projects=projects,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<td>{{ user.full_name or '—' }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
|
||||
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
|
||||
{{ user.role.replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
||||
</li>
|
||||
@@ -155,7 +155,7 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
|
||||
</li>
|
||||
@@ -163,7 +163,7 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||
href="{{ url_for('issues.verification_queue') }}">
|
||||
@@ -205,6 +205,11 @@
|
||||
<i class="bi bi-chat-dots me-2"></i>Ask a Question
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
|
||||
<i class="bi bi-clock-history me-2"></i>My Conversations
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
|
||||
<i class="bi bi-inbox me-2"></i>My Requests
|
||||
|
||||
@@ -5,12 +5,60 @@
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col">
|
||||
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
|
||||
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
|
||||
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'auditor' %}secondary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
|
||||
{{ current_user.role.replace('_',' ')|title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Scheduled Inspections (phase43) — plan-mode, staff only ─────────────── #}
|
||||
{% if current_user.role != 'customer' and (sched_upcoming or sched_overdue_count) %}
|
||||
<div class="card shadow-sm mb-4 border-0" style="border-left:4px solid #6366f1 !important;">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="fw-bold"><i class="bi bi-calendar-check text-primary"></i> Scheduled Inspections</span>
|
||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
||||
</div>
|
||||
{% if sched_overdue_count %}
|
||||
<div class="alert alert-danger py-2 mb-2">
|
||||
<i class="bi bi-alarm-fill"></i>
|
||||
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
|
||||
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if sched_upcoming %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr><th>Schedule</th><th>Facility</th><th>Template</th><th>Inspector</th><th>Due</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in sched_upcoming %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ s.name }}</td>
|
||||
<td>{{ s.facility.name if s.facility else '—' }}</td>
|
||||
<td class="small">{{ s.template.name if s.template else '—' }}</td>
|
||||
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||
<td class="small">{{ s.next_run_at.strftime('%b %d') if s.next_run_at else '—' }}</td>
|
||||
<td class="text-end">
|
||||
{% if current_user.role in ['admin','director','project_manager','auditor']
|
||||
or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %}
|
||||
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted small mb-0">No inspections due in the next 7 days.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Inspections section ─────────────────────────────────────────────────── #}
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-clipboard-data-fill text-primary"></i>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>QR Code — {{ area.name }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background:#f1f5f9; }
|
||||
.qr-card { max-width:420px; margin:2rem auto; background:#fff; border-radius:.75rem;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,.12); padding:2rem; text-align:center; }
|
||||
.qr-box svg { width:260px; height:260px; }
|
||||
.scan-url { word-break:break-all; font-size:.72rem; color:#94a3b8; }
|
||||
@media print {
|
||||
body { background:#fff; }
|
||||
.no-print { display:none !important; }
|
||||
.qr-card { box-shadow:none; margin:0 auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="text-center mt-3 no-print">
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Facility
|
||||
</a>
|
||||
<button onclick="window.print()" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-printer"></i> Print
|
||||
</button>
|
||||
<a href="{{ url_for('facilities.qr_print_all', facility_id=facility.id, include_areas=1) }}"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-grid-3x3-gap"></i> Print All Codes
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'success' if category == 'success' else 'warning' }} mx-auto mt-3 no-print" style="max-width:420px;">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="qr-card">
|
||||
<div class="text-muted text-uppercase small" style="letter-spacing:.08em;">Area</div>
|
||||
<h4 class="mb-0">{{ area.name }}</h4>
|
||||
<div class="text-muted small">{{ facility.name }}</div>
|
||||
{% if area.area_type %}<div class="text-muted small mb-2">{{ area.area_type }}</div>{% endif %}
|
||||
|
||||
<div class="qr-box my-3">{{ svg | safe }}</div>
|
||||
|
||||
<div class="fw-semibold mb-1">
|
||||
<i class="bi bi-phone"></i> Scan to report a problem in this area
|
||||
</div>
|
||||
<div class="text-muted small mb-2">
|
||||
Recent scores, open issues, and quality trend for {{ area.name }}.
|
||||
</div>
|
||||
<div class="scan-url">{{ scan_url }}</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-muted small no-print">
|
||||
Tip: post this inside the area itself (e.g. on the restroom door), not at the building entrance.
|
||||
</p>
|
||||
|
||||
{% if current_user.role in ['admin', 'director', 'customer'] %}
|
||||
<div class="text-center mb-4 no-print">
|
||||
<form method="POST" action="{{ url_for('facilities.regenerate_area_qr', area_id=area.id) }}"
|
||||
onsubmit="return confirm('Regenerate this QR code? Every previously printed poster for {{ area.name }} will stop working.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-arrow-repeat"></i> Regenerate QR Code
|
||||
</button>
|
||||
<div class="form-text">Use this if a printed poster leaked or was posted somewhere it shouldn't be.</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,9 +8,10 @@
|
||||
<h2><i class="bi bi-building"></i> Facilities</h2>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
<a href="{{ url_for('facilities.qr_sheet') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-qr-code"></i> Print QR Codes
|
||||
{% if current_user.role != 'inspector' %}
|
||||
<a href="{{ url_for('facilities.qr_print_all') }}"
|
||||
class="btn btn-outline-dark" title="Printable sheet of your facilities' QR codes">
|
||||
<i class="bi bi-qr-code"></i> Print All QR Codes
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
@@ -43,7 +44,7 @@
|
||||
{% endif %}
|
||||
</button>
|
||||
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
|
||||
{% if group.project and current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
{% if group.project and current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary ms-2"
|
||||
title="View Contract">
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Print QR Codes{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.qr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
.qr-item { position: relative; break-inside: avoid; page-break-inside: avoid;
|
||||
text-align: center; cursor: pointer; }
|
||||
.qr-item img { width: 220px; height: 220px; max-width: 100%; }
|
||||
.qr-item.qr-selected { outline: 3px solid #2563eb; outline-offset: -1px; }
|
||||
.qr-check { position: absolute; top: 10px; left: 10px; }
|
||||
.qr-check .form-check-input { width: 1.25rem; height: 1.25rem; }
|
||||
.qr-kind { font-size: .68rem; letter-spacing: .08em; }
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.navbar, nav, footer { display: none !important; }
|
||||
.qr-item { border: 1px dashed #bbb !important; cursor: default; }
|
||||
.qr-item.qr-selected { outline: none !important; }
|
||||
.qr-grid { gap: 8px; }
|
||||
/* When printing a selection, hide the unselected cards. */
|
||||
body.print-selected-only .qr-item:not(.qr-selected) { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
|
||||
<div>
|
||||
<h2 class="h4 mb-0"><i class="bi bi-qr-code"></i> QR Codes</h2>
|
||||
<div class="text-muted small">
|
||||
{% if selected_contract %}Contract: {{ selected_contract.name }} — {% endif %}
|
||||
{{ facilities|length }} facilit{{ 'y' if facilities|length == 1 else 'ies' }}
|
||||
{% if include_areas %}(with areas){% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# ── Filter bar (GET reload) ─────────────────────────────────────────────── #}
|
||||
<form method="get" id="filterForm" class="card card-body mb-3 no-print">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold mb-1">Contract</label>
|
||||
<select name="contract_id" class="form-select form-select-sm"
|
||||
onchange="document.getElementById('facilitySelect').value=''; this.form.submit();">
|
||||
<option value="">All Contracts</option>
|
||||
{% for c in contracts %}
|
||||
<option value="{{ c.id }}" {{ 'selected' if selected_contract_id == c.id }}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold mb-1">Facility</label>
|
||||
<select name="facility_id" id="facilitySelect" class="form-select form-select-sm">
|
||||
<option value="">All Facilities</option>
|
||||
{% for f in facility_options %}
|
||||
<option value="{{ f.id }}" {{ 'selected' if selected_facility_id == f.id }}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="include_areas" value="1"
|
||||
id="includeAreas" {{ 'checked' if include_areas }}>
|
||||
<label class="form-check-label small" for="includeAreas">Include area QR codes</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-funnel"></i> Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ── Selection toolbar + export form ─────────────────────────────────────── #}
|
||||
<form method="post" action="{{ url_for('facilities.qr_export_pdf') }}" id="qrForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3 no-print flex-wrap">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(true)">Select All</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(false)">Clear</button>
|
||||
<span class="text-muted small" id="selCount">0 selected</span>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="printSelected()">
|
||||
<i class="bi bi-printer"></i> Print Selected
|
||||
</button>
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<i class="bi bi-file-earmark-pdf"></i> Export Selected to PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if facilities %}
|
||||
<div class="qr-grid">
|
||||
{% for f in facilities %}
|
||||
{# Facility QR card #}
|
||||
<label class="qr-item card shadow-sm p-3 mb-0">
|
||||
<div class="qr-check no-print">
|
||||
<input type="checkbox" class="form-check-input qr-cb" name="facility_ids" value="{{ f.id }}">
|
||||
</div>
|
||||
<div class="text-muted text-uppercase qr-kind">Facility</div>
|
||||
<div class="fw-bold">{{ f.name }}</div>
|
||||
{% if f.project %}
|
||||
<div class="text-muted small mb-1">{{ f.project.name }}</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<img src="{{ url_for('facilities.facility_qr_png', facility_id=f.id) }}"
|
||||
alt="QR code for {{ f.name }}" loading="lazy">
|
||||
</div>
|
||||
<div class="small">Report a problem & view recent quality</div>
|
||||
</label>
|
||||
|
||||
{% if include_areas %}
|
||||
{% for a in areas_by_facility.get(f.id, []) %}
|
||||
{# Area QR card #}
|
||||
<label class="qr-item card shadow-sm p-3 mb-0">
|
||||
<div class="qr-check no-print">
|
||||
<input type="checkbox" class="form-check-input qr-cb" name="area_ids" value="{{ a.id }}">
|
||||
</div>
|
||||
<div class="text-muted text-uppercase qr-kind">Area</div>
|
||||
<div class="fw-bold">{{ a.name }}</div>
|
||||
<div class="text-muted small mb-1">{{ f.name }}</div>
|
||||
<div>
|
||||
<img src="{{ url_for('facilities.area_qr_png', area_id=a.id) }}"
|
||||
alt="QR code for {{ a.name }}" loading="lazy">
|
||||
</div>
|
||||
<div class="small">Report a problem & view recent quality</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">No facilities match the selected filters.</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
<p class="text-muted small mt-3 no-print">
|
||||
Tip: tick the codes you want, then <strong>Print Selected</strong> or
|
||||
<strong>Export Selected to PDF</strong>. With nothing ticked, Print Selected prints them all.
|
||||
</p>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function checkboxes() { return document.querySelectorAll('.qr-cb'); }
|
||||
|
||||
function updateCount() {
|
||||
var n = document.querySelectorAll('.qr-cb:checked').length;
|
||||
document.getElementById('selCount').textContent = n + ' selected';
|
||||
}
|
||||
|
||||
function syncCard(cb) {
|
||||
var card = cb.closest('.qr-item');
|
||||
if (card) { card.classList.toggle('qr-selected', cb.checked); }
|
||||
}
|
||||
|
||||
window.selectAllQr = function (state) {
|
||||
checkboxes().forEach(function (cb) { cb.checked = state; syncCard(cb); });
|
||||
updateCount();
|
||||
};
|
||||
|
||||
window.printSelected = function () {
|
||||
var anySelected = document.querySelectorAll('.qr-cb:checked').length > 0;
|
||||
if (anySelected) { document.body.classList.add('print-selected-only'); }
|
||||
window.print();
|
||||
setTimeout(function () {
|
||||
document.body.classList.remove('print-selected-only');
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Toggling a checkbox inside its <label> card also fires on the label click.
|
||||
checkboxes().forEach(function (cb) {
|
||||
cb.addEventListener('change', function () { syncCard(cb); updateCount(); });
|
||||
});
|
||||
|
||||
updateCount();
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -11,17 +11,21 @@
|
||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Facilities
|
||||
</a>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
|
||||
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
|
||||
class="btn btn-outline-info">
|
||||
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
{% if current_user.role != 'inspector' %}
|
||||
<a href="{{ url_for('facilities.qr_card', facility_id=facility.id) }}"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="bi bi-qr-code"></i> QR Code
|
||||
</a>
|
||||
<a href="{{ url_for('facilities.qr_print_all', facility_id=facility.id, include_areas=1) }}"
|
||||
class="btn btn-outline-secondary" title="Print or export this facility's codes, including every area">
|
||||
<i class="bi bi-grid-3x3-gap"></i> All Codes
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
|
||||
@@ -135,6 +139,12 @@
|
||||
</td>
|
||||
<td>{{ area.inspections.count() }}</td>
|
||||
<td>
|
||||
{% if current_user.role != 'inspector' %}
|
||||
<a href="{{ url_for('facilities.area_qr_card', area_id=area.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary" title="Printable QR code for this area">
|
||||
<i class="bi bi-qr-code"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{# ── Public "report a problem" form (phase42) ───────────────────────────────
|
||||
Shared by the facility scan page and the area scan page. Caller passes:
|
||||
action — the POST endpoint URL
|
||||
form — a PublicIssueReportForm instance
|
||||
Photos: up to 5, first becomes the issue's primary photo. Honeypot field
|
||||
`website` is off-screen: humans never see it, bots fill it.
|
||||
#}
|
||||
<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>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger py-2 small">
|
||||
<strong>Please check the form:</strong>
|
||||
<ul class="mb-0 ps-3">
|
||||
{% for field, errs in form.errors.items() %}
|
||||
{% for e in errs %}<li>{{ e }}</li>{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ action }}" enctype="multipart/form-data">
|
||||
<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">
|
||||
{{ form.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>
|
||||
{{ form.description(class="form-control form-control-sm", rows=3,
|
||||
maxlength=2000,
|
||||
placeholder="Describe the issue (e.g. restroom out of paper towels, spill in lobby…)") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Urgency</label>
|
||||
{{ form.severity(class="form-select form-select-sm") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{{ area_label_prompt or 'Where in the building?' }}</label>
|
||||
{{ form.area_label(class="form-control form-control-sm",
|
||||
placeholder="e.g. 2nd floor men's restroom") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Add photos (optional, up to 5)</label>
|
||||
{{ form.photos(class="form-control form-control-sm", accept="image/*") }}
|
||||
<div class="form-text small">A photo helps our team find and fix it faster.</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-semibold">Your name (optional)</label>
|
||||
{{ form.reporter_name(class="form-control form-control-sm") }}
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-semibold">Email or phone (optional)</label>
|
||||
{{ form.reporter_contact(class="form-control form-control-sm") }}
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{{ area.name }} — {{ facility.name }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background:#f1f5f9; color:#1f2937; }
|
||||
.fq-wrap { max-width:640px; margin:2rem auto; padding:0 1rem; }
|
||||
.stat-tile { background:#fff; border-radius:.5rem; padding:.9rem .5rem; text-align:center;
|
||||
box-shadow:0 1px 2px rgba(0,0,0,.06); height:100%; }
|
||||
.stat-tile .val { font-size:1.6rem; font-weight:700; line-height:1.2; }
|
||||
.stat-tile .lbl { font-size:.72rem; color:#64748b; text-transform:uppercase;
|
||||
letter-spacing:.03em; margin-top:.15rem; }
|
||||
.sev-critical{background:#dc2626}.sev-high{background:#ea580c}
|
||||
.sev-medium{background:#d97706}.sev-low{background:#64748b}
|
||||
.trend-up { color:#15803d; }
|
||||
.trend-down { color:#dc2626; }
|
||||
.trend-flat { color:#64748b; }
|
||||
@media (max-width:576px){ .fq-wrap{ margin:1rem auto; } }
|
||||
</style>
|
||||
</head>
|
||||
<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-door-open fs-3 text-primary"></i>
|
||||
<div>
|
||||
<div class="fw-bold">{{ area.name }}</div>
|
||||
<div class="text-muted small">
|
||||
{{ facility.name }}
|
||||
{% if area.area_type %} · {{ area.area_type }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-light border py-2 small mb-3">
|
||||
<i class="bi bi-info-circle text-primary"></i>
|
||||
Everything below is for <strong>{{ area.name }}</strong> only — not the whole building.
|
||||
</div>
|
||||
|
||||
{# ── Summary stat tiles (90 days) ── #}
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-3">
|
||||
<div class="stat-tile">
|
||||
<div class="val">{% if avg_90 is not none %}{{ '%.1f'|format(avg_90) }}%{% else %}—{% endif %}</div>
|
||||
<div class="lbl">Avg Score<br>90 days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="stat-tile">
|
||||
<div class="val">{{ total_90 }}</div>
|
||||
<div class="lbl">Inspections<br>90 days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="stat-tile">
|
||||
<div class="val">{{ open_total }}</div>
|
||||
<div class="lbl">Open<br>Issues</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="stat-tile">
|
||||
<div class="val">{{ resolved_90 }}</div>
|
||||
<div class="lbl">Resolved<br>90 days</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Score trend ── #}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2 d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small text-uppercase">Score trend — 30 days vs prior 30</div>
|
||||
{% if trend_delta is not none %}
|
||||
{% if trend_delta > 0.5 %}
|
||||
<div class="trend-up fw-semibold">
|
||||
<i class="bi bi-arrow-up-right"></i> Improving
|
||||
(+{{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%)
|
||||
</div>
|
||||
{% elif trend_delta < -0.5 %}
|
||||
<div class="trend-down fw-semibold">
|
||||
<i class="bi bi-arrow-down-right"></i> Declining
|
||||
({{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%)
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="trend-flat fw-semibold">
|
||||
<i class="bi bi-arrow-right"></i> Steady ({{ '%.1f'|format(avg_cur) }}%)
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-muted small">Not enough data yet</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Open issues by severity + SLA state ── #}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white fw-semibold py-2">
|
||||
<i class="bi bi-exclamation-triangle"></i> Open Issues
|
||||
</div>
|
||||
<div class="card-body py-3">
|
||||
{% if open_total or pending_verification %}
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
{% for sev in severity_order %}
|
||||
{% if severity_counts[sev] %}
|
||||
<span class="badge sev-{{ sev }} text-white">
|
||||
{{ severity_counts[sev] }} {{ sev }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if pending_verification %}
|
||||
<span class="badge bg-info text-dark">{{ pending_verification }} pending verification</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if sla_breached %}
|
||||
<div class="small text-danger fw-semibold">
|
||||
<i class="bi bi-alarm"></i> {{ sla_breached }} issue{{ 's' if sla_breached != 1 }} past the response-time target
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if sla_at_risk %}
|
||||
<div class="small text-warning-emphasis fw-semibold">
|
||||
<i class="bi bi-hourglass-split"></i> {{ sla_at_risk }} issue{{ 's' if sla_at_risk != 1 }} approaching the response-time target
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not sla_breached and not sla_at_risk and open_total %}
|
||||
<div class="small text-muted">All open issues are within response-time targets.</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-muted small"><i class="bi bi-check-circle text-success"></i> No open issues in this area right now.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Recent inspections ── #}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white fw-semibold py-2">
|
||||
<i class="bi bi-clipboard-check"></i> Recent Inspections
|
||||
</div>
|
||||
{% if recent %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Date</th>
|
||||
<th>Checklist</th>
|
||||
<th class="text-end pe-3">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ins in recent %}
|
||||
<tr>
|
||||
<td class="ps-3 text-nowrap">{{ ins.inspection_date.strftime('%b %d, %Y') }}</td>
|
||||
<td class="text-muted small">{{ ins.template.name if ins.template else '—' }}</td>
|
||||
<td class="text-end pe-3 fw-semibold">
|
||||
{% if ins.overall_score is not none %}{{ '%.1f'|format(ins.overall_score) }}%{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body py-3 text-muted small">No completed inspections for this area yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if can_view_full %}
|
||||
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
|
||||
class="btn btn-primary w-100 mb-3">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> Open Full Facility View
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% with action = url_for('facility_qr.area_report', token=token),
|
||||
area_label_prompt = 'Whereabouts in ' ~ area.name ~ '? (optional)' %}
|
||||
{% include "facility_qr/_report_form.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<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
|
||||
</p>
|
||||
<p class="text-center text-muted small">Janitorial QC — area quality snapshot</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -177,41 +177,9 @@
|
||||
</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>
|
||||
{% with action = url_for('facility_qr.report', token=token) %}
|
||||
{% include "facility_qr/_report_form.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<p class="text-center text-muted small mt-2 mb-1">
|
||||
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
|
||||
|
||||
@@ -39,6 +39,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Mode</label>
|
||||
<select name="mode" class="form-select">
|
||||
<option value="auto" {{ 'selected' if not schedule or schedule.mode != 'plan' }}>
|
||||
Auto — create the inspection automatically each period</option>
|
||||
<option value="plan" {{ 'selected' if schedule and schedule.mode == 'plan' }}>
|
||||
Plan — inspector presses Start (with due/overdue reminders)</option>
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Auto drops an in-progress inspection into the inspector's queue on
|
||||
schedule. Plan assigns a due date and reminds them the day before, on
|
||||
the day, and alerts managers once it's overdue.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Notes for the inspector <span class="text-muted small">(optional)</span></label>
|
||||
<textarea name="notes" class="form-control" rows="2"
|
||||
placeholder="Anything the inspector should know before starting">{{ schedule.notes if schedule and schedule.notes else '' }}</textarea>
|
||||
<div class="form-text">Copied onto the inspection when it starts.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Facility</label>
|
||||
@@ -80,8 +103,12 @@
|
||||
<label class="form-check-label" for="activeSwitch">Active</label>
|
||||
</div>
|
||||
<p class="text-muted small">
|
||||
Saving recomputes the next run from now. Next run:
|
||||
Saving recomputes the next {{ 'due date' if schedule.mode == 'plan' else 'run' }}
|
||||
from now, and resets this occurrence's reminders. Currently:
|
||||
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
|
||||
{% if schedule.last_completed_at %}
|
||||
· last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -3,15 +3,24 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
|
||||
{% if current_user.role != 'inspector' %}
|
||||
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Schedule
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mb-4">
|
||||
Recurring schedules automatically create an in-progress inspection for the
|
||||
assigned inspector each period. The inspector is notified and opens it from
|
||||
their Inspections list to complete it.
|
||||
{% if current_user.role == 'inspector' %}
|
||||
Inspections scheduled for you. <strong>Auto</strong> schedules appear in your
|
||||
Inspections list on their own each period; <strong>Plan</strong> schedules wait
|
||||
for you to press Start.
|
||||
{% else %}
|
||||
<strong>Auto</strong> schedules automatically create an in-progress inspection
|
||||
for the assigned inspector each period. <strong>Plan</strong> schedules assign a
|
||||
due date and let the inspector press Start when they begin — with reminders the
|
||||
day before, on the day, and an alert to managers once overdue.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if schedules %}
|
||||
@@ -22,8 +31,8 @@
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th><th>Template</th><th>Facility / Area</th>
|
||||
<th>Inspector</th><th>Frequency</th><th>Next Run</th>
|
||||
<th>Last Run</th><th>Status</th><th width="150"></th>
|
||||
<th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th>
|
||||
<th>Last Run</th><th>Status</th><th width="190"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -37,8 +46,18 @@
|
||||
</td>
|
||||
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ s.frequency|title }}</span></td>
|
||||
<td>
|
||||
{% if s.mode == 'plan' %}
|
||||
<span class="badge bg-info text-dark" title="Inspector presses Start">Plan</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark border" title="Cron creates the inspection">Auto</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small {{ 'text-danger fw-semibold' if s.active and s.next_run_at and s.next_run_at <= now else 'text-muted' }}">
|
||||
{{ s.next_run_at.strftime('%Y-%m-%d %H:%M') if s.next_run_at else '—' }}
|
||||
{% if s.is_overdue(today) %}
|
||||
<span class="badge bg-danger ms-1">Overdue</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }}
|
||||
@@ -48,6 +67,14 @@
|
||||
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if s.active and s.mode == 'plan'
|
||||
and (current_user.role != 'inspector' or s.inspector_id == current_user.id) %}
|
||||
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-primary" title="Start this inspection now">
|
||||
<i class="bi bi-play-fill"></i> Start
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role != 'inspector' %}
|
||||
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
@@ -70,6 +97,7 @@
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -439,7 +439,7 @@
|
||||
</label>
|
||||
{# Thumbnail shown after AJAX upload or when a saved path exists #}
|
||||
{% if saved %}
|
||||
<img src="{{ url_for('static', filename=saved) }}"
|
||||
<img src="{{ media_url(saved) }}"
|
||||
id="thumb_{{ fid }}"
|
||||
alt="Photo"
|
||||
style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;">
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
|
||||
{% if current_user.role != 'customer' %}
|
||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Inspection
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-calendar-check"></i> Scheduled
|
||||
</a>
|
||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Inspection
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -688,7 +688,7 @@
|
||||
<div style="display:flex;align-items:center;flex:1;min-height:0;">
|
||||
{% if val %}
|
||||
<button type="button" class="btn-view-media"
|
||||
onclick="openMedia('{{ url_for('static', filename=val) }}','{{ field.label | e }}')">
|
||||
onclick="openMedia('{{ media_url(val) }}','{{ field.label | e }}')">
|
||||
<i class="bi bi-image"></i> View Photo
|
||||
</button>
|
||||
{% else %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
||||
{% if current_user.role in ['admin','director','customer'] %}
|
||||
{% if current_user.role in ['admin','director','customer','auditor'] %}
|
||||
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
||||
<i class="bi bi-plus-circle"></i> Log Issue
|
||||
</a>
|
||||
@@ -176,7 +176,7 @@
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if current_user.role in ['admin', 'director'] and issue.status != 'resolved' %}
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %}
|
||||
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
|
||||
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||
<option value="">— Unassigned —</option>
|
||||
@@ -212,7 +212,7 @@
|
||||
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
{% if current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
|
||||
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
{% else %}
|
||||
<i class="bi bi-eye"></i> View
|
||||
@@ -294,7 +294,7 @@
|
||||
}());
|
||||
</script>
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set can_edit = current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
|
||||
{% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||
|
||||
<div class="row">
|
||||
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
||||
@@ -127,14 +127,14 @@
|
||||
<h6>Photo Evidence</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% if issue.photo_path %}
|
||||
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename=issue.photo_path) }}"
|
||||
<a href="{{ media_url(issue.photo_path) }}" target="_blank">
|
||||
<img src="{{ media_url(issue.photo_path) }}"
|
||||
class="img-fluid rounded" style="max-height:300px; max-width:100%;">
|
||||
</a>
|
||||
{% endif %}
|
||||
{% for photo in (issue.mobile_photo_paths or []) %}
|
||||
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename=photo) }}"
|
||||
<a href="{{ media_url(photo) }}" target="_blank">
|
||||
<img src="{{ media_url(photo) }}"
|
||||
class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;">
|
||||
</a>
|
||||
{% endfor %}
|
||||
@@ -150,8 +150,8 @@
|
||||
{% if issue.result_photos %}
|
||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||
{% for photo in issue.result_photos %}
|
||||
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename=photo) }}"
|
||||
<a href="{{ media_url(photo) }}" target="_blank">
|
||||
<img src="{{ media_url(photo) }}"
|
||||
class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;"
|
||||
alt="Result photo">
|
||||
</a>
|
||||
@@ -174,7 +174,7 @@
|
||||
<div class="alert alert-info py-2 mb-0">
|
||||
<i class="bi bi-hourglass-split me-1"></i>
|
||||
<strong>Awaiting director verification.</strong>
|
||||
{% if current_user.role in ['admin','director'] %}
|
||||
{% if current_user.role in ['admin','director','auditor'] %}
|
||||
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
@@ -363,7 +363,7 @@
|
||||
{{ form.status.label(class="form-label fw-semibold") }}
|
||||
{{ form.status(class="form-select") }}
|
||||
</div>
|
||||
{% if current_user.role in ['admin','director'] %}
|
||||
{% if current_user.role in ['admin','director','auditor'] %}
|
||||
<div class="mb-3">
|
||||
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
||||
{{ form.assigned_to(class="form-select") }}
|
||||
@@ -386,7 +386,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
||||
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||
<hr class="my-3">
|
||||
<p class="fw-semibold small mb-2">
|
||||
<i class="bi bi-person-check me-1 text-secondary"></i>Handler / Ownership
|
||||
@@ -467,7 +467,7 @@
|
||||
{% endif %}
|
||||
|
||||
{# ── Vendor Work Orders (phase36) ───────────────────────────────────── #}
|
||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
||||
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0"><i class="bi bi-send me-1"></i>Contractor Work Orders</h6>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<i class="bi bi-shield-check me-1"></i>SLA Compliance
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
|
||||
href="{{ url_for('reports.followup_closure') }}">
|
||||
@@ -33,7 +33,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
|
||||
href="{{ url_for('scheduled_reports.index') }}">
|
||||
|
||||
@@ -105,7 +105,17 @@
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-8 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6></div>
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6>
|
||||
{% if score_contracts %}
|
||||
<select id="scoreContractFilter" class="form-select form-select-sm" style="max-width:230px;">
|
||||
<option value="">All Contracts</option>
|
||||
{% for pid, cname in score_contracts %}
|
||||
<option value="{{ pid }}">{{ cname }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="facilityChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,6 +139,7 @@
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Facility</th>
|
||||
<th>Contract</th>
|
||||
<th class="text-end">Current Period</th>
|
||||
<th class="text-end">Prior Period</th>
|
||||
<th class="text-end">Change</th>
|
||||
@@ -137,8 +148,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in facility_scores %}
|
||||
<tr>
|
||||
<tr class="facility-score-row" data-project-id="{{ row.project_id }}">
|
||||
<td class="fw-semibold">{{ row.name }}</td>
|
||||
<td class="text-muted small">{{ row.contract }}</td>
|
||||
<td class="text-end">
|
||||
<span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}">
|
||||
{{ '%.1f'|format(row.avg_score|float) }}%
|
||||
@@ -266,16 +278,31 @@ new Chart(document.getElementById('trendChart'), {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Facility bar chart ────────────────────────────────────────────────────────
|
||||
new Chart(document.getElementById('facilityChart'), {
|
||||
// ── Facility bar chart (filterable by contract) ───────────────────────────────
|
||||
const FACILITY_SCORES = {{ facility_scores | tojson }};
|
||||
let facilityChartObj = null;
|
||||
function _facColors(data) { return data.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED); }
|
||||
function renderFacilityChart(pid) {
|
||||
const rows = (!pid)
|
||||
? FACILITY_SCORES
|
||||
: FACILITY_SCORES.filter(r => String(r.project_id) === String(pid));
|
||||
const labels = rows.map(r => r.name);
|
||||
const data = rows.map(r => r.avg_score);
|
||||
if (facilityChartObj) {
|
||||
facilityChartObj.data.labels = labels;
|
||||
facilityChartObj.data.datasets[0].data = data;
|
||||
facilityChartObj.data.datasets[0].backgroundColor = _facColors(data);
|
||||
facilityChartObj.update();
|
||||
return;
|
||||
}
|
||||
facilityChartObj = new Chart(document.getElementById('facilityChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ facility_scores | map(attribute='name') | list | tojson }},
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Avg Score (%)',
|
||||
data: {{ facility_scores | map(attribute='avg_score') | list | tojson }},
|
||||
backgroundColor: {{ facility_scores | map(attribute='avg_score') | list | tojson }}
|
||||
.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED),
|
||||
data: data,
|
||||
backgroundColor: _facColors(data),
|
||||
borderRadius: 4,
|
||||
}]
|
||||
},
|
||||
@@ -285,6 +312,25 @@ new Chart(document.getElementById('facilityChart'), {
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function filterFacilityScoreTable(pid) {
|
||||
document.querySelectorAll('.facility-score-row').forEach(function (tr) {
|
||||
const rp = tr.getAttribute('data-project-id');
|
||||
tr.style.display = (!pid || rp === String(pid)) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
(function () {
|
||||
renderFacilityChart('');
|
||||
const sel = document.getElementById('scoreContractFilter');
|
||||
if (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
renderFacilityChart(this.value);
|
||||
filterFacilityScoreTable(this.value);
|
||||
});
|
||||
}
|
||||
}());
|
||||
|
||||
// ── Severity doughnut ─────────────────────────────────────────────────────────
|
||||
const sevData = {{ issue_severity | tojson }};
|
||||
|
||||
@@ -29,9 +29,20 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% if projects %}
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Contract</label>
|
||||
<select id="aging_contract_sel" class="form-select form-select-sm">
|
||||
<option value="">All Contracts</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Facility</label>
|
||||
<select name="facility_id" class="form-select form-select-sm">
|
||||
<select id="aging_facility_sel" name="facility_id" class="form-select form-select-sm">
|
||||
<option value="">All Facilities</option>
|
||||
{% for f in facilities %}
|
||||
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
|
||||
@@ -46,6 +57,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if projects %}
|
||||
<script>
|
||||
(function () {
|
||||
var contractSel = document.getElementById('aging_contract_sel');
|
||||
var facSel = document.getElementById('aging_facility_sel');
|
||||
if (!contractSel || !facSel) return;
|
||||
var allOpts = Array.from(facSel.options).map(function (o) { return {v: o.value, t: o.text}; });
|
||||
contractSel.addEventListener('change', function () {
|
||||
var pid = this.value;
|
||||
facSel.innerHTML = '<option value="">All Facilities</option>';
|
||||
if (!pid) {
|
||||
allOpts.slice(1).forEach(function (o) {
|
||||
var opt = document.createElement('option'); opt.value = o.v; opt.text = o.t;
|
||||
facSel.appendChild(opt);
|
||||
});
|
||||
return;
|
||||
}
|
||||
fetch('/inspections/facilities_for_project/' + pid)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
data.forEach(function (f) {
|
||||
var opt = document.createElement('option'); opt.value = f.id; opt.text = f.name;
|
||||
facSel.appendChild(opt);
|
||||
});
|
||||
});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{# ── KPI row ── #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-4">
|
||||
|
||||
@@ -25,17 +25,28 @@
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">From</label>
|
||||
<input type="date" name="start" class="form-control form-control-sm" value="{{ start.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">To</label>
|
||||
<input type="date" name="end" class="form-control form-control-sm" value="{{ end.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% if projects %}
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Contract</label>
|
||||
<select id="sla_contract_sel" class="form-select form-select-sm">
|
||||
<option value="">All Contracts</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Facility</label>
|
||||
<select name="facility_id" class="form-select form-select-sm">
|
||||
<select id="sla_facility_sel" name="facility_id" class="form-select form-select-sm">
|
||||
<option value="">All Facilities</option>
|
||||
{% for f in facilities %}
|
||||
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
|
||||
@@ -50,6 +61,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if projects %}
|
||||
<script>
|
||||
(function () {
|
||||
var contractSel = document.getElementById('sla_contract_sel');
|
||||
var facSel = document.getElementById('sla_facility_sel');
|
||||
if (!contractSel || !facSel) return;
|
||||
var allOpts = Array.from(facSel.options).map(function (o) { return {v: o.value, t: o.text}; });
|
||||
contractSel.addEventListener('change', function () {
|
||||
var pid = this.value;
|
||||
facSel.innerHTML = '<option value="">All Facilities</option>';
|
||||
if (!pid) {
|
||||
allOpts.slice(1).forEach(function (o) {
|
||||
var opt = document.createElement('option'); opt.value = o.v; opt.text = o.t;
|
||||
facSel.appendChild(opt);
|
||||
});
|
||||
return;
|
||||
}
|
||||
fetch('/inspections/facilities_for_project/' + pid)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
data.forEach(function (f) {
|
||||
var opt = document.createElement('option'); opt.value = f.id; opt.text = f.name;
|
||||
facSel.appendChild(opt);
|
||||
});
|
||||
});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% if total == 0 %}
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>No resolved issues found for this period and filter.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}Support Knowledge Base{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-book me-2 text-primary"></i>Support Knowledge Base</h4>
|
||||
<small class="text-muted">Active entries are injected into the AI chatbot system prompt</small>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="col-lg-8">
|
||||
|
||||
{# ── Header ── #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
|
||||
<small class="text-muted">
|
||||
|
||||
+24
-2
@@ -54,17 +54,39 @@ def supervisor_required(f):
|
||||
return decorated_function
|
||||
|
||||
def project_manager_required(f):
|
||||
"""Grants access to admin, director, and project_manager roles."""
|
||||
"""Grants access to admin, director, project_manager, and auditor roles.
|
||||
|
||||
Auditor mirrors Project Manager for all baseline access, so it is included
|
||||
here alongside project_manager.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated or current_user.role not in [
|
||||
'admin', 'director', 'project_manager'
|
||||
'admin', 'director', 'project_manager', 'auditor'
|
||||
]:
|
||||
flash('Project Manager access required.', 'danger')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def issue_manager_required(f):
|
||||
"""Grants access to admin, director, and auditor roles.
|
||||
|
||||
Used for issue-management powers that go beyond the Project Manager
|
||||
baseline (verification and the verification queue). Deliberately does NOT
|
||||
include project_manager, and does NOT grant issue deletion — delete stays
|
||||
on @supervisor_required (admin/director only).
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated or current_user.role not in [
|
||||
'admin', 'director', 'auditor'
|
||||
]:
|
||||
flash('Issue management access required.', 'danger')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def customer_required(f):
|
||||
"""Restricts access to customer-role users only.
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ class UserForm(FlaskForm):
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('auditor', 'Auditor'),
|
||||
# 'customer' is intentionally excluded — customer accounts are managed via /customers
|
||||
], validators=[Optional()])
|
||||
# NOTE: Optional() here because directors submit no role value (the field is
|
||||
@@ -319,3 +320,33 @@ class SetPasswordForm(FlaskForm):
|
||||
existing = User.query.filter_by(username=field.data.strip()).first()
|
||||
if existing:
|
||||
raise ValidationError('This username is already taken. Please choose another.')
|
||||
|
||||
# ── Public QR issue report (phase42) ─────────────────────────────────────────
|
||||
|
||||
class PublicIssueReportForm(FlaskForm):
|
||||
"""Login-free issue report submitted from a facility's or area's public QR page.
|
||||
|
||||
Ported from the single-tenant tree, with MT's occupant-chosen `severity`
|
||||
field retained (the ST original always filed at 'medium').
|
||||
|
||||
`website` is a honeypot: real users never see it (hidden via CSS); bots
|
||||
that fill every field trip it and the submission is silently rejected.
|
||||
"""
|
||||
area_label = StringField('Where in the building?',
|
||||
validators=[Optional(), Length(max=120)])
|
||||
description = TextAreaField('Describe the problem',
|
||||
validators=[DataRequired(), Length(min=5, max=2000)])
|
||||
severity = SelectField('How urgent is it?', choices=[
|
||||
('low', 'Minor — can wait'),
|
||||
('medium', 'Normal'),
|
||||
('high', 'Urgent — needs attention today'),
|
||||
], default='medium', validators=[Optional()])
|
||||
reporter_name = StringField('Your name (optional)',
|
||||
validators=[Optional(), Length(max=100)])
|
||||
reporter_contact = StringField('Email or phone (optional)',
|
||||
validators=[Optional(), Length(max=120)])
|
||||
photos = MultipleFileField('Add photos (optional, up to 5)',
|
||||
validators=[Optional(),
|
||||
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
|
||||
'Images only (jpg, png, gif).')])
|
||||
website = StringField('Website') # honeypot — must stay empty
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
app/utils/mail_utils.py
|
||||
-----------------------
|
||||
Helpers for constructing outbound email sender identities.
|
||||
|
||||
`branded_sender()` returns a Flask-Mail sender as a ``(display_name, address)``
|
||||
tuple so outbound branded email (customer invitations, billing notices) shows a
|
||||
per-tenant identity — WITHOUT risking deliverability.
|
||||
|
||||
Two independent things vary:
|
||||
|
||||
1. DISPLAY NAME (what the recipient sees, e.g. "Gov Services QC")
|
||||
Always applied. Costs nothing, needs no DNS. This is the safe default.
|
||||
|
||||
2. FROM ADDRESS (the actual @domain, e.g. jqc.noreply@govservicesinc.com)
|
||||
Only used for domains that are KNOWN to authorize this mail server
|
||||
(the authenticated sender's own domain, plus anything you add to
|
||||
SENDER_AUTHORIZED_DOMAINS). Every other domain keeps the authenticated
|
||||
address so the message still passes SPF/DMARC and delivers.
|
||||
|
||||
Result, with no DNS work:
|
||||
Invited from gov.jqc.app ->
|
||||
From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>
|
||||
(branded NAME, deliverable authenticated ADDRESS)
|
||||
|
||||
Once a tenant's own domain authorizes this mail server (SPF include + DKIM),
|
||||
add it to SENDER_AUTHORIZED_DOMAINS and it upgrades to:
|
||||
From: "Gov Services QC" <jqc.noreply@govservicesinc.com>
|
||||
|
||||
Ported from the single-tenant tree with one multi-tenant change: the display
|
||||
name comes from the TENANT, not a hardcoded host→name dict. Resolution order:
|
||||
|
||||
1. TenantSettings.company_name — what the tenant typed into Branding
|
||||
2. Tenant.name (control plane) — always known for a resolved tenant
|
||||
3. BRAND_NAMES[domain] — ST's host map, kept for non-tenant hosts
|
||||
4. DEFAULT_BRAND_NAME
|
||||
|
||||
See CLAUDE.md rule 64.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from flask import current_app, g
|
||||
|
||||
# ── Per-domain display names (single-tenant / non-tenant hosts only) ─────────
|
||||
# In multi-tenant mode the tenant record supplies the name and this map is never
|
||||
# consulted. It remains for the single-tenant deployment and for hosts that
|
||||
# resolve to no tenant.
|
||||
BRAND_NAMES = {
|
||||
'ltservicesinc.com': 'LT Services QC',
|
||||
'govservicesinc.com': 'Gov Services QC',
|
||||
'efs.com': 'EFS QC',
|
||||
}
|
||||
DEFAULT_BRAND_NAME = 'Janitorial QC'
|
||||
|
||||
# ── Domains cleared to use a BRANDED FROM ADDRESS ────────────────────────────
|
||||
# Add a registrable domain here ONLY after its DNS authorizes this mail server
|
||||
# (SPF `include:` + DKIM). Until then the domain still gets its branded display
|
||||
# NAME but sends from the authenticated address, so it always delivers.
|
||||
# The authenticated sender's own domain is always treated as authorized and does
|
||||
# NOT need to be listed here.
|
||||
# NOTE: written as set([...]) deliberately. A brace literal containing only
|
||||
# comments is an empty *dict*, which silently becomes a *set* the moment a line
|
||||
# is uncommented. Membership works either way, so this is cosmetic — but the
|
||||
# declared type shouldn't change based on whether a comment is uncommented.
|
||||
SENDER_AUTHORIZED_DOMAINS = set([
|
||||
# 'govservicesinc.com', # ← uncomment once SPF+DKIM are live for it
|
||||
# 'efs.com',
|
||||
])
|
||||
|
||||
|
||||
def _authenticated_sender() -> str:
|
||||
"""The SMTP identity we authenticate as — always deliverable."""
|
||||
return (current_app.config.get('MAIL_DEFAULT_SENDER')
|
||||
or current_app.config.get('MAIL_USERNAME')
|
||||
or 'jqc.noreply@janitorialqc.local')
|
||||
|
||||
|
||||
def _host_domain(base_url):
|
||||
"""Registrable domain (last two labels) of base_url, or None if unparseable."""
|
||||
base = (base_url or current_app.config.get('APP_BASE_URL', '') or '').strip().rstrip('/')
|
||||
if not base:
|
||||
return None
|
||||
netloc = urlparse(base).netloc or urlparse('//' + base).netloc
|
||||
host = netloc.split('@')[-1].split(':')[0].strip().lower() # drop userinfo/port
|
||||
if not host or '.' not in host:
|
||||
return None
|
||||
labels = [l for l in host.split('.') if l]
|
||||
return '.'.join(labels[-2:]) if len(labels) >= 2 else host
|
||||
|
||||
|
||||
def _tenant_brand_name():
|
||||
"""Display name from the current tenant, or None.
|
||||
|
||||
Prefers what the tenant typed into Branding (TenantSettings.company_name),
|
||||
then the control-plane tenant name. Returns None in single-tenant mode or
|
||||
when no tenant is bound.
|
||||
|
||||
Deliberately swallows errors: TenantSettings.query raises ProgrammingError
|
||||
when a freshly provisioned tenant DB has not been migrated yet, and an
|
||||
un-migrated tenant must not break sending mail.
|
||||
"""
|
||||
if not current_app.config.get('MULTI_TENANT_ENABLED'):
|
||||
return None
|
||||
|
||||
tenant = getattr(g, 'tenant', None) if g else None
|
||||
if tenant is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from app.models.tenant_settings import TenantSettings
|
||||
settings = TenantSettings.get_or_default()
|
||||
if settings is not None:
|
||||
name = (settings.company_name or '').strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (getattr(tenant, 'name', '') or '').strip() or None
|
||||
|
||||
|
||||
def branded_sender(base_url=None):
|
||||
"""Return a ``(display_name, address)`` sender tuple for branded email.
|
||||
|
||||
The display name tracks the tenant (falling back to the host's domain); the
|
||||
address is branded only for the authenticated domain and any
|
||||
SENDER_AUTHORIZED_DOMAINS, and otherwise stays the authenticated address so
|
||||
the mail still delivers.
|
||||
|
||||
Falls back to the bare authenticated sender string whenever the host cannot
|
||||
be parsed (localhost, empty, no dot), so this can never yield an invalid From.
|
||||
"""
|
||||
auth_sender = _authenticated_sender()
|
||||
if '@' not in auth_sender:
|
||||
return auth_sender
|
||||
local, auth_domain = auth_sender.rsplit('@', 1)
|
||||
auth_domain = auth_domain.lower()
|
||||
|
||||
tenant_name = _tenant_brand_name()
|
||||
domain = _host_domain(base_url)
|
||||
|
||||
if not domain:
|
||||
# Unknown host → safe authenticated address; still brand the name if a
|
||||
# tenant is bound.
|
||||
return (tenant_name or DEFAULT_BRAND_NAME, auth_sender)
|
||||
|
||||
display_name = tenant_name or BRAND_NAMES.get(domain, DEFAULT_BRAND_NAME)
|
||||
|
||||
if domain == auth_domain or domain in SENDER_AUTHORIZED_DOMAINS:
|
||||
address = f'{local}@{domain}' # branded address — DNS-authorized
|
||||
else:
|
||||
address = auth_sender # keep deliverable authenticated address
|
||||
|
||||
return (display_name, address)
|
||||
@@ -546,6 +546,7 @@ def notify_by_matrix(
|
||||
'director': 'director',
|
||||
'inspector': 'inspector',
|
||||
'project_manager': 'project_manager',
|
||||
'auditor': 'auditor',
|
||||
'customer': 'customer',
|
||||
}
|
||||
|
||||
|
||||
+139
-4
@@ -657,6 +657,27 @@ def _notes_section(inspection):
|
||||
|
||||
# ── Public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
def _collect_media_keys(form_data):
|
||||
"""Return the set of 'uploads/...' storage keys referenced in form_data
|
||||
(image field values, including any nested in lists/dicts). Signature values
|
||||
are inline 'data:' base64 and are naturally excluded."""
|
||||
keys = set()
|
||||
|
||||
def _walk(v):
|
||||
if isinstance(v, str):
|
||||
if v.startswith('uploads/'):
|
||||
keys.add(v)
|
||||
elif isinstance(v, dict):
|
||||
for x in v.values():
|
||||
_walk(x)
|
||||
elif isinstance(v, (list, tuple)):
|
||||
for x in v:
|
||||
_walk(x)
|
||||
|
||||
_walk(form_data or {})
|
||||
return keys
|
||||
|
||||
|
||||
def generate_inspection_pdf(inspection, form_fields, form_data, issues,
|
||||
static_folder) -> bytes:
|
||||
"""
|
||||
@@ -674,6 +695,14 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues,
|
||||
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||||
report_title = f'Inspection Report — {inspection.template.name}'
|
||||
|
||||
# Make referenced photos available as local file paths for ReportLab/PIL.
|
||||
# local backend: no-op (returns the real static folder); s3 backend:
|
||||
# downloads the form's image keys to a temp dir. Cleaned up after build.
|
||||
from app.utils import storage
|
||||
static_folder, _cleanup_media = storage.materialize_to_dir(
|
||||
_collect_media_keys(form_data)
|
||||
)
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=letter,
|
||||
@@ -723,8 +752,11 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues,
|
||||
]))
|
||||
story.append(sig_tbl)
|
||||
|
||||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||
return buf.getvalue()
|
||||
try:
|
||||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||
return buf.getvalue()
|
||||
finally:
|
||||
_cleanup_media()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -743,6 +775,14 @@ def generate_issue_pdf(issue, static_folder: str) -> bytes:
|
||||
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||||
report_title = f'Issue Report — Issue #{issue.id}'
|
||||
|
||||
# Make referenced photos available as local file paths for ReportLab/PIL.
|
||||
# local backend: no-op; s3 backend: downloads the issue's photo keys to a
|
||||
# temp dir. Cleaned up after build.
|
||||
from app.utils import storage
|
||||
_issue_keys = [issue.photo_path] + list(issue.mobile_photo_paths or []) \
|
||||
+ list(issue.result_photos or [])
|
||||
static_folder, _cleanup_media = storage.materialize_to_dir(_issue_keys)
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=letter,
|
||||
@@ -936,8 +976,11 @@ def generate_issue_pdf(issue, static_folder: str) -> bytes:
|
||||
]))
|
||||
story.append(v_tbl)
|
||||
|
||||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||
return buf.getvalue()
|
||||
try:
|
||||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||
return buf.getvalue()
|
||||
finally:
|
||||
_cleanup_media()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1547,3 +1590,95 @@ def generate_facility_summary_pdf(facility, days, start, now,
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── QR code sheet (phase42) ───────────────────────────────────────────────────
|
||||
|
||||
def generate_qr_codes_pdf(items, filter_summary: str = '') -> bytes:
|
||||
"""Return a PDF byte-string laying out selected QR codes in a grid.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
items : list of dicts, each:
|
||||
{
|
||||
'title': str, # main label (facility or area name)
|
||||
'subtitle': str | None, # e.g. contract name, or parent facility
|
||||
'caption': str | None, # small line under the QR
|
||||
'png': bytes, # QR code PNG image bytes
|
||||
}
|
||||
filter_summary : human-readable string describing the selection (optional)
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||||
report_title = 'QR Codes'
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=letter,
|
||||
leftMargin=0.65 * inch,
|
||||
rightMargin=0.65 * inch,
|
||||
topMargin=1.35 * inch,
|
||||
bottomMargin=0.75 * inch,
|
||||
title=report_title,
|
||||
author='Janitorial QC System',
|
||||
)
|
||||
|
||||
def _page_cb(canvas, doc):
|
||||
_on_page(canvas, doc, report_title, generated_at)
|
||||
|
||||
story = []
|
||||
if filter_summary:
|
||||
story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub']))
|
||||
story.append(Paragraph(
|
||||
f'Total codes: {len(items)}', STYLES['ReportSub']))
|
||||
story.append(Spacer(1, 10))
|
||||
|
||||
if not items:
|
||||
story.append(Paragraph('No QR codes selected.', STYLES['FieldValue']))
|
||||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||
return buf.getvalue()
|
||||
|
||||
COLS = 3
|
||||
pw = letter[0] - 1.3 * inch # usable width
|
||||
cell_w = pw / COLS
|
||||
qr_size = 1.7 * inch
|
||||
|
||||
title_style = ParagraphStyle('QRT', fontName='Helvetica-Bold', fontSize=9,
|
||||
alignment=TA_CENTER, leading=11, textColor=C_DARK)
|
||||
sub_style = ParagraphStyle('QRS', fontName='Helvetica', fontSize=7.5,
|
||||
alignment=TA_CENTER, leading=9, textColor=C_SLATE)
|
||||
cap_style = ParagraphStyle('QRC', fontName='Helvetica', fontSize=6.5,
|
||||
alignment=TA_CENTER, leading=8, textColor=C_SLATE)
|
||||
|
||||
def _cell(item):
|
||||
flow = [Paragraph(item.get('title') or '', title_style)]
|
||||
if item.get('subtitle'):
|
||||
flow.append(Paragraph(item['subtitle'], sub_style))
|
||||
flow.append(Spacer(1, 4))
|
||||
flow.append(RLImage(io.BytesIO(item['png']), width=qr_size, height=qr_size))
|
||||
if item.get('caption'):
|
||||
flow.append(Spacer(1, 3))
|
||||
flow.append(Paragraph(item['caption'], cap_style))
|
||||
return flow
|
||||
|
||||
rows = []
|
||||
for i in range(0, len(items), COLS):
|
||||
chunk = items[i:i + COLS]
|
||||
row = [_cell(it) for it in chunk]
|
||||
while len(row) < COLS:
|
||||
row.append('') # filler cell to keep the grid rectangular
|
||||
rows.append(row)
|
||||
|
||||
tbl = Table(rows, colWidths=[cell_w] * COLS)
|
||||
tbl.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 16),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||||
]))
|
||||
story.append(tbl)
|
||||
|
||||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||
return buf.getvalue()
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
app/utils/storage.py
|
||||
---------------------
|
||||
Storage abstraction for uploaded photo files (R2 object-storage migration).
|
||||
|
||||
Ported from the single-tenant tree (§22 Phase 1/2) with one multi-tenant
|
||||
addition: per-tenant object-key prefixing. See "Tenant isolation" below.
|
||||
|
||||
One interface, backend selected by config ``STORAGE_BACKEND``:
|
||||
- ``local`` (default): files under ``app/static/uploads``, served via
|
||||
``url_for('static', ...)``. **Byte-for-byte identical** to the behavior
|
||||
before this abstraction existed — introducing this seam is a no-op.
|
||||
- ``s3``: Cloudflare R2 / any S3-compatible store. Private bucket; browser/API
|
||||
URLs are short-lived presigned GETs. Requires ``boto3`` and the ``R2_*``
|
||||
config keys. boto3 is imported lazily, so a ``local`` deploy needs neither.
|
||||
Inert until STORAGE_BACKEND is flipped to s3 per tenant at R2 cutover.
|
||||
|
||||
The stored **key** is always the relative path ``uploads/<subfolder>/<file>`` —
|
||||
the exact string persisted in the DB (`Issue.photo_path`, `result_photos[]`,
|
||||
`mobile_photo_paths[]`, `Inspection.form_data` image values,
|
||||
`InspectionResult.photo_path`). It never changes between backends or between
|
||||
tenants, so there is **no schema migration** and no data rewrite.
|
||||
|
||||
Tenant isolation
|
||||
----------------
|
||||
Every tenant's DB holds keys in the same ``uploads/...`` namespace, so on a
|
||||
shared object store those keys would collide across tenants. The prefix
|
||||
``t<tenant_id>/`` is therefore applied **inside the S3 backend, on the wire**
|
||||
(``_object_key``) — never in the DB, never in a template, never in an API
|
||||
payload. Callers stay tenant-agnostic; the DB stays portable.
|
||||
|
||||
DB value: uploads/issue_photos/ab12….jpg
|
||||
R2 object: t3/uploads/issue_photos/ab12….jpg
|
||||
|
||||
The **local** backend deliberately does NOT prefix: its layout is the existing
|
||||
on-disk tree, and prefixing would relocate every existing file (that is not a
|
||||
no-op, and this seam must be one). Local mode therefore keeps today's shared
|
||||
``app/static/uploads`` directory across tenants — an isolation weakness that
|
||||
predates this module and is retired when a tenant moves to ``s3`` at cutover.
|
||||
|
||||
Public module-level API (delegates to the active backend):
|
||||
|
||||
save(file_obj, subfolder) -> key # write an uploaded file, return its key
|
||||
media_url(key) -> str # browser/API URL for a key ('' if falsy)
|
||||
read(key) -> bytes # raw bytes (e.g. PDF export image embed)
|
||||
exists(key) -> bool
|
||||
delete(key) -> None # best-effort file removal
|
||||
abs_local_path(key) -> str # physical path under static/ (local backend)
|
||||
materialize_to_dir(keys) -> (base_dir, cleanup)
|
||||
|
||||
NOTE: extension / magic-byte validation stays in the CALLER (e.g. `_save_photo`,
|
||||
`api/photos.upload_photo`) — this module only moves bytes and builds URLs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from flask import current_app, g, url_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ext_of(filename):
|
||||
"""Lower-case extension of a filename, defaulting to 'jpg' when absent."""
|
||||
name = filename or ''
|
||||
return name.rsplit('.', 1)[-1].lower() if '.' in name else 'jpg'
|
||||
|
||||
|
||||
def tenant_key_prefix():
|
||||
"""Object-key prefix isolating one tenant's media from another's.
|
||||
|
||||
Returns ``'t<id>/'`` when multi-tenancy is enabled and a tenant is bound to
|
||||
the current context, else ``''``.
|
||||
|
||||
The empty case covers three legitimate situations:
|
||||
- MULTI_TENANT_ENABLED is false (plain single-tenant deploy),
|
||||
- a cross-tenant cron/CLI context with no tenant bound,
|
||||
- request contexts exempt from the tenant middleware.
|
||||
|
||||
Callers that must not silently write to an unprefixed key guard on this
|
||||
themselves — see ``S3Backend.save``.
|
||||
"""
|
||||
if not current_app.config.get('MULTI_TENANT_ENABLED'):
|
||||
return ''
|
||||
tenant = getattr(g, 'tenant', None) if g else None
|
||||
return f't{tenant.id}/' if tenant is not None else ''
|
||||
|
||||
|
||||
# ── Local filesystem backend (current behavior) ───────────────────────────────
|
||||
|
||||
class LocalBackend:
|
||||
"""Files under ``app/static/uploads``, served by Flask/Nginx static route.
|
||||
|
||||
No tenant prefixing — see module docstring ("Tenant isolation").
|
||||
"""
|
||||
|
||||
name = 'local'
|
||||
|
||||
def _uploads_root(self):
|
||||
# config UPLOAD_FOLDER == <root>/app/static/uploads
|
||||
return current_app.config['UPLOAD_FOLDER']
|
||||
|
||||
def _static_folder(self):
|
||||
# <root>/app/static — key 'uploads/...' resolves under here
|
||||
return os.path.join(current_app.root_path, 'static')
|
||||
|
||||
def save(self, file_obj, subfolder):
|
||||
"""Write ``file_obj`` under ``subfolder``; return the ``uploads/...`` key.
|
||||
|
||||
Replicates the previous inline logic in `_save_photo` / `upload_photo`
|
||||
exactly: random uuid filename, `os.makedirs(exist_ok=True)`, same key.
|
||||
"""
|
||||
ext = _ext_of(file_obj.filename)
|
||||
filename = f'{uuid.uuid4().hex}.{ext}'
|
||||
dest_dir = os.path.join(self._uploads_root(), subfolder)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
file_obj.save(os.path.join(dest_dir, filename))
|
||||
return f'uploads/{subfolder}/{filename}'
|
||||
|
||||
def abs_local_path(self, key):
|
||||
return os.path.normpath(os.path.join(self._static_folder(), key))
|
||||
|
||||
def media_url(self, key, external=False):
|
||||
if not key:
|
||||
return ''
|
||||
# external=True yields an absolute URL (scheme+host) for API responses
|
||||
# consumed off-origin (the iPad); templates call with external=False.
|
||||
return url_for('static', filename=key, _external=external)
|
||||
|
||||
def read(self, key):
|
||||
with open(self.abs_local_path(key), 'rb') as fh:
|
||||
return fh.read()
|
||||
|
||||
def exists(self, key):
|
||||
return bool(key) and os.path.isfile(self.abs_local_path(key))
|
||||
|
||||
def delete(self, key):
|
||||
if not key:
|
||||
return
|
||||
try:
|
||||
path = self.abs_local_path(key)
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def materialize_to_dir(self, keys):
|
||||
# Files already live under the static folder — no copy needed.
|
||||
return self._static_folder(), (lambda: None)
|
||||
|
||||
|
||||
# ── Cloudflare R2 / S3-compatible backend ─────────────────────────────────────
|
||||
|
||||
_CONTENT_TYPES = {
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
||||
'png': 'image/png', 'gif': 'image/gif',
|
||||
}
|
||||
|
||||
|
||||
class S3Backend:
|
||||
"""
|
||||
Cloudflare R2 (or any S3-compatible store). Objects are private; browser/API
|
||||
URLs are short-lived presigned GETs. The object key == tenant prefix + the
|
||||
DB path string (see module docstring).
|
||||
|
||||
boto3 is imported lazily so a 'local' deployment doesn't require it installed.
|
||||
"""
|
||||
|
||||
name = 's3'
|
||||
|
||||
def __init__(self):
|
||||
import boto3 # lazy — only when s3 is active
|
||||
from botocore.config import Config as _BotoConfig
|
||||
|
||||
cfg = current_app.config
|
||||
missing = [k for k in ('R2_ENDPOINT_URL', 'R2_ACCESS_KEY_ID',
|
||||
'R2_SECRET_ACCESS_KEY', 'R2_BUCKET') if not cfg.get(k)]
|
||||
if missing:
|
||||
raise RuntimeError(f'STORAGE_BACKEND=s3 but missing config: {", ".join(missing)}')
|
||||
|
||||
self.bucket = cfg['R2_BUCKET']
|
||||
self.ttl = int(cfg.get('R2_PRESIGN_TTL', 86400))
|
||||
self.fallback = bool(cfg.get('R2_MEDIA_FALLBACK', False))
|
||||
self._client = boto3.client(
|
||||
's3',
|
||||
endpoint_url = cfg['R2_ENDPOINT_URL'],
|
||||
aws_access_key_id = cfg['R2_ACCESS_KEY_ID'],
|
||||
aws_secret_access_key = cfg['R2_SECRET_ACCESS_KEY'],
|
||||
region_name = 'auto',
|
||||
config = _BotoConfig(signature_version='s3v4'),
|
||||
)
|
||||
# A LocalBackend for transition fallback (read/media_url when object absent).
|
||||
self._local = LocalBackend()
|
||||
|
||||
# ── Key mapping ──────────────────────────────────────────────────────────
|
||||
|
||||
def _object_key(self, key):
|
||||
"""DB key -> on-the-wire object key (tenant-prefixed)."""
|
||||
return f'{tenant_key_prefix()}{key}'
|
||||
|
||||
def _read_candidates(self, key):
|
||||
"""Object keys to try when reading, most-specific first.
|
||||
|
||||
The unprefixed key is retained as a fallback so objects written before a
|
||||
tenant prefix existed (or by a no-tenant context) stay readable.
|
||||
"""
|
||||
prefixed = self._object_key(key)
|
||||
return [prefixed] if prefixed == key else [prefixed, key]
|
||||
|
||||
def _content_type(self, key):
|
||||
return _CONTENT_TYPES.get(_ext_of(key), 'application/octet-stream')
|
||||
|
||||
# ── Interface ────────────────────────────────────────────────────────────
|
||||
|
||||
def save(self, file_obj, subfolder):
|
||||
ext = _ext_of(file_obj.filename)
|
||||
key = f'uploads/{subfolder}/{uuid.uuid4().hex}.{ext}'
|
||||
|
||||
# Fail loudly rather than write a key that could collide with another
|
||||
# tenant's. A missing tenant here means the caller reached an upload
|
||||
# path outside tenant resolution — a bug, not a fallback case.
|
||||
if current_app.config.get('MULTI_TENANT_ENABLED') and not tenant_key_prefix():
|
||||
raise RuntimeError(
|
||||
'storage.save() called with STORAGE_BACKEND=s3 and no tenant '
|
||||
'bound — refusing to write an unprefixed object key.'
|
||||
)
|
||||
|
||||
file_obj.stream.seek(0)
|
||||
body = file_obj.stream.read()
|
||||
self._client.put_object(
|
||||
Bucket=self.bucket, Key=self._object_key(key), Body=body,
|
||||
ContentType=_CONTENT_TYPES.get(ext, 'application/octet-stream'),
|
||||
)
|
||||
# The DB always stores the unprefixed key.
|
||||
return key
|
||||
|
||||
def abs_local_path(self, key):
|
||||
# No local path for an S3 object; expose the local mirror path (may or may
|
||||
# not exist) so transition code / callers can still reference it.
|
||||
return self._local.abs_local_path(key)
|
||||
|
||||
def media_url(self, key, external=False):
|
||||
if not key:
|
||||
return ''
|
||||
obj = self._object_key(key)
|
||||
if self.fallback and not self._head(obj):
|
||||
# Object not at the tenant-prefixed key — try the legacy unprefixed
|
||||
# key, then the local copy, during transition.
|
||||
if obj != key and self._head(key):
|
||||
obj = key
|
||||
elif self._local.exists(key):
|
||||
return self._local.media_url(key, external=external)
|
||||
# Presigned R2 URLs are always absolute; the external flag is moot.
|
||||
return self._client.generate_presigned_url(
|
||||
'get_object',
|
||||
Params={'Bucket': self.bucket, 'Key': obj},
|
||||
ExpiresIn=self.ttl,
|
||||
)
|
||||
|
||||
def read(self, key):
|
||||
for candidate in self._read_candidates(key):
|
||||
try:
|
||||
resp = self._client.get_object(Bucket=self.bucket, Key=candidate)
|
||||
return resp['Body'].read()
|
||||
except Exception:
|
||||
continue
|
||||
# Transition safety: fall back to a local copy if present.
|
||||
if self._local.exists(key):
|
||||
return self._local.read(key)
|
||||
raise FileNotFoundError(f'storage: no object for key {key!r}')
|
||||
|
||||
def _head(self, key):
|
||||
try:
|
||||
self._client.head_object(Bucket=self.bucket, Key=key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def exists(self, key):
|
||||
if not key:
|
||||
return False
|
||||
return any(self._head(k) for k in self._read_candidates(key))
|
||||
|
||||
def delete(self, key):
|
||||
if not key:
|
||||
return
|
||||
for candidate in self._read_candidates(key):
|
||||
try:
|
||||
self._client.delete_object(Bucket=self.bucket, Key=candidate)
|
||||
except Exception as e:
|
||||
logger.warning('S3 delete failed for %s: %s', candidate, e)
|
||||
|
||||
def materialize_to_dir(self, keys):
|
||||
"""Download the given keys to a temp dir mirroring the key layout.
|
||||
|
||||
Returns (base_dir, cleanup); each key resolves at base_dir/key so tools
|
||||
that need real file paths (ReportLab/PIL) work unchanged. The temp tree
|
||||
uses the **DB** key layout (unprefixed) because that is what callers
|
||||
join against. Missing or unreadable keys are skipped — callers already
|
||||
guard with os.path.exists.
|
||||
"""
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
base = tempfile.mkdtemp(prefix='jqc_media_')
|
||||
for key in {k for k in keys if k}:
|
||||
try:
|
||||
data = self.read(key)
|
||||
except Exception:
|
||||
continue # skip missing; caller guards existence
|
||||
dest = os.path.join(base, key)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, 'wb') as fh:
|
||||
fh.write(data)
|
||||
|
||||
def _cleanup():
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
|
||||
return base, _cleanup
|
||||
|
||||
|
||||
_BACKENDS = {
|
||||
'local': LocalBackend,
|
||||
's3': S3Backend,
|
||||
}
|
||||
|
||||
|
||||
def get_backend():
|
||||
"""Return the active storage backend (one cached instance per app)."""
|
||||
name = (current_app.config.get('STORAGE_BACKEND') or 'local').lower()
|
||||
cls = _BACKENDS.get(name)
|
||||
if cls is None:
|
||||
logger.warning('Unknown STORAGE_BACKEND=%r — falling back to local', name)
|
||||
cls = LocalBackend
|
||||
cache_key = f'_storage_backend_{cls.name}'
|
||||
inst = current_app.extensions.get(cache_key)
|
||||
if inst is None:
|
||||
inst = cls()
|
||||
current_app.extensions[cache_key] = inst
|
||||
return inst
|
||||
|
||||
|
||||
# ── Module-level convenience delegates ────────────────────────────────────────
|
||||
|
||||
def save(file_obj, subfolder):
|
||||
return get_backend().save(file_obj, subfolder)
|
||||
|
||||
|
||||
def media_url(key, external=False):
|
||||
return get_backend().media_url(key, external=external)
|
||||
|
||||
|
||||
def read(key):
|
||||
return get_backend().read(key)
|
||||
|
||||
|
||||
def exists(key):
|
||||
return get_backend().exists(key)
|
||||
|
||||
|
||||
def delete(key):
|
||||
return get_backend().delete(key)
|
||||
|
||||
|
||||
def abs_local_path(key):
|
||||
return get_backend().abs_local_path(key)
|
||||
|
||||
|
||||
def materialize_to_dir(keys):
|
||||
return get_backend().materialize_to_dir(keys)
|
||||
@@ -62,6 +62,24 @@ class Config:
|
||||
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||
|
||||
# ── Storage backend (R2 migration, MT-2) ────────────────────────────────
|
||||
# 'local' (default) = files under app/static/uploads, served via url_for('static').
|
||||
# 's3' = Cloudflare R2 / S3-compatible. Flip via env only, per tenant, in MT-8.
|
||||
# Object keys are tenant-prefixed inside the backend; DB paths never change.
|
||||
STORAGE_BACKEND = os.environ.get('STORAGE_BACKEND', 'local')
|
||||
|
||||
# Cloudflare R2 — only used when STORAGE_BACKEND=s3 (inert until MT-8).
|
||||
R2_ENDPOINT_URL = os.environ.get('R2_ENDPOINT_URL')
|
||||
R2_ACCESS_KEY_ID = os.environ.get('R2_ACCESS_KEY_ID')
|
||||
R2_SECRET_ACCESS_KEY = os.environ.get('R2_SECRET_ACCESS_KEY')
|
||||
R2_BUCKET = os.environ.get('R2_BUCKET')
|
||||
R2_PRESIGN_TTL = int(os.environ.get('R2_PRESIGN_TTL', '86400')) # seconds (24h)
|
||||
# When true, media_url HEADs the object and falls back to the legacy
|
||||
# unprefixed key, then a local static URL, if it's missing (belt-and-braces
|
||||
# during an early/partial cutover). Off by default — cutover is gated on
|
||||
# full verification, so objects exist.
|
||||
R2_MEDIA_FALLBACK = os.environ.get('R2_MEDIA_FALLBACK', 'false').lower() == 'true'
|
||||
|
||||
# ── Session / cookies ───────────────────────────────────────────────────
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
|
||||
# Secure by default — subclasses must explicitly opt out for local dev.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""phase41 — add 'auditor' role to users.role ENUM
|
||||
|
||||
Introduces a new staff role, Auditor, with the same access as Project Manager
|
||||
plus full issue-management powers (create, assign, quick-assign, handler/vendor
|
||||
triage, request-verification, verify/bulk-verify/verification-queue) — but NOT
|
||||
issue deletion (that stays admin/director via @supervisor_required).
|
||||
|
||||
Ported from the single-tenant chain (phase40_auditor_role) and renumbered onto
|
||||
the multi-tenant HEAD.
|
||||
|
||||
This is a pure ENUM expansion (adds a value, removes none, no data migration),
|
||||
so the 3-step ENUM protocol does not apply. Re-running the same MODIFY is a
|
||||
no-op — safe to re-run on every tenant DB.
|
||||
"""
|
||||
|
||||
revision = 'phase41_auditor_role'
|
||||
down_revision = 'phase40_support_chat_kb'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
_ENUM_WITH_AUDITOR = (
|
||||
"ENUM('admin','director','inspector','project_manager','customer','auditor')"
|
||||
)
|
||||
_ENUM_WITHOUT_AUDITOR = (
|
||||
"ENUM('admin','director','inspector','project_manager','customer')"
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Idempotent: MODIFY to the expanded set is harmless if already applied.
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITH_AUDITOR} NOT NULL"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Reassign any auditor rows before contracting the ENUM so no data is lost.
|
||||
op.execute(sa.text(
|
||||
"UPDATE users SET role = 'project_manager' WHERE role = 'auditor'"
|
||||
))
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITHOUT_AUDITOR} NOT NULL"
|
||||
))
|
||||
@@ -0,0 +1,67 @@
|
||||
"""phase42 — areas.qr_token for per-area public QR landing pages
|
||||
|
||||
Adds a unique, unguessable token per area. Each area's QR encodes
|
||||
/f/area/<qr_token>, a login-free summary scoped to that area plus a
|
||||
"report a problem" form.
|
||||
|
||||
Ported from the single-tenant chain (phase39_area_public_token) and adapted:
|
||||
the MT column is named `qr_token` (matching `facilities.qr_token` from
|
||||
phase38_facility_qr), not `public_token`, and is VARCHAR(64) to match the
|
||||
facility column and MT's `secrets.token_urlsafe(32)` generator.
|
||||
|
||||
Unlike the ST original, existing areas are NOT backfilled: MT mints tokens
|
||||
lazily via `Area.ensure_qr_token()` on first use, exactly as
|
||||
`Facility.ensure_qr_token()` already does. A backfill would mint tokens for
|
||||
areas nobody ever prints a code for.
|
||||
|
||||
Uses INFORMATION_SCHEMA checks — safe to re-run on every tenant DB.
|
||||
"""
|
||||
|
||||
revision = 'phase42_area_qr_token'
|
||||
down_revision = 'phase41_auditor_role'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _column_exists(conn, table, column):
|
||||
return 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() > 0
|
||||
|
||||
|
||||
def _index_exists(conn, table, index):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :t AND INDEX_NAME = :i"
|
||||
), {"t": table, "i": index}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _column_exists(bind, 'areas', 'qr_token'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE areas ADD COLUMN qr_token VARCHAR(64) NULL"
|
||||
))
|
||||
|
||||
# Unique index tolerates multiple NULLs in MySQL, so it can be created
|
||||
# immediately — no backfill needed before enforcing uniqueness.
|
||||
if not _index_exists(bind, 'areas', 'uq_area_qr_token'):
|
||||
op.execute(sa.text(
|
||||
"CREATE UNIQUE INDEX uq_area_qr_token ON areas (qr_token)"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if _index_exists(bind, 'areas', 'uq_area_qr_token'):
|
||||
op.execute(sa.text("DROP INDEX uq_area_qr_token ON areas"))
|
||||
if _column_exists(bind, 'areas', 'qr_token'):
|
||||
op.execute(sa.text("ALTER TABLE areas DROP COLUMN qr_token"))
|
||||
@@ -0,0 +1,120 @@
|
||||
"""phase43 — plan semantics for inspection_schedules
|
||||
|
||||
Extends MT's `inspection_schedules` (phase34) with the single-tenant "plan"
|
||||
model (ST phase36_scheduled_inspections), rather than adding a second, competing
|
||||
scheduler table. Adds:
|
||||
|
||||
notes TEXT — free-text brief for the inspector
|
||||
last_completed_at DATETIME — when the last occurrence was fulfilled
|
||||
advance_notified BOOL — per-occurrence reminder de-dup flags, reset
|
||||
due_notified BOOL when a recurring schedule rolls forward
|
||||
overdue_notified BOOL
|
||||
mode ENUM — 'auto' = cron materialises the Inspection
|
||||
'plan' = inspector clicks Start (ST behaviour)
|
||||
|
||||
inspections.inspection_schedule_id — FK back to the originating schedule
|
||||
|
||||
`next_run_at` (phase34) is reused as the due datetime — ST's `next_due_date` by
|
||||
another name. No duplicate column, no renames (rule 7).
|
||||
|
||||
Existing rows keep working exactly as before: `mode` defaults to 'auto', which
|
||||
is the only behaviour that has ever existed in MT. Nothing flips to 'plan'
|
||||
unless someone chooses it in the form.
|
||||
|
||||
INFORMATION_SCHEMA-guarded throughout — safe to re-run on every tenant DB.
|
||||
"""
|
||||
|
||||
revision = 'phase43_schedule_plan_fields'
|
||||
down_revision = 'phase42_area_qr_token'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _column_exists(conn, table, column):
|
||||
return 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() > 0
|
||||
|
||||
|
||||
def _fk_exists(conn, table, name):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t "
|
||||
"AND CONSTRAINT_NAME = :n AND CONSTRAINT_TYPE = 'FOREIGN KEY'"
|
||||
), {"t": table, "n": name}).scalar() > 0
|
||||
|
||||
|
||||
def _index_exists(conn, table, index):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :t AND INDEX_NAME = :i"
|
||||
), {"t": table, "i": index}).scalar() > 0
|
||||
|
||||
|
||||
_NEW_COLUMNS = (
|
||||
('notes', 'TEXT NULL'),
|
||||
('last_completed_at', 'DATETIME NULL'),
|
||||
('advance_notified', 'TINYINT(1) NOT NULL DEFAULT 0'),
|
||||
('due_notified', 'TINYINT(1) NOT NULL DEFAULT 0'),
|
||||
('overdue_notified', 'TINYINT(1) NOT NULL DEFAULT 0'),
|
||||
('mode', "ENUM('auto','plan') NOT NULL DEFAULT 'auto'"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
for name, ddl in _NEW_COLUMNS:
|
||||
if not _column_exists(bind, 'inspection_schedules', name):
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE inspection_schedules ADD COLUMN {name} {ddl}"
|
||||
))
|
||||
|
||||
# Link a materialised/started Inspection back to its schedule.
|
||||
if not _column_exists(bind, 'inspections', 'inspection_schedule_id'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE inspections ADD COLUMN inspection_schedule_id INT NULL"
|
||||
))
|
||||
if not _index_exists(bind, 'inspections', 'ix_inspections_inspection_schedule_id'):
|
||||
op.execute(sa.text(
|
||||
"CREATE INDEX ix_inspections_inspection_schedule_id "
|
||||
"ON inspections (inspection_schedule_id)"
|
||||
))
|
||||
if not _fk_exists(bind, 'inspections', 'fk_inspections_inspection_schedule'):
|
||||
# ON DELETE SET NULL: deleting a schedule must never delete completed
|
||||
# inspection history.
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE inspections "
|
||||
"ADD CONSTRAINT fk_inspections_inspection_schedule "
|
||||
"FOREIGN KEY (inspection_schedule_id) "
|
||||
"REFERENCES inspection_schedules (id) ON DELETE SET NULL"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if _fk_exists(bind, 'inspections', 'fk_inspections_inspection_schedule'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE inspections DROP FOREIGN KEY fk_inspections_inspection_schedule"
|
||||
))
|
||||
if _index_exists(bind, 'inspections', 'ix_inspections_inspection_schedule_id'):
|
||||
op.execute(sa.text(
|
||||
"DROP INDEX ix_inspections_inspection_schedule_id ON inspections"
|
||||
))
|
||||
if _column_exists(bind, 'inspections', 'inspection_schedule_id'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE inspections DROP COLUMN inspection_schedule_id"
|
||||
))
|
||||
|
||||
for name, _ddl in reversed(_NEW_COLUMNS):
|
||||
if _column_exists(bind, 'inspection_schedules', name):
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE inspection_schedules DROP COLUMN {name}"
|
||||
))
|
||||
@@ -67,7 +67,13 @@ def test_cron_materialises_due_schedule_and_notifies(client):
|
||||
|
||||
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {'ok': True, 'due': 1, 'created': 1}
|
||||
# phase43 widened this response with a 'reminders' key (plan-mode schedules).
|
||||
# Materialisation of 'auto' schedules is unchanged.
|
||||
payload = resp.get_json()
|
||||
assert payload['ok'] is True
|
||||
assert payload['due'] == 1
|
||||
assert payload['created'] == 1
|
||||
assert payload['reminders'] == {'advance': 0, 'due': 0, 'overdue': 0}
|
||||
|
||||
# A real in_progress inspection now exists for the assigned inspector.
|
||||
insp = Inspection.query.filter_by(inspector_id=insp_user_id).first()
|
||||
|
||||
Reference in New Issue
Block a user