Jul 8 - Implement issue assignment separation

This commit is contained in:
2026-07-08 13:56:25 -04:00
parent 39feaa4704
commit b06d939ac0
7 changed files with 287 additions and 35 deletions
+27 -2
View File
@@ -245,9 +245,23 @@ issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity
mobile_local_id VARCHAR(64) nullable indexed, ← Phase B
vendor_name VARCHAR(100) nullable, ← Phase 26
vendor_contact VARCHAR(200) nullable, ← Phase 26
vendor_notes TEXT nullable ← Phase 26
vendor_notes TEXT nullable, ← Phase 26
handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal', ← Phase 35
facility_handler_name VARCHAR(100) nullable, ← Phase 35
facility_handler_contact VARCHAR(200) nullable, ← Phase 35
facility_handler_notes TEXT nullable ← Phase 35
```
**Handler (`handler_type`, Phase 35) — who is doing the work:**
| Value | Meaning | Detail fields | `assigned_to` role |
|---|---|---|---|
| `internal` (default) | Our staff | — (the assignee IS the handler) | the handler |
| `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** |
`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`. Set via the **Update Issue** panel on the issue detail page — the "Handled By" selector reveals the facility or vendor sub-fields via JS. Triage of `handler_type` + facility/vendor detail fields is **admin/director/project_manager only** (same gate as vendor fields); `assigned_to` remains admin/director. Issue list is filterable by `?handler_type=` and shows a Facility/Vendor badge. `Issue.handler_label` gives the display string. Not yet exposed in the mobile API.
**Photo columns — three distinct fields with different semantics:**
| Column | Type | Populated by | Displayed as |
@@ -714,7 +728,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase31_device_registry
→ phase32_device_token_columns
→ phase33_contract_recipients
→ phase34_facility_qr ← HEAD
→ phase34_facility_qr
→ phase35_issue_handler ← HEAD
```
### phase21_performance_indexes
@@ -810,6 +825,16 @@ flask db upgrade # adds + backfills public_token
sudo systemctl restart gunicorn
```
### phase35_issue_handler
Revision id `phase35_issue_handler` (file `phase35_issue_handler_type.py`). Adds to `issues`: `handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal'` and `facility_handler_name/contact/notes`. **Backfills** existing rows with a non-empty `vendor_name` to `handler_type='vendor'`. Separates WHO handles an issue (see §5 Issue + the Handler section). `INFORMATION_SCHEMA` checks — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
**Deploy order for phases 2432:**
```bash
flask db upgrade
+23
View File
@@ -84,6 +84,18 @@ class Issue(db.Model):
vendor_contact = db.Column(db.String(200), nullable=True) # phone or email
vendor_notes = db.Column(db.Text, nullable=True)
# ── Who handles the issue (phase35) ──────────────────────────────────
# internal = our staff (assigned_to); facility = the facility's own staff
# (facility_handler_* below); vendor = external contractor (vendor_* above).
# assigned_to remains the internal follow-up owner in ALL cases.
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) # phone or email
facility_handler_notes = db.Column(db.Text, nullable=True)
# Relationships
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
# Do NOT add a second explicit db.relationship('Area') here — it conflicts
@@ -102,6 +114,17 @@ class Issue(db.Model):
"""Return True if the given user is currently following this issue."""
return self.followers.filter_by(user_id=user.id).first() is not None
# Human-readable label for the handler category (phase35).
HANDLER_LABELS = {
'internal': 'Our Staff',
'facility': 'Facility Staff',
'vendor': 'External Vendor',
}
@property
def handler_label(self):
return self.HANDLER_LABELS.get(self.handler_type or 'internal', 'Our Staff')
@property
def resolved_facility(self):
"""Returns the Facility for this issue regardless of which path was used to create it.
+19 -2
View File
@@ -112,9 +112,12 @@ def export_list_pdf():
date_from_filter = request.args.get('date_from', '')
date_to_filter = request.args.get('date_to', '')
reporter_filter = request.args.get('reporter_id', '')
handler_filter = request.args.get('handler_type', '')
if issue_id_filter.isdigit():
q = q.filter(Issue.id == int(issue_id_filter))
if handler_filter in ('internal', 'facility', 'vendor'):
q = q.filter(Issue.handler_type == handler_filter)
if severity_filter:
q = q.filter(Issue.severity == severity_filter)
if status_filter:
@@ -243,9 +246,12 @@ def index():
date_from_filter = request.args.get('date_from', '')
date_to_filter = request.args.get('date_to', '')
reporter_filter = request.args.get('reporter_id', '')
handler_filter = request.args.get('handler_type', '')
if issue_id_filter.isdigit():
q = q.filter(Issue.id == int(issue_id_filter))
if handler_filter in ('internal', 'facility', 'vendor'):
q = q.filter(Issue.handler_type == handler_filter)
if severity_filter:
q = q.filter(Issue.severity == severity_filter)
if status_filter:
@@ -352,6 +358,7 @@ def index():
date_from_filter=date_from_filter,
date_to_filter=date_to_filter,
reporter_filter=reporter_filter,
handler_filter=handler_filter,
facilities=facilities,
projects=projects,
staff=staff,
@@ -432,12 +439,22 @@ def view(issue_id):
issue.result_notes = form.result_notes.data or None
# Vendor / contractor assignment — admin, director, project_manager only
# Handler assignment (who handles it) + vendor/facility details —
# admin, director, project_manager only.
if current_user.role in ('admin', 'director', 'project_manager'):
handler = form.handler_type.data or 'internal'
if handler not in ('internal', 'facility', 'vendor'):
handler = 'internal'
issue.handler_type = handler
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.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
from app.routes.inspections import _save_photo
new_photos = []
for file_obj in request.files.getlist('result_photos'):
@@ -595,7 +612,7 @@ def view(issue_id):
db.session.commit() # Single atomic commit: issue fields + comment + all notification rows
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}',
f'status={issue.status}; assigned_to={issue.assigned_to}')
f'status={issue.status}; handler={issue.handler_type}; assigned_to={issue.assigned_to}')
flash('Issue updated.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id))
+16 -2
View File
@@ -82,6 +82,15 @@
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Handled By</label>
<select name="handler_type" class="form-select form-select-sm">
<option value="">All Handlers</option>
<option value="internal" {{ 'selected' if handler_filter == 'internal' }}>Our Staff</option>
<option value="facility" {{ 'selected' if handler_filter == 'facility' }}>Facility Staff</option>
<option value="vendor" {{ 'selected' if handler_filter == 'vendor' }}>External Vendor</option>
</select>
</div>
<div class="col-auto d-flex align-items-end gap-2 flex-wrap">
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
@@ -172,6 +181,11 @@
{% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
{% else %}<span class="text-muted"></span>{% endif %}
{% endif %}
{% if issue.handler_type == 'facility' %}
<div><span class="badge bg-info text-dark mt-1" title="Handled by facility staff"><i class="bi bi-building"></i> Facility</span></div>
{% elif issue.handler_type == 'vendor' %}
<div><span class="badge bg-warning text-dark mt-1" title="Handled by external vendor"><i class="bi bi-person-gear"></i> Vendor</span></div>
{% endif %}
</td>
<td class="text-nowrap">
{# Following badge + inline unfollow #}
@@ -184,7 +198,7 @@
class="d-inline"
title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter) }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter) }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
@@ -225,7 +239,7 @@
{% if p %}
<li class="page-item {{ 'active' if p == issues.page }}">
<a class="page-link"
href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter) }}">{{ p }}</a>
href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter) }}">{{ p }}</a>
</li>
{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}
{% endfor %}
+120 -25
View File
@@ -73,10 +73,37 @@
</dd>
{% endif %}
<dt class="col-sm-3">Assigned To</dt>
<dt class="col-sm-3">Handled By</dt>
<dd class="col-sm-9">
{% if issue.handler_type == 'facility' %}
<span class="badge bg-info text-dark"><i class="bi bi-building me-1"></i>Facility Staff</span>
{% elif issue.handler_type == 'vendor' %}
<span class="badge bg-warning text-dark"><i class="bi bi-person-gear me-1"></i>External Vendor</span>
{% else %}
<span class="badge bg-primary"><i class="bi bi-people me-1"></i>Our Staff</span>
{% endif %}
</dd>
<dt class="col-sm-3">
{{ 'Follow-up Owner' if issue.handler_type in ['facility','vendor'] else 'Assigned To' }}
</dt>
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
{% if issue.vendor_name %}
{% if issue.handler_type == 'facility' and issue.facility_handler_name %}
<dt class="col-sm-3">Facility Contact</dt>
<dd class="col-sm-9">
<i class="bi bi-building text-secondary me-1"></i>
<strong>{{ issue.facility_handler_name }}</strong>
{% if issue.facility_handler_contact %}
<span class="text-muted ms-2">{{ issue.facility_handler_contact }}</span>
{% endif %}
{% if issue.facility_handler_notes %}
<div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.facility_handler_notes }}</div>
{% endif %}
</dd>
{% endif %}
{% if issue.handler_type == 'vendor' and issue.vendor_name %}
<dt class="col-sm-3">Contractor</dt>
<dd class="col-sm-9">
<i class="bi bi-person-gear text-secondary me-1"></i>
@@ -341,30 +368,52 @@
{{ form.status.label(class="form-label fw-semibold") }}
{{ form.status(class="form-select") }}
</div>
{% if current_user.role in ['admin','director'] %}
<div class="mb-3">
{{ form.assigned_to.label(class="form-label fw-semibold") }}
{{ form.assigned_to(class="form-select") }}
</div>
{% endif %}
<div class="mb-3">
{{ form.result_notes.label(class="form-label fw-semibold") }}
{{ form.result_notes(class="form-control", rows=3,
placeholder="Describe what was done to resolve this issue…",
value=issue.result_notes or '') }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Result Photos</label>
<input type="file" name="result_photos" id="result_photos"
class="form-control" accept="image/*" multiple>
<div class="form-text">Attach one or more photos showing the resolution.</div>
{% if issue.result_photos %}
<div class="mt-2">
<small class="text-muted">{{ issue.result_photos|length }} photo(s) already uploaded</small>
</div>
{% endif %}
</div>
{% if current_user.role in ['admin','director','project_manager'] %}
{# ── Who handles this issue ── #}
<div class="mb-3">
{{ form.handler_type.label(class="form-label fw-semibold") }}
{{ form.handler_type(class="form-select", id="handler_type_select") }}
</div>
{% endif %}
{% if current_user.role in ['admin','director'] %}
<div class="mb-3" id="assigned_to_wrap">
<label class="form-label fw-semibold" id="assigned_to_label">Assign To</label>
{{ form.assigned_to(class="form-select") }}
<div class="form-text" id="assigned_to_help" style="display:none;">
The facility/vendor does the work; this is our follow-up owner.
</div>
</div>
{% endif %}
{% if current_user.role in ['admin','director','project_manager'] %}
{# ── Facility-staff handler (shown when Handled By = Facility Staff) ── #}
<div id="facility_handler_block" style="display:none;">
<hr class="my-3">
<p class="fw-semibold small mb-2">
<i class="bi bi-building me-1 text-secondary"></i>Facility Staff Contact
</p>
<div class="mb-2">
{{ form.facility_handler_name.label(class="form-label small fw-semibold mb-1") }}
{{ form.facility_handler_name(class="form-control form-control-sm",
placeholder="Facility staff / point of contact",
value=issue.facility_handler_name or '') }}
</div>
<div class="mb-2">
{{ form.facility_handler_contact.label(class="form-label small fw-semibold mb-1") }}
{{ form.facility_handler_contact(class="form-control form-control-sm",
placeholder="Phone or email",
value=issue.facility_handler_contact or '') }}
</div>
<div class="mb-3">
{{ form.facility_handler_notes.label(class="form-label small fw-semibold mb-1") }}
{{ form.facility_handler_notes(class="form-control form-control-sm", rows=2,
placeholder="What the facility staff are handling…") }}
</div>
</div>
{# ── External vendor (shown when Handled By = External Vendor) ── #}
<div id="vendor_block" style="display:none;">
<hr class="my-3">
<p class="fw-semibold small mb-2">
<i class="bi bi-person-gear me-1 text-secondary"></i>External Contractor
@@ -386,7 +435,27 @@
{{ form.vendor_notes(class="form-control form-control-sm", rows=2,
placeholder="Notes about what the contractor is handling…") }}
</div>
</div>
{% endif %}
<hr class="my-3">
<div class="mb-3">
{{ form.result_notes.label(class="form-label fw-semibold") }}
{{ form.result_notes(class="form-control", rows=3,
placeholder="Describe what was done to resolve this issue…",
value=issue.result_notes or '') }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Result Photos</label>
<input type="file" name="result_photos" id="result_photos"
class="form-control" accept="image/*" multiple>
<div class="form-text">Attach one or more photos showing the resolution.</div>
{% if issue.result_photos %}
<div class="mt-2">
<small class="text-muted">{{ issue.result_photos|length }} photo(s) already uploaded</small>
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary w-100">Save Update</button>
</form>
{% if issue.status in ['in_progress', 'resolved'] and current_user.role not in ['customer'] %}
@@ -476,6 +545,32 @@
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// ── "Handled By" — show the relevant sub-block (facility vs vendor) and
// relabel the assignee as a follow-up owner for facility/vendor. ──
var handlerSelect = document.getElementById('handler_type_select');
function syncHandlerUI() {
if (!handlerSelect) { return; }
var v = handlerSelect.value;
var facBlock = document.getElementById('facility_handler_block');
var venBlock = document.getElementById('vendor_block');
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
var label = document.getElementById('assigned_to_label');
var help = document.getElementById('assigned_to_help');
if (label) {
label.textContent = (v === 'facility' || v === 'vendor')
? 'Follow-up Owner' : 'Assign To';
}
if (help) {
help.style.display = (v === 'facility' || v === 'vendor') ? '' : 'none';
}
}
if (handlerSelect) {
handlerSelect.addEventListener('change', syncHandlerUI);
syncHandlerUI(); // set initial state on load
}
})();
</script>
{% endblock %}
+11 -1
View File
@@ -185,7 +185,17 @@ class IssueUpdateForm(FlaskForm):
Optional(),
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
])
# External contractor / vendor fields (phase26)
# Who handles the issue (phase35)
handler_type = SelectField('Handled By', choices=[
('internal', 'Our Staff'),
('facility', 'Facility Staff'),
('vendor', 'External Vendor'),
], validators=[Optional()])
# Facility-staff handler (free text) — used when handler_type == 'facility'
facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)])
facility_handler_contact = StringField('Facility Contact', validators=[Optional(), Length(max=200)])
facility_handler_notes = TextAreaField('Facility Handling Notes', validators=[Optional(), Length(max=1000)])
# External contractor / vendor fields (phase26) — used when handler_type == 'vendor'
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
@@ -0,0 +1,68 @@
"""phase35 — issue handler_type + facility-staff handler fields
Separates WHO handles an issue into three categories:
internal one of our staff (existing assigned_to User)
facility the facility's own staff (new free-text facility_handler_* fields)
vendor an external contractor (existing vendor_* fields, phase26)
`assigned_to` remains the internal follow-up owner in all cases.
Backfills existing rows that already have a vendor_name to handler_type='vendor'.
Uses INFORMATION_SCHEMA column-existence checks safe to re-run.
"""
revision = 'phase35_issue_handler'
down_revision = 'phase34_facility_qr'
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', 'handler_type'):
op.execute(sa.text(
"ALTER TABLE issues ADD COLUMN handler_type "
"ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal'"
))
if not _column_exists(bind, 'issues', 'facility_handler_name'):
op.execute(sa.text(
"ALTER TABLE issues ADD COLUMN facility_handler_name VARCHAR(100) NULL"
))
if not _column_exists(bind, 'issues', 'facility_handler_contact'):
op.execute(sa.text(
"ALTER TABLE issues ADD COLUMN facility_handler_contact VARCHAR(200) NULL"
))
if not _column_exists(bind, 'issues', 'facility_handler_notes'):
op.execute(sa.text(
"ALTER TABLE issues ADD COLUMN facility_handler_notes TEXT NULL"
))
# Backfill: rows already carrying a vendor become handler_type='vendor'
# so existing contractor assignments keep their meaning.
op.execute(sa.text(
"UPDATE issues SET handler_type = 'vendor' "
"WHERE handler_type = 'internal' "
"AND vendor_name IS NOT NULL AND TRIM(vendor_name) <> ''"
))
def downgrade():
bind = op.get_bind()
for col in ('facility_handler_notes', 'facility_handler_contact',
'facility_handler_name', 'handler_type'):
if _column_exists(bind, 'issues', col):
op.execute(sa.text(f"ALTER TABLE issues DROP COLUMN {col}"))