Jul 7 - Implement additional recipient per contract
This commit is contained in:
@@ -296,8 +296,13 @@ inspector_assignments: id, user_id, project_id, created_at
|
|||||||
UniqueConstraint(user_id, project_id, name='uq_inspector_project')
|
UniqueConstraint(user_id, project_id, name='uq_inspector_project')
|
||||||
ForeignKey user_id → users(id) ON DELETE CASCADE
|
ForeignKey user_id → users(id) ON DELETE CASCADE
|
||||||
ForeignKey project_id → projects(id) ON DELETE CASCADE
|
ForeignKey project_id → projects(id) ON DELETE CASCADE
|
||||||
|
project_notification_recipients: id, project_id (FK→projects CASCADE), ← phase37
|
||||||
|
user_id (FK→users CASCADE, nullable), email VARCHAR(255) nullable,
|
||||||
|
events TEXT (JSON list of MATRIX_EVENTS keys), created_at
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`project_notification_recipients` (phase37):** Per-contract additional notification recipients, layered on top of the global notification matrix. Exactly one of `user_id` (staff → in-app + email via `notify()`) / `email` (external → email only) is set — enforced in the route layer, not by a DB constraint. `notify_by_matrix()` calls `_notify_project_recipients()` AFTER the matrix roles and global custom emails: it resolves the contract via `facility_id` arg → `issue.resolved_facility` → `inspection.facility_id`, then notifies every recipient of that contract subscribed to the event. Deduplicated against matrix-role notifications (user IDs) and global custom emails (lowercased). Managed at `/projects/<id>/recipients` (`@supervisor_required`); re-adding an existing recipient replaces its event list (upsert).
|
||||||
|
|
||||||
### Inspection
|
### Inspection
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -494,7 +499,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
|
|||||||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix`, `/mfa` (login 2FA challenge), `/mfa/setup` + `/mfa/disable` (phase35, `@supervisor_required` enroll/disable) |
|
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix`, `/mfa` (login 2FA challenge), `/mfa/setup` + `/mfa/disable` (phase35, `@supervisor_required` enroll/disable) |
|
||||||
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||||||
| `facilities` | `/facilities` | CRUD + area management |
|
| `facilities` | `/facilities` | CRUD + area management |
|
||||||
| `projects` | `/projects` | CRUD + customer assignment management |
|
| `projects` | `/projects` | CRUD + customer assignment management + per-contract notification recipients (`GET /<id>/recipients`, `POST /<id>/recipients/add`, `POST /recipients/<rid>/remove` — `@supervisor_required`, phase37) |
|
||||||
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
||||||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
||||||
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
||||||
@@ -776,7 +781,7 @@ limiter = Limiter(
|
|||||||
|
|
||||||
## 17. Alembic Migration Chain
|
## 17. Alembic Migration Chain
|
||||||
|
|
||||||
**Current HEAD:** `phase36_issue_work_orders` (34 migrations total).
|
**Current HEAD:** `phase37_project_notification_recipients` (35 migrations total).
|
||||||
|
|
||||||
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
||||||
|
|
||||||
@@ -807,7 +812,18 @@ limiter = Limiter(
|
|||||||
→ phase33_tenant_settings
|
→ phase33_tenant_settings
|
||||||
→ phase34_inspection_schedules
|
→ phase34_inspection_schedules
|
||||||
→ phase35_user_mfa
|
→ phase35_user_mfa
|
||||||
→ phase36_issue_work_orders ← HEAD
|
→ phase36_issue_work_orders
|
||||||
|
→ phase37_project_notification_recipients ← HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
### phase37_project_notification_recipients
|
||||||
|
|
||||||
|
Creates the `project_notification_recipients` table backing per-contract additional notification recipients (see §5 model + the `/projects/<id>/recipients` routes). Each row subscribes one recipient — a staff User (in-app + email) or an external email address (email only) — to a chosen set of notification-matrix event types, scoped to events occurring in that contract's facilities. Dispatched by `notify_by_matrix()` → `_notify_project_recipients()`. Guarded by an `INFORMATION_SCHEMA` table-existence check — safe to re-run.
|
||||||
|
|
||||||
|
**Deploy order:**
|
||||||
|
```bash
|
||||||
|
flask db upgrade
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
```
|
```
|
||||||
|
|
||||||
### phase36_issue_work_orders
|
### phase36_issue_work_orders
|
||||||
@@ -1317,6 +1333,7 @@ set -a; . /etc/jqc/control.env; set +a
|
|||||||
| 87 | **MFA is opt-in, TOTP-based, with hashed one-time recovery codes** | `app/utils/mfa.py` (data plane) and `control/mfa.py` (panel) are pure-logic mirrors — keep them in sync (same rule class as `time_utils`). The login challenge (`/auth/mfa`, panel `/mfa`) fires for ANY account with `mfa_enabled=1`; `login_user()`/`session['sa_id']` is deferred until the code passes. Recovery codes are stored ONLY as werkzeug hashes and are single-use (consumed on match). Disable requires a current TOTP code OR the password. **Lock-out escape hatch:** because MFA is per-account opt-in, the recovery path is the primary unlock; the operational last resort is a DB update `UPDATE users SET mfa_enabled=0, mfa_secret=NULL, mfa_recovery_codes=NULL WHERE username=...` (or the same on `superadmins`). Do not store `mfa_secret`/recovery codes in plaintext, and do not skip the deferred-login pattern. |
|
| 87 | **MFA is opt-in, TOTP-based, with hashed one-time recovery codes** | `app/utils/mfa.py` (data plane) and `control/mfa.py` (panel) are pure-logic mirrors — keep them in sync (same rule class as `time_utils`). The login challenge (`/auth/mfa`, panel `/mfa`) fires for ANY account with `mfa_enabled=1`; `login_user()`/`session['sa_id']` is deferred until the code passes. Recovery codes are stored ONLY as werkzeug hashes and are single-use (consumed on match). Disable requires a current TOTP code OR the password. **Lock-out escape hatch:** because MFA is per-account opt-in, the recovery path is the primary unlock; the operational last resort is a DB update `UPDATE users SET mfa_enabled=0, mfa_secret=NULL, mfa_recovery_codes=NULL WHERE username=...` (or the same on `superadmins`). Do not store `mfa_secret`/recovery codes in plaintext, and do not skip the deferred-login pattern. |
|
||||||
| 89 | **Vendor work-order pages are public and token-authorized — the token IS the credential** | `GET/POST /work-orders/<token>` have NO `@login_required`; the unguessable `secrets.token_urlsafe(32)` token is the sole authorization, so never render one in any staff-visible page, log line, or list except in the contractor's own emailed link. Rate-limited (`60/hr` view, `20/hr` update). The public page shows only scoped issue details (facility, area, description, severity, staff message) — never internal notes/comments/assignees. State transitions are one-way and guarded (`sent→acknowledged→completed`); a completed order ignores further actions. Completing an order sets the parent issue to `pending_verification` (staff still sign off — the vendor cannot self-resolve). In MT mode the link resolves to the right tenant by Host, so the route is NOT tenant-exempt. |
|
| 89 | **Vendor work-order pages are public and token-authorized — the token IS the credential** | `GET/POST /work-orders/<token>` have NO `@login_required`; the unguessable `secrets.token_urlsafe(32)` token is the sole authorization, so never render one in any staff-visible page, log line, or list except in the contractor's own emailed link. Rate-limited (`60/hr` view, `20/hr` update). The public page shows only scoped issue details (facility, area, description, severity, staff message) — never internal notes/comments/assignees. State transitions are one-way and guarded (`sent→acknowledged→completed`); a completed order ignores further actions. Completing an order sets the parent issue to `pending_verification` (staff still sign off — the vendor cannot self-resolve). In MT mode the link resolves to the right tenant by Host, so the route is NOT tenant-exempt. |
|
||||||
| 88 | **Password strength enforced by one shared `strong_password()` validator** | Lives in `app/utils/forms.py`: ≥8 chars, at least one letter AND one digit, and not in a small common-password blocklist. Applied to every password-setting form — `ProfileForm`, `UserForm`, `CustomerForm`, `ResetPasswordForm`, `SetPasswordForm`, and `signup.SignupForm` (imports it). Sits after `Optional()` on edit forms (skips blank = "leave unchanged"). Do not re-introduce ad-hoc `Length(min=6)` password rules — route new password fields through `strong_password()` so the policy stays consistent. |
|
| 88 | **Password strength enforced by one shared `strong_password()` validator** | Lives in `app/utils/forms.py`: ≥8 chars, at least one letter AND one digit, and not in a small common-password blocklist. Applied to every password-setting form — `ProfileForm`, `UserForm`, `CustomerForm`, `ResetPasswordForm`, `SetPasswordForm`, and `signup.SignupForm` (imports it). Sits after `Optional()` on edit forms (skips blank = "leave unchanged"). Do not re-introduce ad-hoc `Length(min=6)` password rules — route new password fields through `strong_password()` so the policy stays consistent. |
|
||||||
|
| 90 | **Per-contract recipients dispatch INSIDE `notify_by_matrix()` — never call `_notify_project_recipients()` from routes** | phase37. Contract-scoped recipients (`ProjectNotificationRecipient`) are dispatched automatically at the end of `notify_by_matrix()`, after matrix roles + global custom emails, with dedup against both. The contract is resolved from `facility_id` arg → `issue.resolved_facility` → `inspection.facility_id`; events fired without any facility context reach matrix recipients only. New `notify_by_matrix()` call sites should pass `facility_id` (or `issue_id`/`inspection_id`) so contract recipients fire. The `score_alert` cron call in `sla.py` now passes `facility_id=fid` for this reason (side effect: if the matrix ever enables `customer` for `score_alert`, customers are facility-scoped instead of org-wide — a strict improvement). Staff recipients use `respect_preferences=False` (contract config is the authority, same as matrix broadcasts). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1674,7 +1691,7 @@ Ask: Does this change break any other code path that uses the modified function,
|
|||||||
**Rule 13 — List every file changed** with the exact location of each change (function name and what was modified).
|
**Rule 13 — List every file changed** with the exact location of each change (function name and what was modified).
|
||||||
|
|
||||||
**Rule 14 — Migrations are required for any schema change.**
|
**Rule 14 — Migrations are required for any schema change.**
|
||||||
Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase36_issue_work_orders`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL.
|
Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase37_project_notification_recipients`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL.
|
||||||
|
|
||||||
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.
|
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
|||||||
Inspection, InspectionResult)
|
Inspection, InspectionResult)
|
||||||
from app.models.issue import Issue
|
from app.models.issue import Issue
|
||||||
from app.models.project import Project, CustomerAssignment
|
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.api_token import RefreshToken, DeviceToken
|
||||||
from app.models.notification_matrix import NotificationMatrix
|
from app.models.notification_matrix import NotificationMatrix
|
||||||
from app.models.inspection_schedule import InspectionSchedule
|
from app.models.inspection_schedule import InspectionSchedule
|
||||||
|
|||||||
@@ -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}>'
|
||||||
@@ -236,6 +236,150 @@ def remove_assignment(assignment_id):
|
|||||||
return redirect(url_for('projects.view', project_id=project_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 ─────────────────────────────────────
|
# ── Bulk Import — Excel template download ─────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/import/template')
|
@bp.route('/import/template')
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-auto d-flex gap-2">
|
<div class="col-auto d-flex gap-2">
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% 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">
|
<a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-pencil"></i> Edit
|
<i class="bi bi-pencil"></i> Edit
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -603,12 +603,140 @@ def notify_by_matrix(
|
|||||||
for email in custom_emails:
|
for email in custom_emails:
|
||||||
_send_custom_email(email, title, body, link)
|
_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(
|
logger.info(
|
||||||
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
|
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
|
||||||
event_type, len(notified), len(custom_emails),
|
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):
|
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."""
|
"""Send a plain email to a custom (non-user) address. Best-effort."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
+5
-4
@@ -319,10 +319,11 @@ def send_score_alerts(threshold=SCORE_DROP_THRESHOLD):
|
|||||||
link = f'/reports/facility/{fid}/scorecard'
|
link = f'/reports/facility/{fid}/scorecard'
|
||||||
|
|
||||||
notify_by_matrix(
|
notify_by_matrix(
|
||||||
event_type = 'score_alert',
|
event_type = 'score_alert',
|
||||||
title = title,
|
title = title,
|
||||||
body = body,
|
body = body,
|
||||||
link = link,
|
link = link,
|
||||||
|
facility_id = fid,
|
||||||
)
|
)
|
||||||
total_sent += 1
|
total_sent += 1
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""phase37 — per-contract additional notification recipients
|
||||||
|
|
||||||
|
Creates the project_notification_recipients table. Each row subscribes one
|
||||||
|
recipient — either a staff User (in-app + email) or an external email address
|
||||||
|
(email only) — to a chosen set of notification-matrix event types, scoped to
|
||||||
|
events occurring in facilities that belong to that contract (Project).
|
||||||
|
|
||||||
|
Dispatched by notify_by_matrix() → _notify_project_recipients() in
|
||||||
|
app/utils/notifications.py, AFTER the global matrix roles and custom emails.
|
||||||
|
|
||||||
|
Idempotent: guarded by an INFORMATION_SCHEMA table-existence check so it is
|
||||||
|
safe to re-run across every tenant DB (CLAUDE.md rule 14).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = 'phase37_project_notification_recipients'
|
||||||
|
down_revision = 'phase36_issue_work_orders'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(bind, table: str) -> bool:
|
||||||
|
result = bind.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = :t"
|
||||||
|
), {'t': table})
|
||||||
|
return result.scalar() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _table_exists(bind, 'project_notification_recipients'):
|
||||||
|
op.execute(sa.text("""
|
||||||
|
CREATE TABLE project_notification_recipients (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
project_id INT NOT NULL,
|
||||||
|
user_id INT NULL,
|
||||||
|
email VARCHAR(255) NULL,
|
||||||
|
events TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
CONSTRAINT fk_pnr_project FOREIGN KEY (project_id)
|
||||||
|
REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_pnr_user FOREIGN KEY (user_id)
|
||||||
|
REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX ix_pnr_project (project_id),
|
||||||
|
INDEX ix_pnr_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _table_exists(bind, 'project_notification_recipients'):
|
||||||
|
op.execute(sa.text('DROP TABLE project_notification_recipients'))
|
||||||
Reference in New Issue
Block a user