From ceb0b806af91d9e28d26e81807abb84c05787719 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 30 Jul 2026 12:14:04 -0400 Subject: [PATCH] Jul 30 - Internal handler files --- app/api/issues.py | 9 +- app/models/issue.py | 31 +++-- app/routes/issues.py | 44 ++++++- app/templates/issues/form.html | 87 ++++++++++++++ app/templates/issues/view.html | 38 +++++- app/utils/forms.py | 24 +++- .../versions/phase44_internal_handler.py | 110 ++++++++++++++++++ 7 files changed, 323 insertions(+), 20 deletions(-) create mode 100644 migrations/versions/phase44_internal_handler.py diff --git a/app/api/issues.py b/app/api/issues.py index 37e8702..fac1d77 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -110,6 +110,10 @@ def _issue_payload(issue): 'vendor_name': issue.vendor_name or None, 'vendor_contact': issue.vendor_contact or None, 'vendor_notes': issue.vendor_notes or None, + # Janitorial staff handler — used when handler_type == 'internal'. + # Distinct from assigned_to: the crew member may not be a system user. + 'internal_handler_name': issue.internal_handler_name or None, + 'internal_handler_contact': issue.internal_handler_contact or None, } @@ -550,7 +554,9 @@ def update_issue_handler(issue_id): "facility_handler_notes": "...", // optional "vendor_name": "...", // optional (vendor handler) "vendor_contact": "...", // optional - "vendor_notes": "..." // optional + "vendor_notes": "...", // optional + "internal_handler_name": "...", // optional (janitorial staff handler) + "internal_handler_contact": "..." // optional } Only keys present in the body are updated; empty strings clear a field. @@ -591,6 +597,7 @@ def update_issue_handler(issue_id): _text_fields = ( 'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', 'vendor_name', 'vendor_contact', 'vendor_notes', + 'internal_handler_name', 'internal_handler_contact', ) for field in _text_fields: if field in data: diff --git a/app/models/issue.py b/app/models/issue.py index 186e26b..bb89692 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -85,18 +85,24 @@ class Issue(db.Model): vendor_notes = db.Column(db.Text, nullable=True) # Handler type — who is responsible for resolving the issue (phase39). - # NULL and 'internal' both mean janitorial staff (the default); 'facility' - # unlocks the facility_handler_* sub-fields; 'vendor' points to vendor_*. - handler_type = db.Column(db.Enum('internal', 'facility', 'vendor'), nullable=True) + # 'internal' means janitorial staff (the default); 'facility' unlocks the + # facility_handler_* sub-fields; 'vendor' points to vendor_*. + # NOT NULL DEFAULT 'internal' since phase44 — previously nullable, with NULL + # treated as a synonym for 'internal'. Existing NULLs were backfilled by that + # migration, so the two representations are now one. + handler_type = db.Column( + db.Enum('internal', 'facility', 'vendor'), + nullable=False, default='internal', + ) facility_handler_name = db.Column(db.String(100), nullable=True) facility_handler_contact = db.Column(db.String(200), nullable=True) facility_handler_notes = db.Column(db.Text, nullable=True) - - HANDLER_LABELS = { - 'internal': 'Janitorial Staff', - 'facility': 'Facility Staff', - 'vendor': 'External Vendor', - } + # 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. + # (phase44) + internal_handler_name = db.Column(db.String(100), nullable=True) + internal_handler_contact = db.Column(db.String(200), nullable=True) # phone or email # Relationships # NOTE: Issue.area is provided by the backref on Area.issues (facility.py). @@ -124,6 +130,13 @@ class Issue(db.Model): 'facility': 'Facility Staff', 'vendor': 'External Vendor', } + # One-line explanation per handler type, shown under the radio options on the + # issue form so staff pick the right one. (phase44) + HANDLER_DESCRIPTIONS = { + 'internal': 'Our janitorial crew handles it.', + 'facility': "The facility's own on-site staff handle it.", + 'vendor': 'An outside contractor handles it.', + } @property def handler_label(self): diff --git a/app/routes/issues.py b/app/routes/issues.py index 408ecc3..0465a5e 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -462,8 +462,19 @@ def view(issue_id): issue.vendor_contact = form.vendor_contact.data.strip() or None issue.vendor_notes = form.vendor_notes.data.strip() or None - # Handler type (phase39) - ht = form.handler_type.data or None + # Handler type (phase39; NOT NULL since phase44) + # Coerce empty/unknown to 'internal' explicitly. This is NOT + # preventing a crash: handler_type carries a Python-side + # default='internal', and SQLAlchemy applies a column default when + # the attribute is None — so the previous `or None` would have been + # silently rescued to 'internal' rather than raising. The point is to + # not depend on that fairly obscure behaviour, and to state the + # intended value at the point of assignment. The membership check + # also backstops a crafted POST, though SelectField.pre_validate + # already rejects out-of-choice values. + ht = form.handler_type.data or 'internal' + if ht not in ('internal', 'facility', 'vendor'): + ht = 'internal' issue.handler_type = ht if ht == 'facility': issue.facility_handler_name = form.facility_handler_name.data.strip() or None @@ -474,6 +485,13 @@ def view(issue_id): issue.facility_handler_contact = None issue.facility_handler_notes = None + # Janitorial staff handler (phase44). Written unconditionally, the + # same way vendor_* above is: the work-order dispatch route also + # writes vendor_name, so clearing non-active handler fields here + # would discard data set elsewhere. + issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None + issue.internal_handler_contact = (form.internal_handler_contact.data or '').strip() or None + from app.routes.inspections import _save_photo new_photos = [] for file_obj in request.files.getlist('result_photos'): @@ -752,6 +770,25 @@ def create(): reported_at = now_eastern(), reported_by = current_user.id, ) + + # "Handled By" — staff only; customer-created issues stay internal. + # (phase44) Previously the create form carried no handler fields at all, + # so a handler chosen here was silently discarded and had to be re-entered + # on the update form. + if current_user.role != 'customer': + handler = form.handler_type.data or 'internal' + if handler not in ('internal', 'facility', 'vendor'): + handler = 'internal' + issue.handler_type = handler + issue.facility_handler_name = (form.facility_handler_name.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.vendor_name = (form.vendor_name.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.internal_handler_name = (form.internal_handler_name.data or '').strip() or None + issue.internal_handler_contact = (form.internal_handler_contact.data or '').strip() or None + db.session.add(issue) db.session.commit() current_app.logger.info( @@ -804,7 +841,8 @@ def create(): return redirect(url_for('issues.index')) return render_template('issues/form.html', form=form, title='Log New Issue', - projects=projects, selected_project_id=selected_project_id) + projects=projects, selected_project_id=selected_project_id, + issue_handler_descriptions=Issue.HANDLER_DESCRIPTIONS) # ── Supervisor verify resolved issue ───────────────────────────────────────── diff --git a/app/templates/issues/form.html b/app/templates/issues/form.html index bd5cab3..3e3a5ca 100644 --- a/app/templates/issues/form.html +++ b/app/templates/issues/form.html @@ -46,6 +46,93 @@ {% endif %} + {# ── Handled By (phase44) — staff only; customers stay internal ── #} + {% if current_user.role != 'customer' %} +
+

+ Handled By +

+
+ {{ form.handler_type(class="form-select", id="handlerTypeSelect") }} +
+
+ +
+
+ {{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }} + {{ form.internal_handler_name(class="form-control form-control-sm", + placeholder="Crew member handling this") }} +
+
+ {{ form.internal_handler_contact.label(class="form-label small fw-semibold mb-1") }} + {{ form.internal_handler_contact(class="form-control form-control-sm", + placeholder="Phone or email") }} +
+
+ + + + + + + {% endif %} +
Cancel diff --git a/app/templates/issues/view.html b/app/templates/issues/view.html index 4065f9b..c5117de 100644 --- a/app/templates/issues/view.html +++ b/app/templates/issues/view.html @@ -76,10 +76,11 @@
Assigned To
{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}
- {% if issue.handler_type and issue.handler_type != 'internal' %} + {% set _ht = issue.handler_type or 'internal' %} + {% if _ht != 'internal' or issue.internal_handler_name or issue.internal_handler_contact %}
Handled By
- {% if issue.handler_type == 'facility' %} + {% if _ht == 'facility' %} Facility Staff @@ -92,8 +93,18 @@ {% if issue.facility_handler_notes %}
{{ issue.facility_handler_notes }}
{% endif %} - {% elif issue.handler_type == 'vendor' %} + {% elif _ht == 'vendor' %} External Vendor + {% else %} + + Janitorial Staff + + {% if issue.internal_handler_name %} + {{ issue.internal_handler_name }} + {% endif %} + {% if issue.internal_handler_contact %} + {{ issue.internal_handler_contact }} + {% endif %} {% endif %}
{% endif %} @@ -416,13 +427,30 @@ placeholder="Notes about what they are handling…") }}
+
+
+ {{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }} + {{ form.internal_handler_name(class="form-control form-control-sm", + placeholder="Crew member handling this", + value=issue.internal_handler_name or '') }} +
+
+ {{ form.internal_handler_contact.label(class="form-label small fw-semibold mb-1") }} + {{ form.internal_handler_contact(class="form-control form-control-sm", + placeholder="Phone or email", + value=issue.internal_handler_contact or '') }} +
+