Compare commits

..
10 Commits
59 changed files with 3097 additions and 234 deletions
+24 -11
View File
@@ -2,7 +2,7 @@
> **Audience:** AI assistants and developers working on this codebase. > **Audience:** AI assistants and developers working on this codebase.
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. > **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, phase3032 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 AE + 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 R1R4 + 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, phase3032 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 AE + 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 R1R4 + 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/ │ ├── models/
│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B) │ │ ├── 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) │ │ ├── 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) │ │ ├── tenant_settings.py # MT-7: TenantSettings — per-tenant branding (one row per tenant DB)
│ │ └── ... │ │ └── ...
│ ├── routes/ │ ├── 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. 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. 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 ### 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`, `13 days`, `37 days`, `14 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). Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `13 days`, `37 days`, `14 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. 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. 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_severity` — dict with `total`, `met`, `pct`, `sla_hours` per severity tier
- `by_facility` — list sorted by compliance % descending - `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. 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**. 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 ### Support Chat — Customer UX
`GET /support/chat` — customer only. Renders: `GET /support/chat` — customer only. Accepts optional `?session_id=N` to reload a prior conversation.
- 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. Renders:
- Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns. - 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. - 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 ### 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`. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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`) ### 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 | | `event_type` | Trigger | Required context keys |
|---|---|---| |---|---|---|
+20 -1
View File
@@ -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_remaining'] = sla_hours_remaining
app.jinja_env.globals['SLA_HOURS'] = SLA_HOURS 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 ────── # ── Inject unread notification count into every template context ──────
# This powers the red badge on the navbar bell icon without requiring # This powers the red badge on the navbar bell icon without requiring
# individual routes to pass the count manually. # 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.notifications import bp as _api_notifications_bp
from app.api.stats import bp as _api_stats_bp from app.api.stats import bp as _api_stats_bp
from app.api.comments import bp as _api_comments_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_auth_bp)
csrf.exempt(_api_facilities_bp) csrf.exempt(_api_facilities_bp)
csrf.exempt(_api_templates_bp) csrf.exempt(_api_templates_bp)
@@ -270,11 +276,24 @@ def create_app(config_name='default'):
csrf.exempt(_api_notifications_bp) csrf.exempt(_api_notifications_bp)
csrf.exempt(_api_stats_bp) csrf.exempt(_api_stats_bp)
csrf.exempt(_api_comments_bp) csrf.exempt(_api_comments_bp)
csrf.exempt(_api_scheduled_bp)
register_api(app) register_api(app)
# ── Security response headers ───────────────────────────────────────── # ── Security response headers ─────────────────────────────────────────
# Applied to every response. Blocks clickjacking, MIME sniffing, and # Applied to every response. Blocks clickjacking, MIME sniffing, and
# obvious XSS vectors without breaking Bootstrap CDN / Google Fonts. # 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 @app.after_request
def set_security_headers(response): def set_security_headers(response):
from flask import request as _request 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; " "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; " "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; " "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; " "connect-src 'self' https://cdn.jsdelivr.net; "
"frame-src https://maps.google.com https://www.google.com; " "frame-src https://maps.google.com https://www.google.com; "
# Hardening directives that don't affect existing inline scripts/styles: # Hardening directives that don't affect existing inline scripts/styles:
+4
View File
@@ -50,6 +50,10 @@ def register_api(app):
from app.api.comments import bp as comments_bp from app.api.comments import bp as comments_bp
api_bp.register_blueprint(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 # NOTE: device registration lives on the auth blueprint
# (POST /api/v1/devices/register in app/api/auth.py) and writes to the # (POST /api/v1/devices/register in app/api/auth.py) and writes to the
# canonical api_device_tokens table (model DeviceToken). A former duplicate # canonical api_device_tokens table (model DeviceToken). A former duplicate
+1 -1
View File
@@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_comments', __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: def _comment_payload(comment: IssueComment) -> dict:
+1 -1
View File
@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_inspections', __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: def _merge_form_data(existing: dict, incoming: dict) -> dict:
+97 -1
View File
@@ -42,9 +42,10 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_issues', __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_SEVERITY = {'low', 'medium', 'high', 'critical'}
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'} _VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
_UUID_RE = re.compile( _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}$', r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
re.IGNORECASE, re.IGNORECASE,
@@ -82,6 +83,18 @@ def _issue_payload(issue):
'area_name': issue.area.name if issue.area else None, '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 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, '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) issue.id, len(new_photos), user.username)
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)}) 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
View File
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_photos', __name__) bp = Blueprint('api_photos', __name__)
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} _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: def _allowed_file(filename: str) -> bool:
@@ -85,16 +85,10 @@ def upload_photo():
else: else:
subfolder = 'inspection_photos' subfolder = 'inspection_photos'
ext = file_obj.filename.rsplit('.', 1)[-1].lower() # Write via the active storage backend (local disk or R2). Key format
filename = f'{uuid.uuid4().hex}.{ext}' # 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
from app.utils import storage
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder) server_path = storage.save(file_obj, 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}'
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s', logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
entity_type, server_path, user.username) entity_type, server_path, user.username)
+124
View File
@@ -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
View File
@@ -40,7 +40,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_stats', __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']) @bp.route('/stats/dashboard', methods=['GET'])
+1 -1
View File
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_templates', __name__) bp = Blueprint('api_templates', __name__)
# Customer role cannot access template data — inspectors and above only # 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: def _template_summary_payload(template: InspectionTemplate) -> dict:
+12
View File
@@ -91,6 +91,17 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
app = current_app._get_current_object() 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(): def _send():
try: try:
with app.app_context(): with app.app_context():
@@ -105,6 +116,7 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
msg = Message( msg = Message(
subject=subject, subject=subject,
recipients=[to_addr], recipients=[to_addr],
sender=_sender or None,
body=plain_body, body=plain_body,
html=html_body, html=html_body,
) )
+12
View File
@@ -42,6 +42,18 @@ class Area(db.Model):
name = db.Column(db.String(255), nullable=False) name = db.Column(db.String(255), nullable=False)
area_type = db.Column(db.String(50)) 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 # Relationships
inspections = db.relationship('Inspection', backref='area', lazy='dynamic') inspections = db.relationship('Inspection', backref='area', lazy='dynamic')
issues = db.relationship('Issue', backref='area', lazy='dynamic') issues = db.relationship('Issue', backref='area', lazy='dynamic')
+7
View File
@@ -57,6 +57,13 @@ class Inspection(db.Model):
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False) 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) facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
area_id = db.Column(db.Integer, db.ForeignKey('areas.id')) 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) inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern) inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern)
overall_score = db.Column(db.Numeric(5, 2)) overall_score = db.Column(db.Numeric(5, 2))
+67 -1
View File
@@ -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 This is purely additive: no existing inspection behaviour changes. A schedule is
just an automated `inspections.start()`. 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 from app import db
@@ -49,6 +63,11 @@ class InspectionSchedule(db.Model):
) )
active = db.Column(db.Boolean, nullable=False, default=True) 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( created_by = db.Column(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True nullable=True
@@ -57,6 +76,13 @@ class InspectionSchedule(db.Model):
last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation 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 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. # Relationships — explicit foreign_keys because two columns point at users.id.
template = db.relationship('InspectionTemplate', foreign_keys=[template_id]) template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
facility = db.relationship('Facility', foreign_keys=[facility_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]) inspector = db.relationship('User', foreign_keys=[inspector_id])
creator = db.relationship('User', foreign_keys=[created_by]) 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): 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}>')
+14
View File
@@ -116,6 +116,20 @@ class Issue(db.Model):
"""Return True if the given user is currently following this issue.""" """Return True if the given user is currently following this issue."""
return self.followers.filter_by(user_id=user.id).first() is not None 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 @property
def resolved_facility(self): def resolved_facility(self):
"""Returns the Facility for this issue regardless of which path was used to create it. """Returns the Facility for this issue regardless of which path was used to create it.
+1
View File
@@ -40,6 +40,7 @@ MATRIX_ROLES = [
('director', 'Director'), ('director', 'Director'),
('inspector', 'Inspector'), ('inspector', 'Inspector'),
('project_manager', 'Project Manager'), ('project_manager', 'Project Manager'),
('auditor', 'Auditor'),
('customer', 'Customer'), ('customer', 'Customer'),
('custom', 'Custom Recipients'), ('custom', 'Custom Recipients'),
] ]
+1 -1
View File
@@ -19,7 +19,7 @@ class User(UserMixin, db.Model):
role = db.Column( role = db.Column(
# Phase 11 migration complete — 'supervisor' removed from both the DB # Phase 11 migration complete — 'supervisor' removed from both the DB
# ENUM and this Python-side declaration. Director is the canonical role. # 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 nullable=False
) )
created_at = db.Column(db.DateTime, default=now_eastern) created_at = db.Column(db.DateTime, default=now_eastern)
+8 -3
View File
@@ -179,7 +179,6 @@ def _send_invite_email(user, token, base_url=None):
from flask import current_app, render_template_string from flask import current_app, render_template_string
from flask_mail import Message from flask_mail import Message
from app import mail from app import mail
from urllib.parse import urlparse
import threading import threading
if not current_app.config.get('MAIL_SERVER'): 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('/') 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)}' setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
host = urlparse(effective_base).netloc or 'janitorialqc.local' # Branded From as a (display_name, address) tuple. The display NAME tracks
sender = f'noreply@{host}' # 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_body = render_template_string("""<!DOCTYPE html>
<html> <html>
+27 -2
View File
@@ -29,6 +29,7 @@ def index():
is_privileged = current_user.role in ['admin', 'director'] is_privileged = current_user.role in ['admin', 'director']
is_customer = current_user.role == 'customer' is_customer = current_user.role == 'customer'
is_project_manager = current_user.role == 'project_manager' is_project_manager = current_user.role == 'project_manager'
is_auditor = current_user.role == 'auditor'
# Resolve facility scope # Resolve facility scope
customer_facility_ids = get_customer_scope(current_user) # None for non-customers 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_q = unassigned_q.filter(False) # not relevant for customers
unassigned_open = unassigned_q.count() unassigned_open = unassigned_q.count()
# ── Inspector activity today (admin / director / PM only) ───────────────── # ── Inspector activity today (admin / director / PM / auditor only) ───────
inspector_activity = [] inspector_activity = []
if is_privileged or is_project_manager: if is_privileged or is_project_manager or is_auditor:
active_inspectors = ( active_inspectors = (
User.query User.query
.filter_by(role='inspector', active=True) .filter_by(role='inspector', active=True)
@@ -343,8 +344,32 @@ def index():
.all() .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( return render_template(
'dashboard.html', 'dashboard.html',
sched_upcoming = sched_upcoming,
sched_overdue_count = sched_overdue_count,
today_inspections = today_inspections, today_inspections = today_inspections,
completed_today = completed_today, completed_today = completed_today,
open_issues = open_issues, open_issues = open_issues,
+303 -13
View File
@@ -6,7 +6,8 @@ from app.models.facility import Facility, Area
from app.models.project import Project from app.models.project import Project
from app.utils.forms import FacilityForm, AreaForm from app.utils.forms import FacilityForm, AreaForm
from app.utils.decorators import supervisor_required, admin_required, project_manager_required 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.utils.scope import get_customer_scope, get_inspector_scope
from app.tenancy.gates import quota_soft_check from app.tenancy.gates import quota_soft_check
@@ -244,20 +245,70 @@ def delete_area(area_id):
def _qr_scan_url(facility): def _qr_scan_url(facility):
"""Absolute public scan URL, built from the current host (rule 64 pattern).""" """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', return request.host_url.rstrip('/') + url_for('facility_qr.scan',
token=facility.qr_token) 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') @bp.route('/<int:facility_id>/qr')
@login_required @login_required
@project_manager_required
def qr_card(facility_id): def qr_card(facility_id):
"""Printable QR card for one facility. Generates the token on first use.""" """Printable QR card for one facility. Generates the token on first use."""
from app.utils.qr import qr_svg from app.utils.qr import qr_svg
facility = db.session.get(Facility, facility_id) facility = _facility_for_qr_or_403(facility_id)
if facility is None:
abort(404)
if not facility.qr_token: if not facility.qr_token:
facility.ensure_qr_token() facility.ensure_qr_token()
@@ -274,12 +325,20 @@ def qr_card(facility_id):
@bp.route('/qr-sheet') @bp.route('/qr-sheet')
@login_required @login_required
@project_manager_required
def qr_sheet(): def qr_sheet():
"""Bulk print sheet — one labeled QR card per active facility.""" """Bulk print sheet — one labeled QR card per active facility."""
from app.utils.qr import qr_svg 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 generated = 0
for f in facilities: for f in facilities:
@@ -297,14 +356,17 @@ def qr_sheet():
@bp.route('/<int:facility_id>/qr/regenerate', methods=['POST']) @bp.route('/<int:facility_id>/qr/regenerate', methods=['POST'])
@login_required @login_required
@supervisor_required
def regenerate_qr(facility_id): 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 import secrets
facility = db.session.get(Facility, facility_id) facility = _facility_for_qr_or_403(facility_id)
if facility is None: if current_user.role not in ('admin', 'director', 'customer'):
abort(404) abort(403)
facility.qr_token = secrets.token_urlsafe(32) facility.qr_token = secrets.token_urlsafe(32)
db.session.commit() db.session.commit()
@@ -314,4 +376,232 @@ def regenerate_qr(facility_id):
'QR token regenerated — previously printed QR posters are now invalid') 'QR token regenerated — previously printed QR posters are now invalid')
flash('QR code regenerated. Previously printed posters no longer work — ' flash('QR code regenerated. Previously printed posters no longer work — '
'print and post the new code.', 'success') 'print and post the new code.', 'success')
return redirect(url_for('facilities.qr_card', facility_id=facility_id)) 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
View File
@@ -5,7 +5,13 @@ Public facility QR scan page (phase38).
`GET /f/<token>` public, tokenized, NO login (same authorization model as `GET /f/<token>` public, tokenized, NO login (same authorization model as
vendor work orders, rule 89: the unguessable token IS the credential). Shows a 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, * summary stats (90 days): completed inspections, average score,
resolved issues, last inspection date 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 import logging
from datetime import timedelta 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 flask_login import current_user
from sqlalchemy import func, or_ from sqlalchemy import func, or_
@@ -33,6 +40,8 @@ from app import db, limiter
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.models.issue import Issue 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.sla import sla_status
from app.utils.scope import get_customer_scope, get_inspector_scope from app.utils.scope import get_customer_scope, get_inspector_scope
from app.utils.time_utils import now_eastern 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') 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): def _can_view_full(facility):
"""True when the logged-in scanner's role scope covers this facility.""" """True when the logged-in scanner's role scope covers this facility."""
@@ -57,27 +132,37 @@ def _can_view_full(facility):
return False return False
@bp.route('/<token>') def _build_snapshot(facility, area=None):
@limiter.limit('60 per hour') """Assemble the public snapshot for a facility, or for one area of it.
def scan(token):
facility = Facility.query.filter_by(qr_token=token).first()
if facility is None or not facility.active:
abort(404)
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() now = now_eastern()
d30 = now - timedelta(days=30) d30 = now - timedelta(days=30)
d60 = now - timedelta(days=60) d60 = now - timedelta(days=60)
d90 = now - timedelta(days=90) 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( completed = Inspection.query.filter(
Inspection.facility_id == facility.id, *insp_scope,
Inspection.status == 'completed', Inspection.status == 'completed',
) )
# ── Summary stats (90 days) ─────────────────────────────────────────── # ── Summary stats (90 days) ───────────────────────────────────────────
total_90 = completed.filter(Inspection.inspection_date >= d90).count() total_90 = completed.filter(Inspection.inspection_date >= d90).count()
avg_90 = db.session.query(func.avg(Inspection.overall_score)).filter( avg_90 = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.facility_id == facility.id, *insp_scope,
Inspection.status == 'completed', Inspection.status == 'completed',
Inspection.inspection_date >= d90, Inspection.inspection_date >= d90,
Inspection.overall_score.isnot(None), Inspection.overall_score.isnot(None),
@@ -91,7 +176,7 @@ def scan(token):
# ── Score trend: last 30 days vs prior 30 (mirrors send_score_alerts) ─ # ── Score trend: last 30 days vs prior 30 (mirrors send_score_alerts) ─
def _avg_between(start, end): def _avg_between(start, end):
return db.session.query(func.avg(Inspection.overall_score)).filter( return db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.facility_id == facility.id, *insp_scope,
Inspection.status == 'completed', Inspection.status == 'completed',
Inspection.overall_score.isnot(None), Inspection.overall_score.isnot(None),
Inspection.inspection_date >= start, 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 if (avg_cur is not None and avg_prior is not None) else None
# ── Open issues: counts by severity + SLA state (counts only) ───────── # ── 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( open_issues = issue_q.filter(
Issue.status.in_(('open', 'in_progress'))).all() Issue.status.in_(('open', 'in_progress'))).all()
severity_counts = {s: 0 for s in SEVERITY_ORDER} severity_counts = {s: 0 for s in SEVERITY_ORDER}
@@ -130,13 +210,9 @@ def scan(token):
Issue.resolved_at >= d90, Issue.resolved_at >= d90,
).count() ).count()
logger.info('FACILITY QR SCAN | facility_id=%s | authenticated=%s', return dict(
facility.id, current_user.is_authenticated)
return render_template(
'facility_qr/view.html',
token = token,
facility = facility, facility = facility,
area = area,
contract = facility.project, contract = facility.project,
total_90 = total_90, total_90 = total_90,
avg_90 = float(avg_90) if avg_90 is not None else None, 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_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, avg_prior = float(avg_prior) if avg_prior is not None else None,
open_total = len(open_issues), open_total = len(open_issues),
severity_counts = severity_counts, severity_counts = severity_counts,
severity_order = SEVERITY_ORDER, severity_order = SEVERITY_ORDER,
sla_at_risk = sla_at_risk, sla_at_risk = sla_at_risk,
sla_breached = sla_breached, 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']) @bp.route('/<token>/report', methods=['POST'])
@limiter.limit('5 per hour') @limiter.limit('5 per hour')
def report(token): def report(token):
@@ -165,42 +274,132 @@ def report(token):
No login required the unguessable QR token is the sole authorization. No login required the unguessable QR token is the sole authorization.
A honeypot field silently rejects bot submissions. Creates an Issue with A honeypot field silently rejects bot submissions. Creates an Issue with
reported_by=None so staff know it came from a public form. 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() facility = _facility_by_token_or_404(token)
if facility is None or not facility.active: form = PublicIssueReportForm()
abort(404)
# Honeypot — bots fill this field, humans leave it blank # 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) logger.warning('FACILITY QR REPORT | honeypot triggered | facility_id=%s', facility.id)
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1') return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
description = request.form.get('description', '').strip() if not form.validate_on_submit():
severity = request.form.get('severity', 'medium') # 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: severity = form.severity.data or 'medium'
return redirect(url_for('facility_qr.scan', token=token))
if severity not in ('low', 'medium', 'high'): if severity not in ('low', 'medium', 'high'):
severity = 'medium' severity = 'medium'
photo_path, extra_photos = _save_report_photos(form.photos.data)
description = _build_report_description(form, '[Reported via facility QR code]')
issue = Issue( issue = Issue(
facility_id = facility.id, facility_id = facility.id,
area_id = None,
severity = severity, severity = severity,
description = description, description = description,
photo_path = photo_path,
mobile_photo_paths = extra_photos,
status = 'open', status = 'open',
reported_by = None, # anonymous public submission reported_by = None, # anonymous public submission
) )
db.session.add(issue) db.session.add(issue)
db.session.commit() db.session.commit()
logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s', _photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
facility.id, issue.id, severity) 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) # Notify staff via the notification matrix (same event as Issues → Create).
try: # NOTE: title/body are REQUIRED positional args. The phase38 call omitted
from app.utils.notifications import notify_by_matrix # them, so every public QR report raised TypeError into the except below and
notify_by_matrix('issue_created', issue_id=issue.id, facility_id=facility.id) # nobody was ever notified — see MT3_DEPLOY.md §1.
except Exception as exc: _snippet = form.description.data.strip()
logger.error('FACILITY QR REPORT | notify_failed | err=%s', exc) 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') 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')
+217 -11
View File
@@ -2,7 +2,19 @@
app/routes/inspection_schedules.py app/routes/inspection_schedules.py
----------------------------------- -----------------------------------
CRUD management for recurring InspectionSchedule configs + a cron-triggered 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), Management is admin / director / project_manager (@project_manager_required),
mirroring who may start inspections. The /run route is token-protected with the 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') bp = Blueprint('inspection_schedules', __name__, url_prefix='/inspection-schedules')
_FREQUENCIES = ('daily', 'weekly', 'monthly', 'quarterly') _FREQUENCIES = ('daily', 'weekly', 'monthly', 'quarterly')
_MODES = ('auto', 'plan')
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -69,6 +82,29 @@ def _active_inspectors():
).order_by(User.full_name, User.username).all() ).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: def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
"""Create an in_progress Inspection from a schedule and notify the inspector. """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, inspector_id = schedule.inspector_id,
inspection_date = when, inspection_date = when,
status = 'in_progress', 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.add(inspection)
db.session.flush() # assign inspection.id without committing db.session.flush() # assign inspection.id without committing
@@ -106,13 +143,29 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
@bp.route('/') @bp.route('/')
@login_required @login_required
@project_manager_required
def index(): def index():
schedules = InspectionSchedule.query.order_by( """Schedule list.
InspectionSchedule.active.desc(), InspectionSchedule.name
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() ).all()
now = now_eastern()
return render_template('inspection_schedules/index.html', return render_template('inspection_schedules/index.html',
schedules=schedules, now=now_eastern()) schedules=schedules, now=now, today=now.date())
def _form_choices(): def _form_choices():
@@ -135,6 +188,8 @@ def create():
area_id = request.form.get('area_id', type=int) or None area_id = request.form.get('area_id', type=int) or None
inspector_id = request.form.get('inspector_id', type=int) inspector_id = request.form.get('inspector_id', type=int)
frequency = request.form.get('frequency', 'weekly') frequency = request.form.get('frequency', 'weekly')
mode = request.form.get('mode', 'auto')
notes = request.form.get('notes', '').strip() or None
errors = [] errors = []
if not name: if not name:
@@ -147,6 +202,8 @@ def create():
errors.append('Please choose a valid inspector.') errors.append('Please choose a valid inspector.')
if frequency not in _FREQUENCIES: if frequency not in _FREQUENCIES:
errors.append('Invalid frequency.') errors.append('Invalid frequency.')
if mode not in _MODES:
errors.append('Invalid mode.')
if errors: if errors:
for e in errors: for e in errors:
@@ -163,6 +220,8 @@ def create():
area_id = area_id, area_id = area_id,
inspector_id = inspector_id, inspector_id = inspector_id,
frequency = frequency, frequency = frequency,
mode = mode,
notes = notes,
active = True, active = True,
created_by = current_user.id, created_by = current_user.id,
created_at = now_eastern(), created_at = now_eastern(),
@@ -171,7 +230,12 @@ def create():
db.session.add(schedule) db.session.add(schedule)
db.session.commit() db.session.commit()
log_action(ACTION_CREATE, 'InspectionSchedule', schedule.id, schedule.name, 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') flash(f'Inspection schedule "{schedule.name}" created.', 'success')
return redirect(url_for('inspection_schedules.index')) return redirect(url_for('inspection_schedules.index'))
@@ -191,6 +255,7 @@ def edit(schedule_id):
templates, facilities, inspectors = _form_choices() templates, facilities, inspectors = _form_choices()
if request.method == 'POST': if request.method == 'POST':
old_inspector_id = schedule.inspector_id
schedule.name = request.form.get('name', '').strip() or schedule.name schedule.name = request.form.get('name', '').strip() or schedule.name
template_id = request.form.get('template_id', type=int) template_id = request.form.get('template_id', type=int)
facility_id = request.form.get('facility_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 schedule.area_id = request.form.get('area_id', type=int) or None
if frequency in _FREQUENCIES: if frequency in _FREQUENCIES:
schedule.frequency = frequency 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')) schedule.active = bool(request.form.get('active'))
# Recompute the next run from now against the (possibly changed) cadence. # Recompute the next run from now against the (possibly changed) cadence.
schedule.next_run_at = _compute_next_run(schedule.frequency) 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() db.session.commit()
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name, 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') flash(f'Inspection schedule "{schedule.name}" updated.', 'success')
return redirect(url_for('inspection_schedules.index')) return redirect(url_for('inspection_schedules.index'))
@@ -258,6 +337,55 @@ def run_now(schedule_id):
return redirect(url_for('inspection_schedules.index')) 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 ───────────────────────────────────────────────────────────── # ── Cron endpoint ─────────────────────────────────────────────────────────────
@bp.route('/run', methods=['POST']) @bp.route('/run', methods=['POST'])
@@ -276,7 +404,10 @@ def run():
now = now_eastern() now = now_eastern()
schedules = InspectionSchedule.query.filter_by(active=True).all() 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 created = 0
for schedule in due: for schedule in due:
@@ -295,5 +426,80 @@ def run():
schedule.id, exc) schedule.id, exc)
db.session.commit() db.session.commit()
logger.info('INSPECTION SCHEDULES CRON | due=%s | created=%s', len(due), created) sent = _dispatch_reminders([s for s in schedules if s.mode == 'plan'], now)
return jsonify({'ok': True, 'due': len(due), 'created': created})
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
View File
@@ -59,11 +59,10 @@ def _save_photo(file_obj, subfolder='inspection_photos'):
file_obj.seek(0) file_obj.seek(0)
if not any(header.startswith(m) for m in _IMAGE_MAGIC): if not any(header.startswith(m) for m in _IMAGE_MAGIC):
return None return None
filename = f"{uuid.uuid4().hex}.{ext}" # Write via the active storage backend (local disk or R2). Key format
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder) # 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
os.makedirs(dest_dir, exist_ok=True) from app.utils import storage
file_obj.save(os.path.join(dest_dir, filename)) return storage.save(file_obj, subfolder)
return f"uploads/{subfolder}/{filename}"
def _collect_form_responses(form_fields, existing_responses=None): def _collect_form_responses(form_fields, existing_responses=None):
@@ -581,6 +580,20 @@ def execute(inspection_id):
inspection_id = inspection.id, inspection_id = inspection.id,
facility_id = inspection.facility_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 db.session.commit() # Single atomic commit: inspection fields + notification rows
log_action(ACTION_UPDATE, 'Inspection', inspection.id, log_action(ACTION_UPDATE, 'Inspection', inspection.id,
f'{inspection.template.name} @ {inspection.facility.name}', 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)) return redirect(url_for('inspections.execute', inspection_id=inspection_id))
staff_for_flag_issue = User.query.filter( 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, User.active == True,
).order_by(User.full_name, User.username).all() ).order_by(User.full_name, User.username).all()
@@ -1323,15 +1336,12 @@ def delete(inspection_id):
db.session.delete(inspection) db.session.delete(inspection)
db.session.commit() 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: for rel_path in photo_paths:
abs_path = os.path.normpath( storage.delete(rel_path)
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
current_app.logger.info( current_app.logger.info(
'INSPECTION DELETED | id=%s | facility="%s" | template="%s" | ' 'INSPECTION DELETED | id=%s | facility="%s" | template="%s" | '
+23 -20
View File
@@ -15,7 +15,8 @@ from app.models.notification import (
EVENT_CUSTOMER_ISSUE_UPDATED, EVENT_CUSTOMER_ISSUE_UPDATED,
) )
from app.utils.forms import IssueForm, IssueUpdateForm 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.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.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
from app.tenancy.gates import quota_soft_check 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 for quick-assign dropdown — same roles as the full issue form
staff = User.query.filter( 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() ).order_by(User.username).all()
# Reporters dropdown — users who have actually filed at least one issue # 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)) return redirect(url_for('issues.view', issue_id=issue_id))
form = IssueUpdateForm(obj=issue) 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.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
form.status.data = form.status.data or issue.status form.status.data = form.status.data or issue.status
@@ -431,7 +439,7 @@ def view(issue_id):
issue.status = form.status.data 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 issue.assigned_to = form.assigned_to.data or None
if form.status.data == 'resolved' and not issue.resolved_at: 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 issue.result_notes = form.result_notes.data or None
# Vendor / contractor assignment — admin, director, project_manager only # 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_name = form.vendor_name.data.strip() or None
issue.vendor_contact = form.vendor_contact.data.strip() or None issue.vendor_contact = form.vendor_contact.data.strip() or None
issue.vendor_notes = form.vendor_notes.data.strip() or None issue.vendor_notes = form.vendor_notes.data.strip() or None
@@ -695,7 +703,7 @@ def unfollow(issue_id):
@login_required @login_required
@quota_soft_check('issues') @quota_soft_check('issues')
def create(): def create():
if current_user.role not in ('admin', 'director', 'customer'): if current_user.role not in ('admin', 'director', 'customer', 'auditor'):
abort(403) abort(403)
from app.models.project import Project, CustomerAssignment from app.models.project import Project, CustomerAssignment
@@ -717,7 +725,7 @@ def create():
else: else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
projects = Project.query.filter_by(active=True).order_by(Project.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.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] 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']) @bp.route('/<int:issue_id>/verify', methods=['POST'])
@login_required @login_required
@supervisor_required @issue_manager_required
def verify(issue_id): def verify(issue_id):
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue.""" """Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
issue = db.session.get(Issue, issue_id) issue = db.session.get(Issue, issue_id)
@@ -837,7 +845,7 @@ def verify(issue_id):
@bp.route('/bulk-verify', methods=['POST']) @bp.route('/bulk-verify', methods=['POST'])
@login_required @login_required
@supervisor_required @issue_manager_required
def bulk_verify(): def bulk_verify():
"""Verify multiple pending-verification issues in a single action.""" """Verify multiple pending-verification issues in a single action."""
issue_ids = request.form.getlist('issue_ids', type=int) 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 # Only the assignee, director, or admin can request verification
can_act = ( can_act = (
current_user.role in ['admin', 'director'] current_user.role in ['admin', 'director', 'auditor']
or issue.assigned_to == current_user.id or issue.assigned_to == current_user.id
) )
if not can_act: if not can_act:
@@ -927,7 +935,7 @@ def request_verification(issue_id):
@bp.route('/verification-queue') @bp.route('/verification-queue')
@login_required @login_required
@supervisor_required @issue_manager_required
def verification_queue(): def verification_queue():
"""Supervisor queue of all issues awaiting verification, grouped by facility.""" """Supervisor queue of all issues awaiting verification, grouped by facility."""
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
@@ -1000,15 +1008,10 @@ def delete(issue_id):
db.session.delete(issue) db.session.delete(issue)
db.session.commit() db.session.commit()
# Remove orphaned photo files — best-effort, never block on failure # Remove orphaned photo files from the active storage backend — best-effort.
static_folder = current_app.root_path from app.utils import storage
for rel_path in photo_paths: for rel_path in photo_paths:
abs_path = os.path.normpath(os.path.join(static_folder, 'static', rel_path)) storage.delete(rel_path)
try:
if os.path.isfile(abs_path):
os.remove(abs_path)
except OSError:
pass
current_app.logger.info( current_app.logger.info(
'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s', 'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s',
@@ -1028,7 +1031,7 @@ def delete(issue_id):
@login_required @login_required
def quick_assign(issue_id): def quick_assign(issue_id):
"""Inline assignee update from the issues list — returns JSON.""" """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 return jsonify({'ok': False, 'error': 'Permission denied'}), 403
issue = db.session.get(Issue, issue_id) issue = db.session.get(Issue, issue_id)
+38 -3
View File
@@ -12,6 +12,7 @@ from app import db
from app.models.inspection import Inspection, InspectionTemplate from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.models.issue import Issue from app.models.issue import Issue
from app.models.project import Project
from app.models.user import User from app.models.user import User
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope
@@ -141,9 +142,11 @@ def index():
) )
avg_score = _scope_insp(avg_score).scalar() 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( fac_score_q = db.session.query(
Facility.name, Facility.name,
Facility.project_id,
func.avg(Inspection.overall_score).label('avg_score'), func.avg(Inspection.overall_score).label('avg_score'),
func.count(Inspection.id).label('count'), func.count(Inspection.id).label('count'),
).join(Inspection, Facility.id == Inspection.facility_id)\ ).join(Inspection, Facility.id == Inspection.facility_id)\
@@ -159,9 +162,16 @@ def index():
fac_score_q = fac_score_q.filter( fac_score_q = fac_score_q.filter(
Facility.id.in_(customer_facility_ids) if customer_facility_ids else False 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() .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 # Prior-period facility scores for period-over-period delta badges
period_len = end - start period_len = end - start
prior_end = start prior_end = start
@@ -255,12 +265,24 @@ def index():
inspectors = User.query.filter_by(role='inspector', active=True)\ inspectors = User.query.filter_by(role='inspector', active=True)\
.order_by(User.full_name, User.username).all() .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 # Attach prior avg and delta to each facility score dict for the template table
for row in facility_scores_list: for row in facility_scores_list:
row['prior_avg'] = prior_scores_map.get(row['name']) row['prior_avg'] = prior_scores_map.get(row['name'])
row['delta'] = facility_deltas.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', return render_template('reports/index.html',
start=start, end=end, start=start, end=end,
total_inspections=total_inspections, total_inspections=total_inspections,
@@ -268,6 +290,7 @@ def index():
flagged=flagged, flagged=flagged,
avg_score=round(float(avg_score), 2) if avg_score else None, avg_score=round(float(avg_score), 2) if avg_score else None,
facility_scores=facility_scores_list, 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], 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_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], 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: if customer_facility_ids is not None:
facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True) facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True)
.order_by(Facility.name).all()) if customer_facility_ids else [] .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: else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() 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', return render_template('reports/issues_aging.html',
now=now, now=now,
@@ -1118,6 +1146,7 @@ def issues_aging():
severity_filter=severity_filter, severity_filter=severity_filter,
facility_id_filter=facility_id_filter, facility_id_filter=facility_id_filter,
facilities=facilities, facilities=facilities,
projects=projects,
) )
@@ -1282,8 +1311,13 @@ def sla_compliance():
if customer_facility_ids is not None: if customer_facility_ids is not None:
facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True) facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True)
.order_by(Facility.name).all()) if customer_facility_ids else [] .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: else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() 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', return render_template('reports/sla_compliance.html',
start=start, end=end, start=start, end=end,
@@ -1292,6 +1326,7 @@ def sla_compliance():
by_facility=by_facility, by_facility=by_facility,
facility_id_filter=facility_id_filter, facility_id_filter=facility_id_filter,
facilities=facilities, facilities=facilities,
projects=projects,
) )
+1 -1
View File
@@ -37,7 +37,7 @@
<td>{{ user.full_name or '—' }}</td> <td>{{ user.full_name or '—' }}</td>
<td>{{ user.email }}</td> <td>{{ user.email }}</td>
<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 }} {{ user.role.replace('_',' ')|title }}
</span> </span>
</td> </td>
+8 -3
View File
@@ -139,7 +139,7 @@
<li class="nav-item"> <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> <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> </li>
{% if current_user.role in ['admin', 'director', 'project_manager'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
</li> </li>
@@ -155,7 +155,7 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
</li> </li>
{% if current_user.role in ['admin', 'director', 'project_manager'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item"> <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> <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
</li> </li>
@@ -163,7 +163,7 @@
<li class="nav-item"> <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> <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> </li>
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director', 'auditor'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}" <a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('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 <i class="bi bi-chat-dots me-2"></i>Ask a Question
</a> </a>
</li> </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> <li>
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}"> <a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
<i class="bi bi-inbox me-2"></i>My Requests <i class="bi bi-inbox me-2"></i>My Requests
+49 -1
View File
@@ -5,12 +5,60 @@
<div class="row mb-3 align-items-center"> <div class="row mb-3 align-items-center">
<div class="col"> <div class="col">
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2> <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 }} {{ current_user.role.replace('_',' ')|title }}
</span> </span>
</div> </div>
</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 ─────────────────────────────────────────────────── #} {# ── Inspections section ─────────────────────────────────────────────────── #}
<div class="d-flex align-items-center gap-2 mb-3"> <div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-clipboard-data-fill text-primary"></i> <i class="bi bi-clipboard-data-fill text-primary"></i>
+81
View File
@@ -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>
+5 -4
View File
@@ -8,9 +8,10 @@
<h2><i class="bi bi-building"></i> Facilities</h2> <h2><i class="bi bi-building"></i> Facilities</h2>
</div> </div>
<div class="col-md-6 text-end"> <div class="col-md-6 text-end">
{% if current_user.role in ['admin', 'director', 'project_manager'] %} {% if current_user.role != 'inspector' %}
<a href="{{ url_for('facilities.qr_sheet') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('facilities.qr_print_all') }}"
<i class="bi bi-qr-code"></i> Print QR Codes 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> </a>
{% endif %} {% endif %}
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
@@ -43,7 +44,7 @@
{% endif %} {% endif %}
</button> </button>
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span> <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) }}" <a href="{{ url_for('projects.view', project_id=group.project.id) }}"
class="btn btn-sm btn-outline-secondary ms-2" class="btn btn-sm btn-outline-secondary ms-2"
title="View Contract"> title="View Contract">
+182
View File
@@ -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 &amp; 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 &amp; 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 %}
+12 -2
View File
@@ -11,17 +11,21 @@
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Facilities <i class="bi bi-arrow-left"></i> Back to Facilities
</a> </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) }}" <a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
class="btn btn-outline-info"> class="btn btn-outline-info">
<i class="bi bi-graph-up-arrow"></i> Scorecard <i class="bi bi-graph-up-arrow"></i> Scorecard
</a> </a>
{% endif %} {% 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) }}" <a href="{{ url_for('facilities.qr_card', facility_id=facility.id) }}"
class="btn btn-outline-secondary"> class="btn btn-outline-secondary">
<i class="bi bi-qr-code"></i> QR Code <i class="bi bi-qr-code"></i> QR Code
</a> </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 %} {% endif %}
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary"> <a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
@@ -135,6 +139,12 @@
</td> </td>
<td>{{ area.inspections.count() }}</td> <td>{{ area.inspections.count() }}</td>
<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'] %} {% 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"> <a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> <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>
+196
View File
@@ -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>
+3 -35
View File
@@ -177,41 +177,9 @@
</a> </a>
{% endif %} {% endif %}
{# ── Report a problem ── #} {% with action = url_for('facility_qr.report', token=token) %}
<div class="card shadow-sm mb-3"> {% include "facility_qr/_report_form.html" %}
<div class="card-header bg-white fw-semibold py-2"> {% endwith %}
<i class="bi bi-megaphone text-danger"></i> Report a Problem
</div>
<div class="card-body">
<p class="text-muted small mb-3">
See something that needs attention? Let our team know and we'll take care of it.
</p>
<form method="POST" action="{{ url_for('facility_qr.report', token=token) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Honeypot — invisible to humans, filled by bots #}
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">What did you observe? <span class="text-danger">*</span></label>
<textarea name="description" class="form-control form-control-sm" rows="3"
placeholder="Describe the issue (e.g. restroom out of paper towels, spill in lobby…)"
required maxlength="2000"></textarea>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Urgency</label>
<select name="severity" class="form-select form-select-sm">
<option value="low">Low — not urgent</option>
<option value="medium" selected>Medium — needs attention soon</option>
<option value="high">High — urgent</option>
</select>
</div>
<button type="submit" class="btn btn-danger btn-sm w-100">
<i class="bi bi-send me-1"></i> Submit Report
</button>
</form>
</div>
</div>
<p class="text-center text-muted small mt-2 mb-1"> <p class="text-center text-muted small mt-2 mb-1">
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %} {% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
+28 -1
View File
@@ -39,6 +39,29 @@
</div> </div>
</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="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">Facility</label> <label class="form-label">Facility</label>
@@ -80,8 +103,12 @@
<label class="form-check-label" for="activeSwitch">Active</label> <label class="form-check-label" for="activeSwitch">Active</label>
</div> </div>
<p class="text-muted small"> <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 '—' }} {{ 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> </p>
{% endif %} {% endif %}
</div> </div>
+33 -5
View File
@@ -3,15 +3,24 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2> <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"> <a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule <i class="bi bi-plus-circle"></i> New Schedule
</a> </a>
{% endif %}
</div> </div>
<p class="text-muted small mb-4"> <p class="text-muted small mb-4">
Recurring schedules automatically create an in-progress inspection for the {% if current_user.role == 'inspector' %}
assigned inspector each period. The inspector is notified and opens it from Inspections scheduled for you. <strong>Auto</strong> schedules appear in your
their Inspections list to complete it. 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> </p>
{% if schedules %} {% if schedules %}
@@ -22,8 +31,8 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Name</th><th>Template</th><th>Facility / Area</th> <th>Name</th><th>Template</th><th>Facility / Area</th>
<th>Inspector</th><th>Frequency</th><th>Next Run</th> <th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th>
<th>Last Run</th><th>Status</th><th width="150"></th> <th>Last Run</th><th>Status</th><th width="190"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -37,8 +46,18 @@
</td> </td>
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td> <td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td><span class="badge bg-secondary">{{ s.frequency|title }}</span></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' }}"> <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 '—' }} {{ 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>
<td class="small text-muted"> <td class="small text-muted">
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }} {{ 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 %} {% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
</td> </td>
<td class="text-end"> <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) }}" <a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit"> class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
@@ -70,6 +97,7 @@
<i class="bi bi-trash3"></i> <i class="bi bi-trash3"></i>
</button> </button>
</form> </form>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
+1 -1
View File
@@ -439,7 +439,7 @@
</label> </label>
{# Thumbnail shown after AJAX upload or when a saved path exists #} {# Thumbnail shown after AJAX upload or when a saved path exists #}
{% if saved %} {% if saved %}
<img src="{{ url_for('static', filename=saved) }}" <img src="{{ media_url(saved) }}"
id="thumb_{{ fid }}" id="thumb_{{ fid }}"
alt="Photo" alt="Photo"
style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;"> style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;">
+8 -3
View File
@@ -4,9 +4,14 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2> <h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
{% if current_user.role != 'customer' %} {% if current_user.role != 'customer' %}
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary"> <div class="d-flex gap-2">
<i class="bi bi-plus-circle"></i> New Inspection <a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">
</a> <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 %} {% endif %}
</div> </div>
+1 -1
View File
@@ -688,7 +688,7 @@
<div style="display:flex;align-items:center;flex:1;min-height:0;"> <div style="display:flex;align-items:center;flex:1;min-height:0;">
{% if val %} {% if val %}
<button type="button" class="btn-view-media" <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 <i class="bi bi-image"></i> View Photo
</button> </button>
{% else %} {% else %}
+4 -4
View File
@@ -3,7 +3,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2> <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"> <a href="{{ url_for('issues.create') }}" class="btn btn-danger">
<i class="bi bi-plus-circle"></i> Log Issue <i class="bi bi-plus-circle"></i> Log Issue
</a> </a>
@@ -176,7 +176,7 @@
{% else %}<span class="text-muted"></span>{% endif %} {% else %}<span class="text-muted"></span>{% endif %}
</td> </td>
<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 }}"> <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;"> <select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
<option value="">— Unassigned —</option> <option value="">— Unassigned —</option>
@@ -212,7 +212,7 @@
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" <a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="btn btn-sm btn-outline-secondary"> 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 <i class="bi bi-pencil"></i> Edit
{% else %} {% else %}
<i class="bi bi-eye"></i> View <i class="bi bi-eye"></i> View
@@ -294,7 +294,7 @@
}()); }());
</script> </script>
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director', 'auditor'] %}
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
+11 -11
View File
@@ -18,7 +18,7 @@
{% endblock %} {% endblock %}
{% block content %} {% 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"> <div class="row">
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #} {# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
@@ -127,14 +127,14 @@
<h6>Photo Evidence</h6> <h6>Photo Evidence</h6>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
{% if issue.photo_path %} {% if issue.photo_path %}
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank"> <a href="{{ media_url(issue.photo_path) }}" target="_blank">
<img src="{{ url_for('static', filename=issue.photo_path) }}" <img src="{{ media_url(issue.photo_path) }}"
class="img-fluid rounded" style="max-height:300px; max-width:100%;"> class="img-fluid rounded" style="max-height:300px; max-width:100%;">
</a> </a>
{% endif %} {% endif %}
{% for photo in (issue.mobile_photo_paths or []) %} {% for photo in (issue.mobile_photo_paths or []) %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank"> <a href="{{ media_url(photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}" <img src="{{ media_url(photo) }}"
class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;"> class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;">
</a> </a>
{% endfor %} {% endfor %}
@@ -150,8 +150,8 @@
{% if issue.result_photos %} {% if issue.result_photos %}
<div class="d-flex flex-wrap gap-2 mt-2"> <div class="d-flex flex-wrap gap-2 mt-2">
{% for photo in issue.result_photos %} {% for photo in issue.result_photos %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank"> <a href="{{ media_url(photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}" <img src="{{ media_url(photo) }}"
class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;" class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;"
alt="Result photo"> alt="Result photo">
</a> </a>
@@ -174,7 +174,7 @@
<div class="alert alert-info py-2 mb-0"> <div class="alert alert-info py-2 mb-0">
<i class="bi bi-hourglass-split me-1"></i> <i class="bi bi-hourglass-split me-1"></i>
<strong>Awaiting director verification.</strong> <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"> <form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2"> <div class="mb-2">
@@ -363,7 +363,7 @@
{{ form.status.label(class="form-label fw-semibold") }} {{ form.status.label(class="form-label fw-semibold") }}
{{ form.status(class="form-select") }} {{ form.status(class="form-select") }}
</div> </div>
{% if current_user.role in ['admin','director'] %} {% if current_user.role in ['admin','director','auditor'] %}
<div class="mb-3"> <div class="mb-3">
{{ form.assigned_to.label(class="form-label fw-semibold") }} {{ form.assigned_to.label(class="form-label fw-semibold") }}
{{ form.assigned_to(class="form-select") }} {{ form.assigned_to(class="form-select") }}
@@ -386,7 +386,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if current_user.role in ['admin','director','project_manager'] %} {% if current_user.role in ['admin','director','project_manager','auditor'] %}
<hr class="my-3"> <hr class="my-3">
<p class="fw-semibold small mb-2"> <p class="fw-semibold small mb-2">
<i class="bi bi-person-check me-1 text-secondary"></i>Handler / Ownership <i class="bi bi-person-check me-1 text-secondary"></i>Handler / Ownership
@@ -467,7 +467,7 @@
{% endif %} {% endif %}
{# ── Vendor Work Orders (phase36) ───────────────────────────────────── #} {# ── 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 shadow-sm mt-3">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-send me-1"></i>Contractor Work Orders</h6> <h6 class="mb-0"><i class="bi bi-send me-1"></i>Contractor Work Orders</h6>
+2 -2
View File
@@ -17,7 +17,7 @@
<i class="bi bi-shield-check me-1"></i>SLA Compliance <i class="bi bi-shield-check me-1"></i>SLA Compliance
</a> </a>
</li> </li>
{% if current_user.role in ['admin', 'director', 'project_manager'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}" <a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
href="{{ url_for('reports.followup_closure') }}"> href="{{ url_for('reports.followup_closure') }}">
@@ -33,7 +33,7 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager'] %} {% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}" <a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
href="{{ url_for('scheduled_reports.index') }}"> href="{{ url_for('scheduled_reports.index') }}">
+54 -8
View File
@@ -105,7 +105,17 @@
<div class="row mb-4"> <div class="row mb-4">
<div class="col-lg-8 mb-3"> <div class="col-lg-8 mb-3">
<div class="card shadow-sm h-100"> <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 class="card-body"><div class="chart-container"><canvas id="facilityChart"></canvas></div></div>
</div> </div>
</div> </div>
@@ -129,6 +139,7 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Facility</th> <th>Facility</th>
<th>Contract</th>
<th class="text-end">Current Period</th> <th class="text-end">Current Period</th>
<th class="text-end">Prior Period</th> <th class="text-end">Prior Period</th>
<th class="text-end">Change</th> <th class="text-end">Change</th>
@@ -137,8 +148,9 @@
</thead> </thead>
<tbody> <tbody>
{% for row in facility_scores %} {% 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="fw-semibold">{{ row.name }}</td>
<td class="text-muted small">{{ row.contract }}</td>
<td class="text-end"> <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' }}"> <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) }}% {{ '%.1f'|format(row.avg_score|float) }}%
@@ -266,16 +278,31 @@ new Chart(document.getElementById('trendChart'), {
} }
}); });
// ── Facility bar chart ──────────────────────────────────────────────────────── // ── Facility bar chart (filterable by contract) ───────────────────────────────
new Chart(document.getElementById('facilityChart'), { 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', type: 'bar',
data: { data: {
labels: {{ facility_scores | map(attribute='name') | list | tojson }}, labels: labels,
datasets: [{ datasets: [{
label: 'Avg Score (%)', label: 'Avg Score (%)',
data: {{ facility_scores | map(attribute='avg_score') | list | tojson }}, data: data,
backgroundColor: {{ facility_scores | map(attribute='avg_score') | list | tojson }} backgroundColor: _facColors(data),
.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED),
borderRadius: 4, borderRadius: 4,
}] }]
}, },
@@ -285,6 +312,25 @@ new Chart(document.getElementById('facilityChart'), {
plugins: { legend: { display: false } } 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 ───────────────────────────────────────────────────────── // ── Severity doughnut ─────────────────────────────────────────────────────────
const sevData = {{ issue_severity | tojson }}; const sevData = {{ issue_severity | tojson }};
+43 -2
View File
@@ -29,9 +29,20 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </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> <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> <option value="">All Facilities</option>
{% for f in facilities %} {% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option> <option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
@@ -46,6 +57,36 @@
</div> </div>
</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 ── #} {# ── KPI row ── #}
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-6 col-md-4"> <div class="col-6 col-md-4">
+45 -4
View File
@@ -25,17 +25,28 @@
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
<div class="card-body py-2"> <div class="card-body py-2">
<form method="get" class="row g-2 align-items-end"> <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> <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') }}"> <input type="date" name="start" class="form-control form-control-sm" value="{{ start.strftime('%Y-%m-%d') }}">
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<label class="form-label small mb-1">To</label> <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') }}"> <input type="date" name="end" class="form-control form-control-sm" value="{{ end.strftime('%Y-%m-%d') }}">
</div> </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> <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> <option value="">All Facilities</option>
{% for f in facilities %} {% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option> <option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
@@ -50,6 +61,36 @@
</div> </div>
</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 %} {% if total == 0 %}
<div class="alert alert-info"> <div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>No resolved issues found for this period and filter. <i class="bi bi-info-circle me-2"></i>No resolved issues found for this period and filter.
+1 -1
View File
@@ -2,7 +2,7 @@
{% block title %}Support Knowledge Base{% endblock %} {% block title %}Support Knowledge Base{% endblock %}
{% block content %} {% 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> <div>
<h4 class="mb-0"><i class="bi bi-book me-2 text-primary"></i>Support Knowledge Base</h4> <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> <small class="text-muted">Active entries are injected into the AI chatbot system prompt</small>
+1 -1
View File
@@ -39,7 +39,7 @@
<div class="col-lg-8"> <div class="col-lg-8">
{# ── Header ── #} {# ── 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> <div>
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4> <h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
<small class="text-muted"> <small class="text-muted">
+24 -2
View File
@@ -54,17 +54,39 @@ def supervisor_required(f):
return decorated_function return decorated_function
def project_manager_required(f): 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) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role not in [ 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') flash('Project Manager access required.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function 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): def customer_required(f):
"""Restricts access to customer-role users only. """Restricts access to customer-role users only.
+32 -1
View File
@@ -92,6 +92,7 @@ class UserForm(FlaskForm):
('director', 'Director'), ('director', 'Director'),
('inspector', 'Inspector'), ('inspector', 'Inspector'),
('project_manager', 'Project Manager'), ('project_manager', 'Project Manager'),
('auditor', 'Auditor'),
# 'customer' is intentionally excluded — customer accounts are managed via /customers # 'customer' is intentionally excluded — customer accounts are managed via /customers
], validators=[Optional()]) ], validators=[Optional()])
# NOTE: Optional() here because directors submit no role value (the field is # NOTE: Optional() here because directors submit no role value (the field is
@@ -318,4 +319,34 @@ class SetPasswordForm(FlaskForm):
def validate_username(self, field): def validate_username(self, field):
existing = User.query.filter_by(username=field.data.strip()).first() existing = User.query.filter_by(username=field.data.strip()).first()
if existing: if existing:
raise ValidationError('This username is already taken. Please choose another.') 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
+154
View File
@@ -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 hostname 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)
+1
View File
@@ -546,6 +546,7 @@ def notify_by_matrix(
'director': 'director', 'director': 'director',
'inspector': 'inspector', 'inspector': 'inspector',
'project_manager': 'project_manager', 'project_manager': 'project_manager',
'auditor': 'auditor',
'customer': 'customer', 'customer': 'customer',
} }
+140 -5
View File
@@ -657,6 +657,27 @@ def _notes_section(inspection):
# ── Public entry point ──────────────────────────────────────────────────────── # ── 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, def generate_inspection_pdf(inspection, form_fields, form_data, issues,
static_folder) -> bytes: 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') generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
report_title = f'Inspection Report — {inspection.template.name}' 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( doc = SimpleDocTemplate(
buf, buf,
pagesize=letter, pagesize=letter,
@@ -723,8 +752,11 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues,
])) ]))
story.append(sig_tbl) story.append(sig_tbl)
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) try:
return buf.getvalue() 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') generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
report_title = f'Issue Report — Issue #{issue.id}' 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( doc = SimpleDocTemplate(
buf, buf,
pagesize=letter, pagesize=letter,
@@ -936,8 +976,11 @@ def generate_issue_pdf(issue, static_folder: str) -> bytes:
])) ]))
story.append(v_tbl) story.append(v_tbl)
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) try:
return buf.getvalue() doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()
finally:
_cleanup_media()
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@@ -1546,4 +1589,96 @@ def generate_facility_summary_pdf(facility, days, start, now,
)) ))
doc.build(story) doc.build(story)
return buf.getvalue() return buf.getvalue()
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()
+371
View File
@@ -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)
+18
View File
@@ -62,6 +62,24 @@ class Config:
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} 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 ─────────────────────────────────────────────────── # ── Session / cookies ───────────────────────────────────────────────────
PERMANENT_SESSION_LIFETIME = timedelta(hours=24) PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
# Secure by default — subclasses must explicitly opt out for local dev. # 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}"
))
+7 -1
View File
@@ -67,7 +67,13 @@ def test_cron_materialises_due_schedule_and_notifies(client):
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'}) resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
assert resp.status_code == 200 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. # A real in_progress inspection now exists for the assigned inspector.
insp = Inspection.query.filter_by(inspector_id=insp_user_id).first() insp = Inspection.query.filter_by(inspector_id=insp_user_id).first()