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
+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')