From 056690e6da1629c170b0947df1f13ef889c41733 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Tue, 23 Jun 2026 18:55:53 -0400 Subject: [PATCH] 06/23 Implement broadcast function for admin --- app/__init__.py | 2 + app/models/broadcast.py | 28 ++++ app/models/notification.py | 3 + app/routes/broadcast.py | 138 ++++++++++++++++++++ app/templates/admin/broadcast.html | 152 ++++++++++++++++++++++ app/templates/base.html | 6 + migrations/versions/phase29_broadcasts.py | 37 ++++++ 7 files changed, 366 insertions(+) create mode 100644 app/models/broadcast.py create mode 100644 app/routes/broadcast.py create mode 100644 app/templates/admin/broadcast.html create mode 100644 migrations/versions/phase29_broadcasts.py diff --git a/app/__init__.py b/app/__init__.py index 5da92b1..13106ec 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -164,6 +164,7 @@ def create_app(config_name='default'): from app.routes import customers # Phase 5 — Customer management from app.routes import scheduled_reports # Phase 6 — Scheduled reports from app.routes import support # Support chat + admin tickets + from app.routes import broadcast # Admin broadcast notifications app.register_blueprint(auth.bp) app.register_blueprint(dashboard.bp) @@ -178,6 +179,7 @@ def create_app(config_name='default'): app.register_blueprint(customers.bp) app.register_blueprint(scheduled_reports.bp) app.register_blueprint(support.bp) + app.register_blueprint(broadcast.bp) # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. diff --git a/app/models/broadcast.py b/app/models/broadcast.py new file mode 100644 index 0000000..db2eb9b --- /dev/null +++ b/app/models/broadcast.py @@ -0,0 +1,28 @@ +# app/models/broadcast.py +# ----------------------- +# Stores admin-sent broadcast notification records. +# Each broadcast creates one Notification row per targeted user — +# the iOS app receives them via its existing poll cycle +# (GET /api/v1/notifications?since=...) with no new API endpoint required. + +from app import db +from app.utils.time_utils import now_eastern + + +class Broadcast(db.Model): + __tablename__ = 'broadcasts' + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(255), nullable=False) + body = db.Column(db.Text, nullable=False) + # JSON-encoded list of role strings targeted, e.g. '["inspector","project_manager"]' + target_roles = db.Column(db.JSON, nullable=False, default=list) + sent_by_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True) + sent_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + # Number of Notification rows created (resolved at send time) + recipient_count = db.Column(db.Integer, default=0, nullable=False) + + sent_by = db.relationship('User', foreign_keys=[sent_by_id]) + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/app/models/notification.py b/app/models/notification.py index 1d1b1e4..179bd4c 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -27,6 +27,8 @@ EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated' # more than the configured threshold vs. the prior period. EVENT_SCORE_ALERT = 'score_alert' +EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all apps + ALL_EVENT_TYPES = { EVENT_ISSUE_ASSIGNED: 'Issue assigned to me', EVENT_ISSUE_STATUS: 'Issue status changed', @@ -35,6 +37,7 @@ ALL_EVENT_TYPES = { EVENT_ISSUE_FLAGGED: 'Issue flagged (from inspection)', EVENT_INSPECTION_DONE: 'Inspection completed', EVENT_SLA_ALERT: 'SLA at-risk / breached alerts', + EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)', # Customer-facing — only relevant for customer role accounts EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)', EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)', diff --git a/app/routes/broadcast.py b/app/routes/broadcast.py new file mode 100644 index 0000000..b031157 --- /dev/null +++ b/app/routes/broadcast.py @@ -0,0 +1,138 @@ +# app/routes/broadcast.py +# ----------------------- +# Admin-only route for composing and sending push notifications to all +# iOS apps. Uses the existing Notification model and notify() utility — +# broadcasts arrive on the iPad via the standard 60-second poll cycle +# (GET /api/v1/notifications?since=...) and trigger a local banner via +# deliverLocalNotification(). No APNs/FCM required. + +import logging +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import login_required, current_user + +from app import db +from app.routes.broadcast import Broadcast +from app.models.user import User +from app.models.notification import Notification, EVENT_INSPECTION_DONE +from app.utils.decorators import admin_required +from app.utils.audit import log_action, ACTION_CREATE + +logger = logging.getLogger(__name__) + +bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast') + +# All roles that can hold an active iOS session +BROADCAST_ROLES = ['inspector', 'project_manager', 'director', 'admin'] + +ROLE_LABELS = { + 'inspector': 'Inspectors', + 'project_manager': 'Project Managers', + 'director': 'Directors', + 'admin': 'Admins', +} + + +@bp.route('/', methods=['GET']) +@login_required +@admin_required +def index(): + """Show the compose form and recent broadcast history.""" + history = ( + Broadcast.query + .order_by(Broadcast.sent_at.desc()) + .limit(50) + .all() + ) + return render_template( + 'admin/broadcast.html', + history=history, + roles=BROADCAST_ROLES, + role_labels=ROLE_LABELS, + ) + + +@bp.route('/send', methods=['POST']) +@login_required +@admin_required +def send(): + """ + Compose and send a broadcast notification. + + Form fields + ----------- + title str Notification title (required, max 255) + body str Notification body text (required) + roles[] list One or more role keys to target (required) + """ + title = (request.form.get('title') or '').strip() + body = (request.form.get('body') or '').strip() + target_roles = request.form.getlist('roles') + + # ── Validation ───────────────────────────────────────────────────────── + errors = [] + if not title: + errors.append('Title is required.') + elif len(title) > 255: + errors.append('Title must be 255 characters or fewer.') + if not body: + errors.append('Message body is required.') + valid_roles = [r for r in target_roles if r in BROADCAST_ROLES] + if not valid_roles: + errors.append('Select at least one target role.') + + if errors: + for e in errors: + flash(e, 'danger') + return redirect(url_for('broadcast.index')) + + # ── Find target users (active only) ──────────────────────────────────── + recipients = ( + User.query + .filter(User.role.in_(valid_roles), User.active == True) # noqa: E712 + .all() + ) + if not recipients: + flash('No active users found for the selected roles.', 'warning') + return redirect(url_for('broadcast.index')) + + # ── Create Notification rows ──────────────────────────────────────────── + # One row per recipient — the iOS poll picks them up in the next 60s cycle. + # Using the same Notification model as all other in-app notifications means + # no iOS code changes are needed: existing deliverLocalNotification() fires + # a banner, and unreadNotificationCount increments as usual. + for user in recipients: + notif = Notification( + user_id = user.id, + title = title, + body = body, + link = url_for('broadcast.index', _external=False), + event_type = 'admin_broadcast', + is_read = False, + ) + db.session.add(notif) + + # ── Record the broadcast ──────────────────────────────────────────────── + broadcast = Broadcast( + title = title, + body = body, + target_roles = valid_roles, + sent_by_id = current_user.id, + recipient_count = len(recipients), + ) + db.session.add(broadcast) + db.session.commit() + + log_action(ACTION_CREATE, 'Broadcast', broadcast.id, + f'"{title}" → {", ".join(valid_roles)} ({len(recipients)} users)') + + logger.info( + 'BROADCAST | id=%d | title=%r | roles=%s | recipients=%d | by=%s', + broadcast.id, title, valid_roles, len(recipients), current_user.username, + ) + + flash( + f'Broadcast sent to {len(recipients)} user(s) across ' + f'{", ".join(ROLE_LABELS[r] for r in valid_roles)}.', + 'success', + ) + return redirect(url_for('broadcast.index')) diff --git a/app/templates/admin/broadcast.html b/app/templates/admin/broadcast.html new file mode 100644 index 0000000..ddfbc91 --- /dev/null +++ b/app/templates/admin/broadcast.html @@ -0,0 +1,152 @@ +{% extends "base.html" %} + +{% block title %}Broadcast Notifications{% endblock %} + +{% block content %} +
+
+

Broadcast Notification

+

+ Send an in-app notification to all active iOS app users in the selected roles. + Messages are delivered within 60 seconds via the app's background poll. +

+
+
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} +{% endwith %} + +
+ + {# ── Compose Form ─────────────────────────────────────────────────── #} +
+
+
+ Compose Message +
+
+
+ + +
+ + +
Appears as the notification banner title on iPad.
+
+ +
+ + +
The full message body shown in the notification and in-app inbox.
+
+ +
+ +
+ {% for role in roles %} +
+ + +
+ {% endfor %} +
+
Only active users in the selected roles will receive this message.
+
+ +
+ +
+
+
+
+
+ + {# ── Broadcast History ─────────────────────────────────────────────── #} +
+
+
+ Recent Broadcasts + (last 50) +
+
+ {% if history %} +
+ + + + + + + + + + + + {% for b in history %} + + + + + + + + {% endfor %} + +
SentTitleRolesRecipientsBy
+ {{ b.sent_at.strftime('%b %-d, %Y') }}
+ + {{ b.sent_at.strftime('%I:%M %p') }} + +
+
{{ b.title }}
+
{{ b.body }}
+
+ {% for role in b.target_roles %} + + {{ role_labels.get(role, role) }} + + {% endfor %} + + + {{ b.recipient_count }} + + + {{ b.sent_by.display_name if b.sent_by else '—' }} +
+
+ {% else %} +
+ + No broadcasts sent yet. +
+ {% endif %} +
+
+
+ +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index b68a426..733c6cd 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -202,6 +202,12 @@ Notif. Matrix + {% endif %}