Jul 7 - Implement additional recipient per contract

This commit is contained in:
2026-07-07 20:28:17 -04:00
parent 8f971a403b
commit a3c6fd73bd
9 changed files with 613 additions and 8 deletions
+1
View File
@@ -4,6 +4,7 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem,
Inspection, InspectionResult)
from app.models.issue import Issue
from app.models.project import Project, CustomerAssignment
from app.models.project_recipient import ProjectNotificationRecipient
from app.models.api_token import RefreshToken, DeviceToken
from app.models.notification_matrix import NotificationMatrix
from app.models.inspection_schedule import InspectionSchedule
+72
View File
@@ -0,0 +1,72 @@
"""
app/models/project_recipient.py
-------------------------------
Per-contract (Project) additional notification recipients.
The global notification matrix controls WHICH ROLES receive each event
org-wide. This model adds contract-scoped recipients on top of that:
each row subscribes one recipient to a chosen set of matrix event types,
but ONLY for events that occur in facilities belonging to that contract.
Recipient kinds
---------------
staff — user_id set, email NULL. Gets an in-app Notification + email
via notify() (matrix-authority mode, preferences not consulted).
external — email set, user_id NULL. Gets a plain email only (no account,
no in-app record) via _send_custom_email().
`events` stores a JSON list of MATRIX_EVENTS keys (see
app/models/notification_matrix.py). Dispatch happens inside
notify_by_matrix() → _notify_project_recipients() after the matrix roles
and global custom emails are processed.
"""
import json
from app import db
from app.utils.time_utils import now_eastern
class ProjectNotificationRecipient(db.Model):
"""A contract-scoped additional notification recipient."""
__tablename__ = 'project_notification_recipients'
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer,
db.ForeignKey('projects.id', ondelete='CASCADE'),
nullable=False, index=True)
# Exactly one of (user_id, email) is set — enforced in the route layer.
user_id = db.Column(db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=True, index=True)
email = db.Column(db.String(255), nullable=True)
events = db.Column(db.Text, nullable=False, default='[]') # JSON list of event keys
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
# Relationships
project = db.relationship('Project', foreign_keys=[project_id],
backref=db.backref('notification_recipients',
lazy='dynamic',
cascade='all, delete-orphan'))
user = db.relationship('User', foreign_keys=[user_id],
backref=db.backref('project_notification_subscriptions',
lazy='dynamic'))
@property
def is_staff(self):
"""True when the recipient is an application user (in-app + email)."""
return self.user_id is not None
def get_events(self):
"""Return the subscribed event keys as a Python list."""
if not self.events:
return []
try:
result = json.loads(self.events)
return [e for e in result if isinstance(e, str) and e.strip()]
except (json.JSONDecodeError, TypeError):
return []
def __repr__(self):
who = f'user={self.user_id}' if self.user_id else f'email={self.email}'
return f'<ProjectNotificationRecipient project={self.project_id} {who}>'
+144
View File
@@ -236,6 +236,150 @@ def remove_assignment(assignment_id):
return redirect(url_for('projects.view', project_id=project_id))
# ── Notification Recipients — per-contract (phase37) ─────────────────────────
@bp.route('/<int:project_id>/recipients')
@login_required
@supervisor_required
def recipients(project_id):
"""Manage additional notification recipients for one contract.
Recipients are either staff users (in-app + email) or external email
addresses (email only), each subscribed to a chosen set of matrix events.
Dispatched by notify_by_matrix() for events in this contract's facilities.
"""
from app.models.project_recipient import ProjectNotificationRecipient
from app.models.notification_matrix import MATRIX_EVENTS
project = db.session.get(Project, project_id)
if project is None:
abort(404)
recipient_rows = (
ProjectNotificationRecipient.query
.filter_by(project_id=project_id)
.order_by(ProjectNotificationRecipient.created_at)
.all()
)
staff_users = (
User.query
.filter(User.active == True, User.role != 'customer') # noqa: E712
.order_by(User.username)
.all()
)
return render_template(
'projects/recipients.html',
project=project,
recipients=recipient_rows,
staff_users=staff_users,
matrix_events=MATRIX_EVENTS,
)
@bp.route('/<int:project_id>/recipients/add', methods=['POST'])
@login_required
@supervisor_required
def add_recipient(project_id):
"""Add (or update, if the recipient already exists) a contract recipient."""
import json as _json
import re as _re
from app.models.project_recipient import ProjectNotificationRecipient
from app.models.notification_matrix import MATRIX_EVENTS
project = db.session.get(Project, project_id)
if project is None:
abort(404)
events = [e for e in request.form.getlist('events') if e in MATRIX_EVENTS]
if not events:
flash('Select at least one event to notify this recipient about.', 'warning')
return redirect(url_for('projects.recipients', project_id=project_id))
recipient_type = request.form.get('recipient_type', 'staff')
if recipient_type == 'staff':
user_id = request.form.get('user_id', type=int)
user = db.session.get(User, user_id) if user_id else None
if user is None or not user.active or user.role == 'customer':
flash('Please choose a valid staff user.', 'warning')
return redirect(url_for('projects.recipients', project_id=project_id))
row = ProjectNotificationRecipient.query.filter_by(
project_id=project_id, user_id=user.id
).first()
if row:
row.events = _json.dumps(events)
action, verb = 'updated', 'UPDATE'
else:
row = ProjectNotificationRecipient(
project_id=project_id, user_id=user.id,
events=_json.dumps(events),
)
db.session.add(row)
action, verb = 'added', 'CREATE'
label = user.display_name
else:
email = (request.form.get('email') or '').strip()
if not _re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email):
flash('Please enter a valid email address.', 'warning')
return redirect(url_for('projects.recipients', project_id=project_id))
row = ProjectNotificationRecipient.query.filter(
ProjectNotificationRecipient.project_id == project_id,
ProjectNotificationRecipient.user_id.is_(None),
db.func.lower(ProjectNotificationRecipient.email) == email.lower(),
).first()
if row:
row.events = _json.dumps(events)
action, verb = 'updated', 'UPDATE'
else:
row = ProjectNotificationRecipient(
project_id=project_id, email=email,
events=_json.dumps(events),
)
db.session.add(row)
action, verb = 'added', 'CREATE'
label = email
db.session.commit()
logger.info('PROJECTS | recipient_%s | user=%s project_id=%s recipient=%s events=%s',
action, current_user.username, project_id, label, events)
log_action(ACTION_CREATE if verb == 'CREATE' else ACTION_UPDATE,
'ProjectNotificationRecipient', row.id,
f'{label}{project.name}',
f'events={",".join(events)}')
flash(f'Recipient "{label}" {action} for contract "{project.name}".', 'success')
return redirect(url_for('projects.recipients', project_id=project_id))
@bp.route('/recipients/<int:recipient_id>/remove', methods=['POST'])
@login_required
@supervisor_required
def remove_recipient(recipient_id):
"""Remove a contract notification recipient."""
from app.models.project_recipient import ProjectNotificationRecipient
row = db.session.get(ProjectNotificationRecipient, recipient_id)
if row is None:
abort(404)
project = db.session.get(Project, row.project_id)
if project is None:
abort(404)
label = row.user.display_name if row.user else (row.email or f'id={row.id}')
project_id_snap = row.project_id
row_id_snap = row.id
db.session.delete(row)
db.session.commit()
logger.info('PROJECTS | recipient_remove | user=%s project_id=%s recipient=%s',
current_user.username, project_id_snap, label)
log_action(ACTION_DELETE, 'ProjectNotificationRecipient', row_id_snap,
f'{label}{project.name}')
flash(f'Recipient "{label}" removed.', 'success')
return redirect(url_for('projects.recipients', project_id=project_id_snap))
# ── Bulk Import — Excel template download ─────────────────────────────────────
@bp.route('/import/template')
+183
View File
@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}Notification Recipients — {{ project.name }}{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<h2><i class="bi bi-bell"></i> Notification Recipients</h2>
<p class="text-muted mb-0">
Additional recipients notified for events in
<strong>{{ project.name }}</strong> facilities — on top of the
global {% if current_user.role == 'admin' %}<a href="{{ url_for('auth.notification_matrix') }}">notification matrix</a>{% else %}notification matrix (admin-managed){% endif %}.
</p>
</div>
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> Back to Contract
</a>
</div>
</div>
{# ── Recipient list ── #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold">
<i class="bi bi-bell"></i> Additional Notification Recipients
<span class="badge bg-secondary rounded-pill ms-1">{{ recipients|length }}</span>
</span>
<button class="btn btn-sm btn-primary" type="button"
data-bs-toggle="collapse" data-bs-target="#addRecipientForm"
aria-expanded="false" aria-controls="addRecipientForm">
<i class="bi bi-plus-lg"></i> Add Recipient
</button>
</div>
{# ── Add / update form (collapsed) ── #}
<div class="collapse border-bottom" id="addRecipientForm">
<form method="POST" action="{{ url_for('projects.add_recipient', project_id=project.id) }}"
class="card-body bg-light-subtle">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label small fw-semibold">Recipient type</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="recipient_type"
id="typeStaff" value="staff" checked>
<label class="form-check-label small" for="typeStaff">Staff user</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="recipient_type"
id="typeExternal" value="external">
<label class="form-check-label small" for="typeExternal">External email</label>
</div>
</div>
</div>
<div class="col-md-4" id="staffPicker">
<label class="form-label small fw-semibold" for="user_id">Staff user</label>
<select class="form-select" name="user_id" id="user_id">
{% for u in staff_users %}
<option value="{{ u.id }}">{{ u.display_name }} ({{ u.email or 'no email' }})</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 d-none" id="emailPicker">
<label class="form-label small fw-semibold" for="email">Email address</label>
<input type="email" class="form-control" name="email" id="email"
placeholder="name@example.com">
</div>
</div>
<div class="mt-3">
<label class="form-label small fw-semibold mb-1">Notified events</label>
<div class="row">
{% for key, label in matrix_events.items() %}
<div class="col-12 col-sm-6 col-lg-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="events"
value="{{ key }}" id="ev_{{ key }}">
<label class="form-check-label small" for="ev_{{ key }}">{{ label }}</label>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-check-lg"></i> Save Recipient
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="collapse" data-bs-target="#addRecipientForm">
Cancel
</button>
</div>
<div class="form-text mt-2">
Adding an existing recipient again replaces their event list.
Staff users receive in-app + email notifications; external
addresses receive email only.
</div>
</form>
</div>
<div class="card-body p-0">
{% if recipients %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="min-width:180px;">Recipient</th>
<th style="min-width:150px;">Type</th>
<th>Notified Events</th>
<th width="60"></th>
</tr>
</thead>
<tbody>
{% for r in recipients %}
<tr>
<td>
{% if r.is_staff %}
<strong>{{ r.user.display_name }}</strong>
<div class="text-muted small">{{ r.user.email or 'no email on file' }}</div>
{% else %}
<strong>{{ r.email }}</strong>
{% endif %}
</td>
<td>
{% if r.is_staff %}
<span class="badge bg-primary">Staff · in-app + email</span>
{% else %}
<span class="badge bg-secondary">External · email</span>
{% endif %}
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for ev in r.get_events() %}
<span class="badge bg-light text-dark border">{{ matrix_events.get(ev, ev) }}</span>
{% endfor %}
</div>
</td>
<td>
<form method="POST"
action="{{ url_for('projects.remove_recipient', recipient_id=r.id) }}"
onsubmit="return confirm('Remove this recipient?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Remove recipient">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted small">
No additional recipients configured for this contract yet.
Events fall through to the global notification matrix only.
</div>
{% endif %}
</div>
</div>
<script>
(function () {
var staffRadio = document.getElementById('typeStaff');
var externalRadio = document.getElementById('typeExternal');
var staffPicker = document.getElementById('staffPicker');
var emailPicker = document.getElementById('emailPicker');
function togglePickers() {
var staff = staffRadio.checked;
staffPicker.classList.toggle('d-none', !staff);
emailPicker.classList.toggle('d-none', staff);
}
staffRadio.addEventListener('change', togglePickers);
externalRadio.addEventListener('change', togglePickers);
togglePickers();
})();
</script>
{% endblock %}
+3
View File
@@ -16,6 +16,9 @@
</div>
<div class="col-auto d-flex gap-2">
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('projects.recipients', project_id=project.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-bell"></i> Notification Recipients
</a>
<a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit
</a>
+128
View File
@@ -603,12 +603,140 @@ def notify_by_matrix(
for email in custom_emails:
_send_custom_email(email, title, body, link)
# ── Per-contract additional recipients (phase37) ──────────────────────
_notify_project_recipients(
event_type = event_type,
title = title,
body = body,
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
facility_id = facility_id,
exclude_user_ids = exclude,
already_notified = notified,
already_emailed = {e.strip().lower() for e in custom_emails},
)
logger.info(
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
event_type, len(notified), len(custom_emails),
)
def _notify_project_recipients(
event_type: str,
title: str,
body: str,
link: str = None,
issue_id: int = None,
inspection_id: int = None,
facility_id: int = None,
exclude_user_ids: set = None,
already_notified: set = None,
already_emailed: set = None,
):
"""
Dispatch to per-contract additional recipients (ProjectNotificationRecipient).
Called from notify_by_matrix() AFTER the global matrix roles and custom
emails. Resolves the contract via the event's facility:
facility_id arg → else issue.resolved_facility → else inspection.facility_id
then notifies every recipient of that facility's contract whose subscribed
event list contains event_type.
Staff recipients (user_id set) get an in-app Notification + email via
notify() with respect_preferences=False (matrix-authority mode, same as
role broadcasts). External recipients (email set) get a plain email only.
Deduplication: `already_notified` (user IDs notified by the matrix roles)
and `already_emailed` (lowercased global custom emails) are honoured and
mutated in place so a recipient is never contacted twice per event.
Best-effort: any failure is logged and never propagates to the caller.
"""
exclude = set(exclude_user_ids or [])
notified = already_notified if already_notified is not None else set()
emailed = already_emailed if already_emailed is not None else set()
try:
from app.models.project_recipient import ProjectNotificationRecipient
from app.models.facility import Facility
# Resolve the facility this event occurred at
fid = facility_id
if fid is None and issue_id:
from app.models.issue import Issue
issue = db.session.get(Issue, issue_id)
if issue:
fac = issue.resolved_facility
fid = fac.id if fac else None
if fid is None and inspection_id:
from app.models.inspection import Inspection
insp = db.session.get(Inspection, inspection_id)
fid = insp.facility_id if insp else None
if fid is None:
return # no facility context — contract cannot be determined
facility = db.session.get(Facility, fid)
if not facility or not facility.project_id:
return # facility unknown or not linked to a contract
recipients = ProjectNotificationRecipient.query.filter_by(
project_id=facility.project_id
).all()
sent = 0
for r in recipients:
if event_type not in r.get_events():
continue
if r.user_id:
if r.user_id in exclude or r.user_id in notified:
continue
user = r.user
if not user or not user.active:
continue
notify(
recipient = user,
title = title,
body = body,
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
event_type = event_type,
send_email = True,
respect_preferences = False, # contract config is the authority
)
notified.add(user.id)
sent += 1
logger.info(
'CONTRACT NOTIFY | project_id=%s | event=%s | user=%s',
facility.project_id, event_type, user.username,
)
elif r.email:
key = r.email.strip().lower()
if not key or key in emailed:
continue
_send_custom_email(r.email.strip(), title, body, link)
emailed.add(key)
sent += 1
logger.info(
'CONTRACT NOTIFY | project_id=%s | event=%s | email=%s',
facility.project_id, event_type, key,
)
if sent:
logger.info(
'CONTRACT NOTIFY DONE | project_id=%s | event=%s | sent=%s',
facility.project_id, event_type, sent,
)
except Exception as exc:
logger.error(
'CONTRACT NOTIFY FAILED | event=%s | facility_id=%s | error=%s',
event_type, facility_id, exc,
)
def _send_custom_email(to_email: str, title: str, body: str, link: str = None):
"""Send a plain email to a custom (non-user) address. Best-effort."""
try:
+5 -4
View File
@@ -319,10 +319,11 @@ def send_score_alerts(threshold=SCORE_DROP_THRESHOLD):
link = f'/reports/facility/{fid}/scorecard'
notify_by_matrix(
event_type = 'score_alert',
title = title,
body = body,
link = link,
event_type = 'score_alert',
title = title,
body = body,
link = link,
facility_id = fid,
)
total_sent += 1