Jul 16 - Update issue handler Janitorial staff - add input staff name

This commit is contained in:
2026-07-16 15:39:26 -04:00
parent 5a42b21a04
commit 66dbf31c34
8 changed files with 112 additions and 7 deletions
+21 -6
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 (Phase 19 complete + mobile API gap-fill Phases AE + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 3032 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role) > **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases AE + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 3032 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role + Phase 41 issue internal handler name)
--- ---
@@ -255,20 +255,23 @@ issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity
handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal', ← Phase 35 handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal', ← Phase 35
facility_handler_name VARCHAR(100) nullable, ← Phase 35 facility_handler_name VARCHAR(100) nullable, ← Phase 35
facility_handler_contact VARCHAR(200) nullable, ← Phase 35 facility_handler_contact VARCHAR(200) nullable, ← Phase 35
facility_handler_notes TEXT nullable ← Phase 35 facility_handler_notes TEXT nullable, ← Phase 35
internal_handler_name VARCHAR(100) nullable ← Phase 41
``` ```
**Handler (`handler_type`, Phase 35) — who is doing the work:** **Handler (`handler_type`, Phase 35) — who is doing the work:**
| Value | Meaning | Detail fields | `assigned_to` role | | Value | Meaning | Detail fields | `assigned_to` role |
|---|---|---|---| |---|---|---|---|
| `internal` (default) | Janitorial Staff (our crew) | — (the assignee IS the handler) | the handler | | `internal` (default) | Janitorial Staff (our crew) | `internal_handler_name` (Phase 41, free text — the crew member's name, optional) | the handler |
| `facility` | The facility's own staff | `facility_handler_name/contact/notes` (free text) | internal **follow-up owner** | | `facility` | The facility's own staff | `facility_handler_name/contact/notes` (free text) | internal **follow-up owner** |
| `vendor` | External contractor | `vendor_name/contact/notes` (Phase 26) | internal **follow-up owner** | | `vendor` | External contractor | `vendor_name/contact/notes` (Phase 26) | internal **follow-up owner** |
**Display labels are perspective-neutral** (they read the same for staff and customers) with a descriptor line under the selector and a tooltip on badges: `internal`**"Janitorial Staff"** ("Our janitorial crew handles it."), `facility`**"Facility Staff"** ("The facility's own on-site staff handle it."), `vendor`**"External Vendor"** ("An outside contractor handles it."). Labels/descriptions live in `Issue.HANDLER_LABELS` / `HANDLER_DESCRIPTIONS`, the WTForms `handler_type` choices, and the `HANDLER_DESC` JS map in both issue templates — keep these in sync. Do **not** use viewer-relative words like "Our"/"Your" for the stored categories. **Display labels are perspective-neutral** (they read the same for staff and customers) with a descriptor line under the selector and a tooltip on badges: `internal`**"Janitorial Staff"** ("Our janitorial crew handles it."), `facility`**"Facility Staff"** ("The facility's own on-site staff handle it."), `vendor`**"External Vendor"** ("An outside contractor handles it."). Labels/descriptions live in `Issue.HANDLER_LABELS` / `HANDLER_DESCRIPTIONS`, the WTForms `handler_type` choices, and the `HANDLER_DESC` JS map in both issue templates — keep these in sync. Do **not** use viewer-relative words like "Our"/"Your" for the stored categories.
`assigned_to` (a JQC User) is **always** available: it is the handler for `internal`, and the internal follow-up owner (e.g. the inspector who verifies/updates) for `facility`/`vendor`. Settable in **two places**, both with a "Handled By" selector that reveals the facility or vendor sub-fields via JS: A free-text **`internal_handler_name`** (Phase 41) captures the janitorial crew member's name when `handler_type == 'internal'` — the actual person doing the work, who may not be a system User. It is distinct from `assigned_to` (the follow-up owner) and is revealed by the same "Handled By" selector JS as the facility/vendor blocks (`#internal_handler_block`). Displayed under a **"Staff"** row on the issue detail when set.
`assigned_to` (a JQC User) is **always** available: it is the handler for `internal`, and the internal follow-up owner (e.g. the inspector who verifies/updates) for `facility`/`vendor`. Settable in **two places**, both with a "Handled By" selector that reveals the janitorial/facility/vendor sub-fields via JS:
- **Log New Issue** form (`issues/form.html`) — at creation, for non-customer staff. Customer-created issues stay `internal` (the handler UI is hidden for them, same as `assigned_to`). - **Log New Issue** form (`issues/form.html`) — at creation, for non-customer staff. Customer-created issues stay `internal` (the handler UI is hidden for them, same as `assigned_to`).
- **Update Issue** panel on the issue detail page (`issues/view.html`) — triage after creation. - **Update Issue** panel on the issue detail page (`issues/view.html`) — triage after creation.
@@ -646,6 +649,7 @@ No migration was needed for either feature: the `scheduled_inspections` table (p
'handler_label', # human-readable label (Issue.handler_label property) 'handler_label', # human-readable label (Issue.handler_label property)
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', # nullable 'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', # nullable
'vendor_name', 'vendor_contact', 'vendor_notes', # nullable 'vendor_name', 'vendor_contact', 'vendor_notes', # nullable
'internal_handler_name', # Phase 41 — janitorial staff name (nullable)
} }
``` ```
@@ -813,7 +817,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase37_support_chat → phase37_support_chat
→ phase38_support_knowledge → phase38_support_knowledge
→ phase39_area_public_token → phase39_area_public_token
→ phase40_auditor_role ← HEAD → phase40_auditor_role
→ phase41_internal_handler ← HEAD
``` ```
### phase21_performance_indexes ### phase21_performance_indexes
@@ -962,6 +967,16 @@ flask db upgrade # adds + backfills areas.public_token
sudo systemctl restart gunicorn sudo systemctl restart gunicorn
``` ```
### phase41_internal_handler
Revision id `phase41_internal_handler` (file `phase41_internal_handler_name.py`). Adds `issues.internal_handler_name VARCHAR(100) NULL` — the free-text janitorial staff member's name used when `handler_type == 'internal'` (see §5 Issue + the Handler section). `INFORMATION_SCHEMA` column-existence check — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
### phase40_auditor_role ### phase40_auditor_role
Revision id `phase40_auditor_role`. Adds the `auditor` value to the `users.role` ENUM (`ALTER TABLE users MODIFY COLUMN role ENUM(...,'auditor') NOT NULL`). This is a **pure ENUM expansion** (adds a value, removes/migrates nothing), so the 3-step ENUM protocol does not apply and the `MODIFY` is idempotent — safe to re-run. `downgrade()` reassigns any `auditor` rows to `project_manager` before contracting the ENUM. Backs the new Auditor role — see the `auditor` note in §5 and the `@issue_manager_required` decorator in §6. Revision id `phase40_auditor_role`. Adds the `auditor` value to the `users.role` ENUM (`ALTER TABLE users MODIFY COLUMN role ENUM(...,'auditor') NOT NULL`). This is a **pure ENUM expansion** (adds a value, removes/migrates nothing), so the 3-step ENUM protocol does not apply and the `MODIFY` is idempotent — safe to re-run. `downgrade()` reassigns any `auditor` rows to `project_manager` before contracting the ENUM. Backs the new Auditor role — see the `auditor` note in §5 and the `@issue_manager_required` decorator in §6.
@@ -1316,7 +1331,7 @@ timeout = 30
| 75 | **Email is stored lowercased; look it up case-insensitively** | User/customer email is normalized to `.strip().lower()` at every write site (`auth.py` profile/create/edit, `customers.py` invite/edit). Forgot-password lookup uses `db.func.lower(User.email) == input` so a mixed-case legacy row still matches — a plain `filter_by(email=...)` silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. | | 75 | **Email is stored lowercased; look it up case-insensitively** | User/customer email is normalized to `.strip().lower()` at every write site (`auth.py` profile/create/edit, `customers.py` invite/edit). Forgot-password lookup uses `db.func.lower(User.email) == input` so a mixed-case legacy row still matches — a plain `filter_by(email=...)` silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. |
| 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@<subdomain>` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. | | 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@<subdomain>` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. |
| 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). | | 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). |
| 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all 8 handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. | | 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`, `internal_handler_name`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. |
| 79 | **`auditor` = `project_manager` access + issue management, minus delete — keep the two decorators distinct** | Auditor is added to `@project_manager_required` (PM baseline) and to every `project_manager` role check in routes/templates. Its *extra* issue powers (verify/bulk-verify/verification-queue) go through the separate `@issue_manager_required` (admin/director/auditor). Issue **delete** stays `@supervisor_required` — never add auditor there. When adding a new PM-level gate, include `auditor`; when adding a director-only or delete-level gate, do not. The three issue **delete** template gates (spaced `['admin', 'director']` in `issues/list.html` + `issues/view.html`) are deliberately left without auditor. Auditor is also in the `_ALLOWED_ROLES` set of every `app/api/*` module — a **new** API blueprint's `_ALLOWED_ROLES` must include `auditor` for PM parity. | | 79 | **`auditor` = `project_manager` access + issue management, minus delete — keep the two decorators distinct** | Auditor is added to `@project_manager_required` (PM baseline) and to every `project_manager` role check in routes/templates. Its *extra* issue powers (verify/bulk-verify/verification-queue) go through the separate `@issue_manager_required` (admin/director/auditor). Issue **delete** stays `@supervisor_required` — never add auditor there. When adding a new PM-level gate, include `auditor`; when adding a director-only or delete-level gate, do not. The three issue **delete** template gates (spaced `['admin', 'director']` in `issues/list.html` + `issues/view.html`) are deliberately left without auditor. Auditor is also in the `_ALLOWED_ROLES` set of every `app/api/*` module — a **new** API blueprint's `_ALLOWED_ROLES` must include `auditor` for PM parity. |
| 80 | **Assignee dropdowns are `director`/`inspector`/`auditor` (admin removed, auditor added)** | The issue/inspection assignee `<select>`s query `User.role.in_([...])` — admin was removed and auditor added (the inspection flag-issue list also keeps `project_manager`). These lists control who can be *assigned*, distinct from who can *edit*. The issue-update route (`issues.view`) defensively appends any current `assigned_to` who is not in the set (e.g. a legacy admin assignment) to `form.assigned_to.choices` so saving the form never silently unassigns them. Do not remove that guard. | | 80 | **Assignee dropdowns are `director`/`inspector`/`auditor` (admin removed, auditor added)** | The issue/inspection assignee `<select>`s query `User.role.in_([...])` — admin was removed and auditor added (the inspection flag-issue list also keeps `project_manager`). These lists control who can be *assigned*, distinct from who can *edit*. The issue-update route (`issues.view`) defensively appends any current `assigned_to` who is not in the set (e.g. a legacy admin assignment) to `form.assigned_to.choices` so saving the form never silently unassigns them. Do not remove that guard. |
+4 -1
View File
@@ -109,6 +109,7 @@ def _issue_payload(issue):
'vendor_name': issue.vendor_name or None, 'vendor_name': issue.vendor_name or None,
'vendor_contact': issue.vendor_contact or None, 'vendor_contact': issue.vendor_contact or None,
'vendor_notes': issue.vendor_notes or None, 'vendor_notes': issue.vendor_notes or None,
'internal_handler_name': issue.internal_handler_name or None,
} }
@@ -423,7 +424,8 @@ def update_issue_handler(issue_id):
"facility_handler_notes": "...", // optional "facility_handler_notes": "...", // optional
"vendor_name": "...", // optional (vendor handler) "vendor_name": "...", // optional (vendor handler)
"vendor_contact": "...", // optional "vendor_contact": "...", // optional
"vendor_notes": "..." // optional "vendor_notes": "...", // optional
"internal_handler_name": "..." // optional (janitorial staff name)
} }
Only keys present in the body are updated; empty strings clear a field. Only keys present in the body are updated; empty strings clear a field.
@@ -463,6 +465,7 @@ def update_issue_handler(issue_id):
_text_fields = ( _text_fields = (
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', 'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
'vendor_name', 'vendor_contact', 'vendor_notes', 'vendor_name', 'vendor_contact', 'vendor_notes',
'internal_handler_name',
) )
for field in _text_fields: for field in _text_fields:
if field in data: if field in data:
+4
View File
@@ -95,6 +95,10 @@ class Issue(db.Model):
facility_handler_name = db.Column(db.String(100), nullable=True) facility_handler_name = db.Column(db.String(100), nullable=True)
facility_handler_contact = db.Column(db.String(200), nullable=True) # phone or email facility_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
facility_handler_notes = db.Column(db.Text, nullable=True) facility_handler_notes = db.Column(db.Text, nullable=True)
# Free-text name of the janitorial staff member who will handle the issue,
# used when handler_type == 'internal'. Distinct from assigned_to (the JQC
# User who owns follow-up): the actual crew member may not be a system user.
internal_handler_name = db.Column(db.String(100), nullable=True)
# Relationships # Relationships
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py). # NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
+3
View File
@@ -469,6 +469,8 @@ def view(issue_id):
issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None
issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
from app.routes.inspections import _save_photo from app.routes.inspections import _save_photo
new_photos = [] new_photos = []
for file_obj in request.files.getlist('result_photos'): for file_obj in request.files.getlist('result_photos'):
@@ -759,6 +761,7 @@ def create():
issue.vendor_name = (form.vendor_name.data or '').strip() or None issue.vendor_name = (form.vendor_name.data or '').strip() or None
issue.vendor_contact = (form.vendor_contact.data or '').strip() or None issue.vendor_contact = (form.vendor_contact.data or '').strip() or None
issue.vendor_notes = (form.vendor_notes.data or '').strip() or None issue.vendor_notes = (form.vendor_notes.data or '').strip() or None
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
db.session.add(issue) db.session.add(issue)
db.session.commit() db.session.commit()
+12
View File
@@ -52,6 +52,16 @@
{% for e in form.assigned_to.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %} {% for e in form.assigned_to.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div> </div>
{# Janitorial-staff handler — shown when Handled By = Janitorial Staff #}
<div id="internal_handler_block" style="display:none;">
<div class="mb-3">
{{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }}
{{ form.internal_handler_name(class="form-control form-control-sm",
placeholder="Name of the crew member who will handle it") }}
<div class="form-text">Optional — the janitorial staff member doing the work.</div>
</div>
</div>
{# Facility-staff handler — shown when Handled By = Facility Staff #} {# Facility-staff handler — shown when Handled By = Facility Staff #}
<div id="facility_handler_block" style="display:none;"> <div id="facility_handler_block" style="display:none;">
<div class="mb-2"> <div class="mb-2">
@@ -166,8 +176,10 @@
function syncHandlerUI() { function syncHandlerUI() {
if (!handlerSelect) { return; } if (!handlerSelect) { return; }
var v = handlerSelect.value; var v = handlerSelect.value;
var intBlock = document.getElementById('internal_handler_block');
var facBlock = document.getElementById('facility_handler_block'); var facBlock = document.getElementById('facility_handler_block');
var venBlock = document.getElementById('vendor_block'); var venBlock = document.getElementById('vendor_block');
if (intBlock) { intBlock.style.display = (v === 'internal') ? '' : 'none'; }
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; } if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; } if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
var desc = document.getElementById('handler_desc'); var desc = document.getElementById('handler_desc');
+24
View File
@@ -89,6 +89,14 @@
</dt> </dt>
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd> <dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
{% if (issue.handler_type or 'internal') == 'internal' and issue.internal_handler_name %}
<dt class="col-sm-3">Staff</dt>
<dd class="col-sm-9">
<i class="bi bi-people text-secondary me-1"></i>
<strong>{{ issue.internal_handler_name }}</strong>
</dd>
{% endif %}
{% if issue.handler_type == 'facility' and issue.facility_handler_name %} {% if issue.handler_type == 'facility' and issue.facility_handler_name %}
<dt class="col-sm-3">Facility Contact</dt> <dt class="col-sm-3">Facility Contact</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
@@ -394,6 +402,20 @@
{% endif %} {% endif %}
{% if current_user.role in ['admin','director','project_manager','auditor'] %} {% if current_user.role in ['admin','director','project_manager','auditor'] %}
{# ── Janitorial-staff handler (shown when Handled By = Janitorial Staff) ── #}
<div id="internal_handler_block" style="display:none;">
<hr class="my-3">
<p class="fw-semibold small mb-2">
<i class="bi bi-people me-1 text-secondary"></i>Janitorial Staff
</p>
<div class="mb-3">
{{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }}
{{ form.internal_handler_name(class="form-control form-control-sm",
placeholder="Name of the crew member who will handle it",
value=issue.internal_handler_name or '') }}
</div>
</div>
{# ── Facility-staff handler (shown when Handled By = Facility Staff) ── #} {# ── Facility-staff handler (shown when Handled By = Facility Staff) ── #}
<div id="facility_handler_block" style="display:none;"> <div id="facility_handler_block" style="display:none;">
<hr class="my-3"> <hr class="my-3">
@@ -564,8 +586,10 @@
function syncHandlerUI() { function syncHandlerUI() {
if (!handlerSelect) { return; } if (!handlerSelect) { return; }
var v = handlerSelect.value; var v = handlerSelect.value;
var intBlock = document.getElementById('internal_handler_block');
var facBlock = document.getElementById('facility_handler_block'); var facBlock = document.getElementById('facility_handler_block');
var venBlock = document.getElementById('vendor_block'); var venBlock = document.getElementById('vendor_block');
if (intBlock) { intBlock.style.display = (v === 'internal') ? '' : 'none'; }
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; } if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; } if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
+4
View File
@@ -184,6 +184,8 @@ class IssueForm(FlaskForm):
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)]) vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)]) vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)]) vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
# Janitorial staff member's name — used when handler_type == 'internal'
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
class IssueUpdateForm(FlaskForm): class IssueUpdateForm(FlaskForm):
@@ -212,6 +214,8 @@ class IssueUpdateForm(FlaskForm):
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)]) vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)]) vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)]) vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
# Janitorial staff member's name — used when handler_type == 'internal'
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
# ── Projects ───────────────────────────────────────────────────────────────── # ── Projects ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,40 @@
"""phase41 — issue internal_handler_name
Adds a free-text `internal_handler_name` to `issues`, capturing the name of the
janitorial staff member who will handle an issue when handler_type == 'internal'.
Distinct from `assigned_to` (the JQC User who owns follow-up) the actual crew
member may not be a system user.
Uses an INFORMATION_SCHEMA column-existence check safe to re-run.
"""
revision = 'phase41_internal_handler'
down_revision = 'phase40_auditor_role'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _column_exists(conn, table, column):
result = 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})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _column_exists(bind, 'issues', 'internal_handler_name'):
op.execute(sa.text(
"ALTER TABLE issues ADD COLUMN internal_handler_name VARCHAR(100) NULL"
))
def downgrade():
bind = op.get_bind()
if _column_exists(bind, 'issues', 'internal_handler_name'):
op.execute(sa.text("ALTER TABLE issues DROP COLUMN internal_handler_name"))