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
+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 %}
+123 -28
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,12 +368,77 @@
{{ form.status.label(class="form-label fw-semibold") }}
{{ form.status(class="form-select") }}
</div>
{% if current_user.role in ['admin','director'] %}
{% if current_user.role in ['admin','director','project_manager'] %}
{# ── Who handles this issue ── #}
<div class="mb-3">
{{ form.assigned_to.label(class="form-label fw-semibold") }}
{{ form.assigned_to(class="form-select") }}
{{ 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
</p>
<div class="mb-2">
{{ form.vendor_name.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_name(class="form-control form-control-sm",
placeholder="Contractor or vendor name",
value=issue.vendor_name or '') }}
</div>
<div class="mb-2">
{{ form.vendor_contact.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_contact(class="form-control form-control-sm",
placeholder="Phone or email",
value=issue.vendor_contact or '') }}
</div>
<div class="mb-3">
{{ form.vendor_notes.label(class="form-label small fw-semibold mb-1") }}
{{ 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,
@@ -364,29 +456,6 @@
</div>
{% endif %}
</div>
{% if current_user.role in ['admin','director','project_manager'] %}
<hr class="my-3">
<p class="fw-semibold small mb-2">
<i class="bi bi-person-gear me-1 text-secondary"></i>External Contractor
</p>
<div class="mb-2">
{{ form.vendor_name.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_name(class="form-control form-control-sm",
placeholder="Contractor or vendor name",
value=issue.vendor_name or '') }}
</div>
<div class="mb-2">
{{ form.vendor_contact.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_contact(class="form-control form-control-sm",
placeholder="Phone or email",
value=issue.vendor_contact or '') }}
</div>
<div class="mb-3">
{{ form.vendor_notes.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_notes(class="form-control form-control-sm", rows=2,
placeholder="Notes about what the contractor is handling…") }}
</div>
{% endif %}
<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)])