diff --git a/CLAUDE.md b/CLAUDE.md index e7fa6a5..703a8f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -296,8 +296,13 @@ inspector_assignments: id, user_id, project_id, created_at UniqueConstraint(user_id, project_id, name='uq_inspector_project') ForeignKey user_id → users(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//recipients` (`@supervisor_required`); re-adding an existing recipient replaces its event list (upsert). + ### 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) | | `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) | | `facilities` | `/facilities` | CRUD + area management | -| `projects` | `/projects` | CRUD + customer assignment management | +| `projects` | `/projects` | CRUD + customer assignment management + per-contract notification recipients (`GET //recipients`, `POST //recipients/add`, `POST /recipients//remove` — `@supervisor_required`, phase37) | | `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) | | `templates` | `/templates` | list, create, edit, delete, form editor, preview | @@ -776,7 +781,7 @@ limiter = Limiter( ## 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`. @@ -807,7 +812,18 @@ limiter = Limiter( → phase33_tenant_settings → phase34_inspection_schedules → 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//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 @@ -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. | | 89 | **Vendor work-order pages are public and token-authorized — the token IS the credential** | `GET/POST /work-orders/` 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. | +| 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 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/`. diff --git a/app/models/__init__.py b/app/models/__init__.py index d685e78..18a5722 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -4,6 +4,7 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem, Inspection, InspectionResult) from app.models.issue import Issue from app.models.project import Project, CustomerAssignment +from app.models.project_recipient import ProjectNotificationRecipient from app.models.api_token import RefreshToken, DeviceToken from app.models.notification_matrix import NotificationMatrix from app.models.inspection_schedule import InspectionSchedule diff --git a/app/models/project_recipient.py b/app/models/project_recipient.py new file mode 100644 index 0000000..27879c6 --- /dev/null +++ b/app/models/project_recipient.py @@ -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'' diff --git a/app/routes/projects.py b/app/routes/projects.py index c32b463..0591035 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -236,6 +236,150 @@ def remove_assignment(assignment_id): return redirect(url_for('projects.view', project_id=project_id)) +# ── Notification Recipients — per-contract (phase37) ───────────────────────── + +@bp.route('//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('//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//remove', methods=['POST']) +@login_required +@supervisor_required +def remove_recipient(recipient_id): + """Remove a contract notification recipient.""" + from app.models.project_recipient import ProjectNotificationRecipient + + row = db.session.get(ProjectNotificationRecipient, recipient_id) + if row is None: + abort(404) + project = db.session.get(Project, row.project_id) + if project is None: + abort(404) + + label = row.user.display_name if row.user else (row.email or f'id={row.id}') + project_id_snap = row.project_id + row_id_snap = row.id + + db.session.delete(row) + db.session.commit() + logger.info('PROJECTS | recipient_remove | user=%s project_id=%s recipient=%s', + current_user.username, project_id_snap, label) + log_action(ACTION_DELETE, 'ProjectNotificationRecipient', row_id_snap, + f'{label} → {project.name}') + flash(f'Recipient "{label}" removed.', 'success') + return redirect(url_for('projects.recipients', project_id=project_id_snap)) + + # ── Bulk Import — Excel template download ───────────────────────────────────── @bp.route('/import/template') diff --git a/app/templates/projects/recipients.html b/app/templates/projects/recipients.html new file mode 100644 index 0000000..29e3fc4 --- /dev/null +++ b/app/templates/projects/recipients.html @@ -0,0 +1,183 @@ +{% extends "base.html" %} +{% block title %}Notification Recipients — {{ project.name }}{% endblock %} + +{% block content %} +
+
+

Notification Recipients

+

+ Additional recipients notified for events in + {{ project.name }} facilities — on top of the + global {% if current_user.role == 'admin' %}notification matrix{% else %}notification matrix (admin-managed){% endif %}. +

+
+ +
+ +{# ── Recipient list ── #} +
+
+ + Additional Notification Recipients + {{ recipients|length }} + + +
+ + {# ── Add / update form (collapsed) ── #} +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+ {% for key, label in matrix_events.items() %} +
+
+ + +
+
+ {% endfor %} +
+
+ +
+ + +
+
+ Adding an existing recipient again replaces their event list. + Staff users receive in-app + email notifications; external + addresses receive email only. +
+
+
+ +
+ {% if recipients %} +
+ + + + + + + + + + + {% for r in recipients %} + + + + + + + {% endfor %} + +
RecipientTypeNotified Events
+ {% if r.is_staff %} + {{ r.user.display_name }} +
{{ r.user.email or 'no email on file' }}
+ {% else %} + {{ r.email }} + {% endif %} +
+ {% if r.is_staff %} + Staff · in-app + email + {% else %} + External · email + {% endif %} + +
+ {% for ev in r.get_events() %} + {{ matrix_events.get(ev, ev) }} + {% endfor %} +
+
+
+ + +
+
+
+ {% else %} +
+ No additional recipients configured for this contract yet. + Events fall through to the global notification matrix only. +
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/app/templates/projects/view.html b/app/templates/projects/view.html index 097f0a4..fcfdf66 100644 --- a/app/templates/projects/view.html +++ b/app/templates/projects/view.html @@ -16,6 +16,9 @@
{% if current_user.role in ['admin', 'director'] %} + + Notification Recipients + Edit diff --git a/app/utils/notifications.py b/app/utils/notifications.py index f5d5e73..00d9b6e 100644 --- a/app/utils/notifications.py +++ b/app/utils/notifications.py @@ -603,12 +603,140 @@ def notify_by_matrix( for email in custom_emails: _send_custom_email(email, title, body, link) + # ── Per-contract additional recipients (phase37) ────────────────────── + _notify_project_recipients( + event_type = event_type, + title = title, + body = body, + link = link, + issue_id = issue_id, + inspection_id = inspection_id, + facility_id = facility_id, + exclude_user_ids = exclude, + already_notified = notified, + already_emailed = {e.strip().lower() for e in custom_emails}, + ) + logger.info( 'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s', event_type, len(notified), len(custom_emails), ) +def _notify_project_recipients( + event_type: str, + title: str, + body: str, + link: str = None, + issue_id: int = None, + inspection_id: int = None, + facility_id: int = None, + exclude_user_ids: set = None, + already_notified: set = None, + already_emailed: set = None, +): + """ + Dispatch to per-contract additional recipients (ProjectNotificationRecipient). + + Called from notify_by_matrix() AFTER the global matrix roles and custom + emails. Resolves the contract via the event's facility: + facility_id arg → else issue.resolved_facility → else inspection.facility_id + then notifies every recipient of that facility's contract whose subscribed + event list contains event_type. + + Staff recipients (user_id set) get an in-app Notification + email via + notify() with respect_preferences=False (matrix-authority mode, same as + role broadcasts). External recipients (email set) get a plain email only. + + Deduplication: `already_notified` (user IDs notified by the matrix roles) + and `already_emailed` (lowercased global custom emails) are honoured and + mutated in place so a recipient is never contacted twice per event. + + Best-effort: any failure is logged and never propagates to the caller. + """ + exclude = set(exclude_user_ids or []) + notified = already_notified if already_notified is not None else set() + emailed = already_emailed if already_emailed is not None else set() + + try: + from app.models.project_recipient import ProjectNotificationRecipient + from app.models.facility import Facility + + # Resolve the facility this event occurred at + fid = facility_id + if fid is None and issue_id: + from app.models.issue import Issue + issue = db.session.get(Issue, issue_id) + if issue: + fac = issue.resolved_facility + fid = fac.id if fac else None + if fid is None and inspection_id: + from app.models.inspection import Inspection + insp = db.session.get(Inspection, inspection_id) + fid = insp.facility_id if insp else None + if fid is None: + return # no facility context — contract cannot be determined + + facility = db.session.get(Facility, fid) + if not facility or not facility.project_id: + return # facility unknown or not linked to a contract + + recipients = ProjectNotificationRecipient.query.filter_by( + project_id=facility.project_id + ).all() + + sent = 0 + for r in recipients: + if event_type not in r.get_events(): + continue + + if r.user_id: + if r.user_id in exclude or r.user_id in notified: + continue + user = r.user + if not user or not user.active: + continue + notify( + recipient = user, + title = title, + body = body, + link = link, + issue_id = issue_id, + inspection_id = inspection_id, + event_type = event_type, + send_email = True, + respect_preferences = False, # contract config is the authority + ) + notified.add(user.id) + sent += 1 + logger.info( + 'CONTRACT NOTIFY | project_id=%s | event=%s | user=%s', + facility.project_id, event_type, user.username, + ) + elif r.email: + key = r.email.strip().lower() + if not key or key in emailed: + continue + _send_custom_email(r.email.strip(), title, body, link) + emailed.add(key) + sent += 1 + logger.info( + 'CONTRACT NOTIFY | project_id=%s | event=%s | email=%s', + facility.project_id, event_type, key, + ) + + if sent: + logger.info( + 'CONTRACT NOTIFY DONE | project_id=%s | event=%s | sent=%s', + facility.project_id, event_type, sent, + ) + except Exception as exc: + logger.error( + 'CONTRACT NOTIFY FAILED | event=%s | facility_id=%s | error=%s', + event_type, facility_id, exc, + ) + + def _send_custom_email(to_email: str, title: str, body: str, link: str = None): """Send a plain email to a custom (non-user) address. Best-effort.""" try: diff --git a/app/utils/sla.py b/app/utils/sla.py index ab9f7b9..6aaea7d 100644 --- a/app/utils/sla.py +++ b/app/utils/sla.py @@ -319,10 +319,11 @@ def send_score_alerts(threshold=SCORE_DROP_THRESHOLD): link = f'/reports/facility/{fid}/scorecard' notify_by_matrix( - event_type = 'score_alert', - title = title, - body = body, - link = link, + event_type = 'score_alert', + title = title, + body = body, + link = link, + facility_id = fid, ) total_sent += 1 diff --git a/migrations/versions/phase37_project_notification_recipients.py b/migrations/versions/phase37_project_notification_recipients.py new file mode 100644 index 0000000..b34ec7a --- /dev/null +++ b/migrations/versions/phase37_project_notification_recipients.py @@ -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'))