July 7 - Implement notification per contract
This commit is contained in:
@@ -338,6 +338,23 @@ broadcasts: id, title VARCHAR(255), body TEXT, target_roles (JSON list of role s
|
|||||||
|
|
||||||
Admin-authored broadcast messages. Sending a broadcast fans out one `Notification` row per targeted user; the iPad picks them up through its existing `GET /api/v1/notifications?since=...` poll — **no dedicated broadcast API endpoint exists**. `recipient_count` snapshots how many notifications were created. Managed at `/admin/broadcast` (see §7 `broadcast` blueprint).
|
Admin-authored broadcast messages. Sending a broadcast fans out one `Notification` row per targeted user; the iPad picks them up through its existing `GET /api/v1/notifications?since=...` poll — **no dedicated broadcast API endpoint exists**. `recipient_count` snapshots how many notifications were created. Managed at `/admin/broadcast` (see §7 `broadcast` blueprint).
|
||||||
|
|
||||||
|
### ContractNotificationRecipient
|
||||||
|
|
||||||
|
```
|
||||||
|
contract_notification_recipients:
|
||||||
|
id, project_id (FK→projects CASCADE, indexed),
|
||||||
|
user_id (FK→users CASCADE, nullable, indexed), -- staff-user recipient
|
||||||
|
email VARCHAR(200) nullable, -- external email recipient
|
||||||
|
event_types TEXT (JSON list of event_type keys), created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
**Per-contract additional notification recipients.** Each row is ONE extra recipient attached to a Contract who is notified — for the `event_types` they subscribe to — whenever those events fire within that contract's facilities, **in addition to** the global `NotificationMatrix` routing. Exactly one of `user_id` / `email` is set (enforced in the route, not the DB):
|
||||||
|
|
||||||
|
- `user_id` set → existing staff user → **in-app notification + email**
|
||||||
|
- `email` set → free-form external address → **email only**
|
||||||
|
|
||||||
|
`event_types` is a JSON list of `MATRIX_EVENTS` keys. A recipient fires only when the event is in its list. Managed admin-only on the **Contract detail page** (`/projects/<id>`) via `add_notify_recipient` / `remove_notify_recipient`. Dispatch is resolved centrally in `notify_by_matrix()` — see §11.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Role & Permission Matrix
|
## 6. Role & Permission Matrix
|
||||||
@@ -381,7 +398,7 @@ Admin-authored broadcast messages. Sending a broadcast fans out one `Notificatio
|
|||||||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||||||
| `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 + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
|
||||||
| `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 |
|
||||||
@@ -589,6 +606,16 @@ EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
|
|||||||
EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
|
EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Per-Contract Additional Recipients (Phase 33)
|
||||||
|
|
||||||
|
`notify_by_matrix()` is the single dispatch point for all broadcast events. After routing to the global matrix roles + global custom emails, it calls `_notify_contract_recipients()`, which:
|
||||||
|
|
||||||
|
1. Resolves the owning contract via `_resolve_project_id(facility_id, issue_id, inspection_id)` — tries `facility_id`, then the issue's facility (or `issue.area.facility_id`), then the inspection's facility.
|
||||||
|
2. Loads `ContractNotificationRecipient` rows for that project and notifies each one whose `event_types` contains the firing event.
|
||||||
|
3. **Deduplicates** against users already notified this dispatch (shared `notified` set) and emails already sent (shared `sent_emails` set), so a user who is both a matrix role AND a contract recipient gets exactly one notification.
|
||||||
|
|
||||||
|
Contract recipients fire **regardless of matrix role toggles** — they are additive, not gated by the matrix. Staff-user recipients use `respect_preferences=False` (contract config is authoritative, mirroring matrix broadcasts). Commit is the **caller's** responsibility, same as the rest of `notify_by_matrix()`.
|
||||||
|
|
||||||
### Cron Endpoints (all require `token=DIGEST_SECRET`)
|
### Cron Endpoints (all require `token=DIGEST_SECRET`)
|
||||||
|
|
||||||
| Endpoint | Purpose | Schedule |
|
| Endpoint | Purpose | Schedule |
|
||||||
@@ -673,7 +700,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
|
|||||||
→ phase29_broadcasts
|
→ phase29_broadcasts
|
||||||
→ phase30_device_registry
|
→ phase30_device_registry
|
||||||
→ phase31_device_registry
|
→ phase31_device_registry
|
||||||
→ phase32_device_token_columns ← HEAD
|
→ phase32_device_token_columns
|
||||||
|
→ phase33_contract_notify_recipients ← HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
### phase21_performance_indexes
|
### phase21_performance_indexes
|
||||||
@@ -748,6 +776,16 @@ These three migrations are the history of a **false start** in device tracking.
|
|||||||
|
|
||||||
**The dead `DeviceRegistration` model, `app/api/devices.py` endpoint, and `api_devices` blueprint were removed (July 2026).** They defined a *second* `POST /api/v1/devices/register` that was shadowed at routing time by the `api_auth` copy and would have crashed anyway (it queried the dropped `device_registrations` table). Device registration now has a single implementation: `register_device()` in `app/api/auth.py`, writing to `api_device_tokens`. Do not reintroduce a competing device model or a duplicate register route.
|
**The dead `DeviceRegistration` model, `app/api/devices.py` endpoint, and `api_devices` blueprint were removed (July 2026).** They defined a *second* `POST /api/v1/devices/register` that was shadowed at routing time by the `api_auth` copy and would have crashed anyway (it queried the dropped `device_registrations` table). Device registration now has a single implementation: `register_device()` in `app/api/auth.py`, writing to `api_device_tokens`. Do not reintroduce a competing device model or a duplicate register route.
|
||||||
|
|
||||||
|
### phase33_contract_notify_recipients
|
||||||
|
|
||||||
|
Creates the `contract_notification_recipients` table backing **per-contract additional notification recipients** (see §5 `ContractNotificationRecipient` and §11). Uses table existence check — safe to re-run.
|
||||||
|
|
||||||
|
**Deploy order:**
|
||||||
|
```bash
|
||||||
|
flask db upgrade
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
**Deploy order for phases 24–32:**
|
**Deploy order for phases 24–32:**
|
||||||
```bash
|
```bash
|
||||||
flask db upgrade
|
flask db upgrade
|
||||||
@@ -1074,6 +1112,7 @@ timeout = 30
|
|||||||
| 70 | **`notify()` does NOT commit — caller must `db.session.commit()` after all `notify()` calls** | `notify()` adds a `Notification` row to the session but leaves the commit to the caller. The support helpers (`_notify_admins_new_ticket`, `_notify_customer_reply`, `_notify_admins_customer_reply`) each call `db.session.commit()` after the `notify()` loop. |
|
| 70 | **`notify()` does NOT commit — caller must `db.session.commit()` after all `notify()` calls** | `notify()` adds a `Notification` row to the session but leaves the commit to the caller. The support helpers (`_notify_admins_new_ticket`, `_notify_customer_reply`, `_notify_admins_customer_reply`) each call `db.session.commit()` after the `notify()` loop. |
|
||||||
| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. |
|
| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. |
|
||||||
| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. |
|
| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. |
|
||||||
|
| 73 | **Per-contract recipients are dispatched ONLY inside `notify_by_matrix()` — never add a parallel path** | `_notify_contract_recipients()` runs after role + global-custom-email routing and shares the `notified` / `sent_emails` dedup sets. Any new event that should reach contract recipients must go through `notify_by_matrix()` (passing `facility_id`, or an `issue_id`/`inspection_id` that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ from app.models.issue import Issue
|
|||||||
from app.models.project import Project, CustomerAssignment
|
from app.models.project import Project, CustomerAssignment
|
||||||
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.notification_recipient import ContractNotificationRecipient
|
||||||
@@ -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}>'
|
||||||
@@ -83,11 +83,37 @@ def view(project_id):
|
|||||||
.order_by(User.username)
|
.order_by(User.username)
|
||||||
.all()
|
.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(
|
return render_template(
|
||||||
'projects/view.html',
|
'projects/view.html',
|
||||||
project=project,
|
project=project,
|
||||||
facilities=facilities,
|
facilities=facilities,
|
||||||
assignments=assignments,
|
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))
|
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 ─────────────────────────────────────
|
# ── Bulk Import — Excel template download ─────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/import/template')
|
@bp.route('/import/template')
|
||||||
|
|||||||
@@ -165,5 +165,180 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
+136
-3
@@ -598,17 +598,150 @@ def notify_by_matrix(
|
|||||||
)
|
)
|
||||||
notified.add(user.id)
|
notified.add(user.id)
|
||||||
|
|
||||||
# ── Custom email recipients ───────────────────────────────────────────
|
# ── Custom email recipients (global, per-event) ───────────────────────
|
||||||
custom_emails = get_custom_emails_for(event_type)
|
custom_emails = get_custom_emails_for(event_type)
|
||||||
|
sent_emails = set() # dedupe email sends across global + per-contract
|
||||||
for email in custom_emails:
|
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)
|
_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(
|
logger.info(
|
||||||
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
|
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s | contract_extra=%s',
|
||||||
event_type, len(notified), len(custom_emails),
|
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):
|
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:
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""phase33 — contract_notification_recipients table
|
||||||
|
|
||||||
|
Per-contract additional notification recipients (users or free-form emails)
|
||||||
|
with a per-recipient event_types subscription list. Notified in addition to
|
||||||
|
the global NotificationMatrix routing, scoped to the contract's facilities.
|
||||||
|
|
||||||
|
Uses INFORMATION_SCHEMA table-existence check — safe to re-run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = 'phase33_contract_notify_recipients'
|
||||||
|
down_revision = 'phase32_device_token_columns'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, table):
|
||||||
|
result = conn.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 _table_exists(bind, 'contract_notification_recipients'):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'contract_notification_recipients',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('project_id', sa.Integer,
|
||||||
|
sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer,
|
||||||
|
sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=True),
|
||||||
|
sa.Column('email', sa.String(200), nullable=True),
|
||||||
|
sa.Column('event_types', sa.Text, nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index('ix_cnr_project_id',
|
||||||
|
'contract_notification_recipients', ['project_id'])
|
||||||
|
op.create_index('ix_cnr_user_id',
|
||||||
|
'contract_notification_recipients', ['user_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _table_exists(bind, 'contract_notification_recipients'):
|
||||||
|
op.drop_table('contract_notification_recipients')
|
||||||
Reference in New Issue
Block a user