06/23 Implement broadcast function for admin
This commit is contained in:
@@ -164,6 +164,7 @@ def create_app(config_name='default'):
|
|||||||
from app.routes import customers # Phase 5 — Customer management
|
from app.routes import customers # Phase 5 — Customer management
|
||||||
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
||||||
from app.routes import support # Support chat + admin tickets
|
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(auth.bp)
|
||||||
app.register_blueprint(dashboard.bp)
|
app.register_blueprint(dashboard.bp)
|
||||||
@@ -178,6 +179,7 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(customers.bp)
|
app.register_blueprint(customers.bp)
|
||||||
app.register_blueprint(scheduled_reports.bp)
|
app.register_blueprint(scheduled_reports.bp)
|
||||||
app.register_blueprint(support.bp)
|
app.register_blueprint(support.bp)
|
||||||
|
app.register_blueprint(broadcast.bp)
|
||||||
|
|
||||||
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
||||||
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
||||||
|
|||||||
@@ -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'<Broadcast {self.id} "{self.title[:30]}" roles={self.target_roles}>'
|
||||||
@@ -27,6 +27,8 @@ EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
|
|||||||
# more than the configured threshold vs. the prior period.
|
# more than the configured threshold vs. the prior period.
|
||||||
EVENT_SCORE_ALERT = 'score_alert'
|
EVENT_SCORE_ALERT = 'score_alert'
|
||||||
|
|
||||||
|
EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all apps
|
||||||
|
|
||||||
ALL_EVENT_TYPES = {
|
ALL_EVENT_TYPES = {
|
||||||
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
||||||
EVENT_ISSUE_STATUS: 'Issue status changed',
|
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||||
@@ -35,6 +37,7 @@ ALL_EVENT_TYPES = {
|
|||||||
EVENT_ISSUE_FLAGGED: 'Issue flagged (from inspection)',
|
EVENT_ISSUE_FLAGGED: 'Issue flagged (from inspection)',
|
||||||
EVENT_INSPECTION_DONE: 'Inspection completed',
|
EVENT_INSPECTION_DONE: 'Inspection completed',
|
||||||
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
|
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
|
||||||
|
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
|
||||||
# Customer-facing — only relevant for customer role accounts
|
# Customer-facing — only relevant for customer role accounts
|
||||||
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
||||||
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
||||||
|
|||||||
@@ -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'))
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Broadcast Notifications{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h2><i class="bi bi-megaphone-fill me-2"></i>Broadcast Notification</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
|
||||||
|
{# ── Compose Form ─────────────────────────────────────────────────── #}
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<i class="bi bi-send-fill me-2"></i><strong>Compose Message</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('broadcast.send') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="title" class="form-label fw-semibold">
|
||||||
|
Title <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control" id="title" name="title"
|
||||||
|
maxlength="255" required placeholder="e.g. App Update Available">
|
||||||
|
<div class="form-text">Appears as the notification banner title on iPad.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="body" class="form-label fw-semibold">
|
||||||
|
Message <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea class="form-control" id="body" name="body"
|
||||||
|
rows="4" required
|
||||||
|
placeholder="e.g. A new version of JanitorialQC is available. Please update to v1.3 from the App Store."></textarea>
|
||||||
|
<div class="form-text">The full message body shown in the notification and in-app inbox.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-semibold">
|
||||||
|
Target Roles <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
{% for role in roles %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
name="roles" value="{{ role }}"
|
||||||
|
id="role_{{ role }}"
|
||||||
|
{% if role == 'inspector' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="role_{{ role }}">
|
||||||
|
{{ role_labels[role] }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Only active users in the selected roles will receive this message.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg"
|
||||||
|
onclick="return confirm('Send this broadcast to all selected users?')">
|
||||||
|
<i class="bi bi-send-fill me-2"></i>Send Broadcast
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Broadcast History ─────────────────────────────────────────────── #}
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-clock-history me-2"></i><strong>Recent Broadcasts</strong>
|
||||||
|
<span class="text-muted fw-normal ms-2">(last 50)</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if history %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-sm mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Sent</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th class="text-center">Recipients</th>
|
||||||
|
<th>By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in history %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap text-muted small">
|
||||||
|
{{ b.sent_at.strftime('%b %-d, %Y') }}<br>
|
||||||
|
<span class="text-muted" style="font-size:.75rem;">
|
||||||
|
{{ b.sent_at.strftime('%I:%M %p') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ b.title }}</div>
|
||||||
|
<div class="text-muted small text-truncate" style="max-width:220px;"
|
||||||
|
title="{{ b.body }}">{{ b.body }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% for role in b.target_roles %}
|
||||||
|
<span class="badge bg-secondary me-1">
|
||||||
|
{{ role_labels.get(role, role) }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge bg-primary rounded-pill">
|
||||||
|
{{ b.recipient_count }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">
|
||||||
|
{{ b.sent_by.display_name if b.sent_by else '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-megaphone fs-1 d-block mb-2 opacity-25"></i>
|
||||||
|
No broadcasts sent yet.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /row -->
|
||||||
|
{% endblock %}
|
||||||
@@ -202,6 +202,12 @@
|
|||||||
<i class="bi bi-grid-3x3-gap-fill"></i> Notif. Matrix
|
<i class="bi bi-grid-3x3-gap-fill"></i> Notif. Matrix
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
|
||||||
|
href="{{ url_for('broadcast.index') }}">
|
||||||
|
<i class="bi bi-megaphone-fill"></i> Broadcast
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav align-items-center">
|
<ul class="navbar-nav align-items-center">
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""phase29 — broadcasts table for admin push notifications to iOS
|
||||||
|
|
||||||
|
Adds the `broadcasts` table. Each row records an admin-composed message,
|
||||||
|
the roles it targeted, who sent it, and how many Notification rows were
|
||||||
|
created. The Notification rows themselves are written at send-time using
|
||||||
|
the existing notify() utility — no schema changes to that table are needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'phase29_broadcasts'
|
||||||
|
down_revision = 'phase28_fix_inspection_notify'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS broadcasts (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
target_roles JSON NOT NULL,
|
||||||
|
sent_by_id INT NULL,
|
||||||
|
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
recipient_count INT NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT fk_broadcast_sender
|
||||||
|
FOREIGN KEY (sent_by_id) REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.execute("DROP TABLE IF EXISTS broadcasts")
|
||||||
Reference in New Issue
Block a user