July 7 - Implement notification per contract

This commit is contained in:
2026-07-07 10:03:55 -04:00
parent 0d4a10d01d
commit e578fb6ca1
7 changed files with 634 additions and 6 deletions
+2 -1
View File
@@ -5,4 +5,5 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem,
from app.models.issue import Issue
from app.models.project import Project, CustomerAssignment
from app.models.api_token import RefreshToken, DeviceToken
from app.models.notification_matrix import NotificationMatrix
from app.models.notification_matrix import NotificationMatrix
from app.models.notification_recipient import ContractNotificationRecipient
+79
View File
@@ -0,0 +1,79 @@
"""
app/models/notification_recipient.py
------------------------------------
Per-contract additional notification recipients.
Each row is ONE extra recipient attached to a Contract (Project) who should be
notified when selected notification events fire within that contract's
facilities — in ADDITION to whoever the global NotificationMatrix already
routes to.
A recipient is either:
- an existing app user (user_id set, email NULL) → in-app notification + email
- a free-form email (email set, user_id NULL) → email only
`event_types` is a JSON list of event_type keys (matching MATRIX_EVENTS) that
this recipient subscribes to for this contract. A recipient is notified only
when the firing event_type is present in this list. An empty list means the
recipient receives nothing (the add form requires at least one event).
Resolution happens centrally in notify_by_matrix() (app/utils/notifications.py),
which derives the contract from the event's facility / issue / inspection.
"""
import json
from app import db
from app.utils.time_utils import now_eastern
class ContractNotificationRecipient(db.Model):
__tablename__ = 'contract_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, not the DB).
user_id = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=True, index=True,
)
email = db.Column(db.String(200), nullable=True)
# JSON list of event_type keys this recipient is subscribed to for this contract.
event_types = db.Column(db.Text, nullable=True) # JSON-encoded list[str]
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
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])
def get_event_types(self) -> list:
"""Return event_types as a Python list of strings."""
if not self.event_types:
return []
try:
result = json.loads(self.event_types)
return [e for e in result if isinstance(e, str) and e.strip()]
except (json.JSONDecodeError, TypeError):
return []
def set_event_types(self, values) -> None:
"""Store a list of event_type keys as a JSON string."""
clean = [v.strip() for v in (values or []) if isinstance(v, str) and v.strip()]
self.event_types = json.dumps(clean)
@property
def display_target(self) -> str:
"""Human-readable recipient label for the UI."""
if self.user_id and self.user:
return f'{self.user.display_name} ({self.user.email})'
return self.email or ''
def __repr__(self):
who = f'user={self.user_id}' if self.user_id else f'email={self.email}'
return f'<ContractNotificationRecipient project={self.project_id} {who}>'
+149
View File
@@ -83,11 +83,37 @@ def view(project_id):
.order_by(User.username)
.all()
)
# ── Additional notification recipients (admin-only panel) ──
# Only loaded for admins — the panel is admin-gated in the template, and
# the add/remove routes are @admin_required. No point querying otherwise.
from app.models.notification_matrix import MATRIX_EVENTS
notify_recipients = []
staff_users = []
if current_user.role == 'admin':
from app.models.notification_recipient import ContractNotificationRecipient
notify_recipients = (
ContractNotificationRecipient.query
.filter_by(project_id=project_id)
.order_by(ContractNotificationRecipient.created_at.asc())
.all()
)
# Internal staff users selectable as recipients (exclude customers)
staff_users = (
User.query
.filter(User.active == True, User.role != 'customer') # noqa: E712
.order_by(User.username)
.all()
)
return render_template(
'projects/view.html',
project=project,
facilities=facilities,
assignments=assignments,
notify_recipients=notify_recipients,
matrix_events=MATRIX_EVENTS,
staff_users=staff_users,
)
@@ -236,6 +262,129 @@ def remove_assignment(assignment_id):
return redirect(url_for('projects.view', project_id=project_id))
# ── Additional Notification Recipients — Add ──────────────────────────────────
@bp.route('/<int:project_id>/notify-recipients/add', methods=['POST'])
@login_required
@admin_required
def add_notify_recipient(project_id):
"""Attach an additional notification recipient (user OR email) to a contract.
These recipients are notified — for the selected event types — whenever
those events fire within this contract's facilities, in ADDITION to the
global NotificationMatrix routing. See app/utils/notifications.py.
"""
from app.models.notification_recipient import ContractNotificationRecipient
from app.models.notification_matrix import MATRIX_EVENTS
project = db.session.get(Project, project_id)
if project is None:
abort(404)
recipient_type = (request.form.get('recipient_type') or '').strip()
event_types = request.form.getlist('event_types')
# Keep only valid, known event keys
event_types = [e for e in event_types if e in MATRIX_EVENTS]
if not event_types:
flash('Select at least one event type for the recipient.', 'warning')
return redirect(url_for('projects.view', project_id=project_id))
user_id = None
email = None
if recipient_type == 'user':
try:
user_id = int(request.form.get('user_id') or 0)
except (TypeError, ValueError):
user_id = 0
user = db.session.get(User, user_id) if user_id else None
if user is None or user.role == 'customer' or not user.active:
flash('Select a valid staff user.', 'warning')
return redirect(url_for('projects.view', project_id=project_id))
# Guard against duplicate user recipient on the same contract
existing = ContractNotificationRecipient.query.filter_by(
project_id=project_id, user_id=user_id
).first()
if existing:
existing.set_event_types(event_types)
db.session.commit()
log_action(ACTION_UPDATE, 'ContractNotificationRecipient', existing.id,
f'{user.display_name}{project.name}',
f'events={",".join(event_types)}')
flash(f'Updated notification events for "{user.display_name}".', 'success')
return redirect(url_for('projects.view', project_id=project_id))
target_label = user.display_name
elif recipient_type == 'email':
email = (request.form.get('email') or '').strip()
# Minimal email sanity check — a single '@' with text on both sides
if '@' not in email or email.startswith('@') or email.endswith('@') or ' ' in email:
flash('Enter a valid email address.', 'warning')
return redirect(url_for('projects.view', project_id=project_id))
email = email[:200]
existing = ContractNotificationRecipient.query.filter_by(
project_id=project_id, email=email
).first()
if existing:
existing.set_event_types(event_types)
db.session.commit()
log_action(ACTION_UPDATE, 'ContractNotificationRecipient', existing.id,
f'{email}{project.name}',
f'events={",".join(event_types)}')
flash(f'Updated notification events for "{email}".', 'success')
return redirect(url_for('projects.view', project_id=project_id))
target_label = email
else:
flash('Choose whether to add a user or an email address.', 'warning')
return redirect(url_for('projects.view', project_id=project_id))
recipient = ContractNotificationRecipient(
project_id=project_id,
user_id=user_id,
email=email,
)
recipient.set_event_types(event_types)
db.session.add(recipient)
db.session.commit()
logger.info('PROJECTS | notify_recipient_add | admin=%s project_id=%s target=%s events=%s',
current_user.username, project_id, target_label, event_types)
log_action(ACTION_CREATE, 'ContractNotificationRecipient', recipient.id,
f'{target_label}{project.name}',
f'events={",".join(event_types)}')
flash(f'Notification recipient "{target_label}" added.', 'success')
return redirect(url_for('projects.view', project_id=project_id))
# ── Additional Notification Recipients — Remove ───────────────────────────────
@bp.route('/notify-recipients/<int:recipient_id>/remove', methods=['POST'])
@login_required
@admin_required
def remove_notify_recipient(recipient_id):
from app.models.notification_recipient import ContractNotificationRecipient
recipient = db.session.get(ContractNotificationRecipient, recipient_id)
if recipient is None:
abort(404)
project_id = recipient.project_id
project = db.session.get(Project, project_id)
label = recipient.display_target
recipient_id_snap = recipient.id
db.session.delete(recipient)
db.session.commit()
logger.info('PROJECTS | notify_recipient_remove | admin=%s project_id=%s target=%s',
current_user.username, project_id, label)
log_action(ACTION_DELETE, 'ContractNotificationRecipient', recipient_id_snap,
f'{label}{project.name if project else project_id}')
flash(f'Notification recipient "{label}" removed.', 'success')
return redirect(url_for('projects.view', project_id=project_id))
# ── Bulk Import — Excel template download ─────────────────────────────────────
@bp.route('/import/template')
+175
View File
@@ -165,5 +165,180 @@
</div>
{% endif %}
{# ── Additional Notification Recipients ── #}
{% if current_user.role == 'admin' %}
<div class="col-12">
<div class="card shadow-sm">
<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>
<button class="btn btn-sm btn-primary" type="button"
data-bs-toggle="collapse" data-bs-target="#addNotifyRecipient">
<i class="bi bi-plus-lg"></i> Add Recipient
</button>
</div>
{# Add form (collapsed by default) #}
<div class="collapse border-bottom" id="addNotifyRecipient">
<form method="POST"
action="{{ url_for('projects.add_notify_recipient', project_id=project.id) }}"
class="p-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row g-3 align-items-start">
{# Recipient type + target #}
<div class="col-md-5">
<label class="form-label small fw-semibold">Recipient</label>
<div class="btn-group btn-group-sm w-100 mb-2" role="group">
<input type="radio" class="btn-check" name="recipient_type"
id="rtypeUser" value="user" checked
onchange="toggleRecipientType()">
<label class="btn btn-outline-secondary" for="rtypeUser">Staff User</label>
<input type="radio" class="btn-check" name="recipient_type"
id="rtypeEmail" value="email"
onchange="toggleRecipientType()">
<label class="btn btn-outline-secondary" for="rtypeEmail">Email Address</label>
</div>
<div id="recipientUserWrap">
<select name="user_id" class="form-select form-select-sm">
<option value="">— Select a staff user —</option>
{% for u in staff_users %}
<option value="{{ u.id }}">{{ u.display_name }} ({{ u.role }})</option>
{% endfor %}
</select>
</div>
<div id="recipientEmailWrap" class="d-none">
<input type="email" name="email" class="form-control form-control-sm"
placeholder="name@example.com">
<div class="form-text">External address — receives email only.</div>
</div>
</div>
{# Event subscription #}
<div class="col-md-7">
<label class="form-label small fw-semibold d-flex justify-content-between">
<span>Notify for events</span>
<span>
<a href="#" class="small text-decoration-none"
onclick="setAllEvents(true);return false;">All</a> /
<a href="#" class="small text-decoration-none"
onclick="setAllEvents(false);return false;">None</a>
</span>
</label>
<div class="row row-cols-1 row-cols-lg-2 g-1"
style="max-height:180px;overflow-y:auto;">
{% for ekey, elabel in matrix_events.items() %}
<div class="col">
<div class="form-check">
<input class="form-check-input notify-event-cb" type="checkbox"
name="event_types" value="{{ ekey }}"
id="ev_{{ ekey }}">
<label class="form-check-label small" for="ev_{{ ekey }}">
{{ elabel }}
</label>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="mt-3 text-end">
<button type="submit" class="btn btn-sm btn-success">
<i class="bi bi-check-lg"></i> Save Recipient
</button>
</div>
</form>
</div>
{# Existing recipients #}
<div class="card-body p-0">
{% if notify_recipients %}
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Recipient</th>
<th>Type</th>
<th>Notified Events</th>
<th width="80"></th>
</tr>
</thead>
<tbody>
{% for r in notify_recipients %}
<tr>
<td>
{% if r.user_id %}
<strong>{{ r.user.display_name if r.user else '—' }}</strong>
<div class="text-muted small">{{ r.user.email if r.user else '' }}</div>
{% else %}
<strong>{{ r.email }}</strong>
{% endif %}
</td>
<td>
{% if r.user_id %}
<span class="badge bg-primary">Staff · in-app + email</span>
{% else %}
<span class="badge bg-secondary">External · email</span>
{% endif %}
</td>
<td>
{% set ev = r.get_event_types() %}
{% if ev %}
<div class="d-flex flex-wrap gap-1">
{% for e in ev %}
<span class="badge bg-light text-dark border">
{{ matrix_events.get(e, e) }}
</span>
{% endfor %}
</div>
{% else %}
<span class="text-muted small">None</span>
{% endif %}
</td>
<td>
<form method="POST"
action="{{ url_for('projects.remove_notify_recipient', recipient_id=r.id) }}"
onsubmit="return confirm('Remove this notification 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. These are notified — for the events you pick —
on top of the global
<a href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>.
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
{% if current_user.role == 'admin' %}
<script>
function toggleRecipientType() {
var isUser = document.getElementById('rtypeUser').checked;
document.getElementById('recipientUserWrap').classList.toggle('d-none', !isUser);
document.getElementById('recipientEmailWrap').classList.toggle('d-none', isUser);
}
function setAllEvents(state) {
document.querySelectorAll('.notify-event-cb').forEach(function (cb) { cb.checked = state; });
}
</script>
{% endif %}
{% endblock %}
+136 -3
View File
@@ -598,17 +598,150 @@ def notify_by_matrix(
)
notified.add(user.id)
# ── Custom email recipients ───────────────────────────────────────────
# ── Custom email recipients (global, per-event) ───────────────────────
custom_emails = get_custom_emails_for(event_type)
sent_emails = set() # dedupe email sends across global + per-contract
for email in custom_emails:
norm = (email or '').strip().lower()
if not norm or norm in sent_emails:
continue
_send_custom_email(email, title, body, link)
sent_emails.add(norm)
# ── Per-contract additional recipients ────────────────────────────────
# Extra users/emails attached to the contract that owns this event's
# facility. Fires in ADDITION to the global matrix routing above.
contract_count = _notify_contract_recipients(
event_type = event_type,
title = title,
body = body,
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
facility_id = facility_id,
exclude = exclude,
notified = notified,
sent_emails = sent_emails,
)
logger.info(
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
event_type, len(notified), len(custom_emails),
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s | contract_extra=%s',
event_type, len(notified), len(sent_emails), contract_count,
)
def _resolve_project_id(facility_id=None, issue_id=None, inspection_id=None):
"""Best-effort resolution of the owning contract (project) id for an event.
Tries facility_id first, then the issue's facility, then the inspection's
facility. Returns None if no contract can be determined.
"""
from app.models.facility import Facility
fid = facility_id
try:
if fid is None and issue_id:
from app.models.issue import Issue
iss = db.session.get(Issue, issue_id)
if iss is not None:
fid = iss.facility_id
if fid is None and iss.area is not None:
fid = iss.area.facility_id
if fid is None and inspection_id:
from app.models.inspection import Inspection
insp = db.session.get(Inspection, inspection_id)
if insp is not None:
fid = insp.facility_id
if fid is None:
return None
facility = db.session.get(Facility, fid)
return facility.project_id if facility is not None else None
except Exception as exc:
logger.error('CONTRACT NOTIFY | project resolution failed | error=%s', exc)
return None
def _notify_contract_recipients(
event_type, title, body, link=None,
issue_id=None, inspection_id=None, facility_id=None,
exclude=None, notified=None, sent_emails=None,
):
"""Notify a contract's additional recipients for this event.
Deduplicates against the users already notified by the matrix (`notified`),
the excluded actor set (`exclude`), and emails already sent (`sent_emails`).
Best-effort: never raises into the caller.
Returns the number of extra recipients notified (users + emails).
"""
exclude = exclude if exclude is not None else set()
notified = notified if notified is not None else set()
sent_emails = sent_emails if sent_emails is not None else set()
try:
from app.models.notification_recipient import ContractNotificationRecipient
from app.models.user import User
project_id = _resolve_project_id(facility_id, issue_id, inspection_id)
if project_id is None:
return 0
rows = ContractNotificationRecipient.query.filter_by(project_id=project_id).all()
count = 0
for r in rows:
# Event subscription check
if event_type not in r.get_event_types():
continue
# ── Existing-user recipient → in-app + email ──
if r.user_id:
if r.user_id in exclude or r.user_id in notified:
continue
user = db.session.get(User, r.user_id)
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 authoritative
)
notified.add(user.id)
count += 1
logger.info(
'CONTRACT NOTIFY | project=%s | event=%s | user=%s',
project_id, event_type, user.username,
)
# ── Free-form email recipient → email only ──
elif r.email:
norm = r.email.strip().lower()
if not norm or norm in sent_emails:
continue
_send_custom_email(r.email, title, body, link)
sent_emails.add(norm)
count += 1
logger.info(
'CONTRACT NOTIFY | project=%s | event=%s | email=%s',
project_id, event_type, r.email,
)
return count
except Exception as exc:
logger.error(
'CONTRACT NOTIFY | unexpected error | event=%s | error=%s',
event_type, exc,
)
return 0
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: