From 41e2e0465df14d7b60de2a94f22c7510bde8f431 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Sun, 1 Mar 2026 18:47:21 -0500 Subject: [PATCH] Feb 02 2026: implement notification system --- app/__init__.py | 24 +++- app/routes/inspections.py | 49 ++++++++ app/routes/issues.py | 101 +++++++++++++++++ app/routes/notifications.py | 74 ++++++++++++ app/templates/base.html | 220 +++++++++++++++++++++++++++++++++++- config.py | 17 ++- 6 files changed, 475 insertions(+), 10 deletions(-) create mode 100644 app/routes/notifications.py diff --git a/app/__init__.py b/app/__init__.py index a78b917..2be51cc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -31,10 +31,29 @@ def create_app(config_name='default'): app.jinja_env.globals['csrf_token'] = generate_csrf app.jinja_env.globals['enumerate'] = enumerate + # ── Inject unread notification count into every template context ────── + # This powers the red badge on the navbar bell icon without requiring + # individual routes to pass the count manually. + from flask_login import current_user + + @app.context_processor + def inject_notification_count(): + try: + if current_user.is_authenticated: + from app.models.notification import Notification + count = Notification.query.filter_by( + user_id=current_user.id, is_read=False + ).count() + return {'unread_notification_count': count} + except Exception: + pass + return {'unread_notification_count': 0} + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) from app.routes import auth, dashboard, inspections, templates, reports, facilities - from app.routes import issues # Phase 3 + from app.routes import issues # Phase 3 + from app.routes import notifications # Notification system app.register_blueprint(auth.bp) app.register_blueprint(dashboard.bp) @@ -43,6 +62,7 @@ def create_app(config_name='default'): app.register_blueprint(reports.bp) app.register_blueprint(facilities.bp) app.register_blueprint(issues.bp) + app.register_blueprint(notifications.bp) # ── Error handler: 413 Request Entity Too Large ─────────────────────── # Nginx can return 413 before Flask sees the request; this handler covers @@ -67,4 +87,4 @@ def create_app(config_name='default'): with app.app_context(): db.create_all() - return app + return app \ No newline at end of file diff --git a/app/routes/inspections.py b/app/routes/inspections.py index 48eaddf..f00279e 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -15,6 +15,7 @@ from app.models.user import User from app.utils.forms import StartInspectionForm, IssueForm from app.utils.decorators import supervisor_required from app.utils.pdf_export import generate_inspection_pdf +from app.utils.notifications import notify bp = Blueprint('inspections', __name__, url_prefix='/inspections') @@ -308,6 +309,29 @@ def execute(inspection_id): _save_responses(inspection, responses) db.session.commit() + # ── Notify supervisors/admins that an inspection was completed ── + supervisors = User.query.filter( + User.role.in_(['admin', 'supervisor']) + ).all() + inspection_link = url_for('inspections.view', inspection_id=inspection.id) + score_display = f'{score:.1f}%' if score is not None else 'N/A' + for supervisor in supervisors: + if supervisor.id != current_user.id: + notify( + recipient = supervisor, + title = f'Inspection #{inspection.id} Completed', + body = ( + f'{current_user.username} completed an inspection at ' + f'{inspection.facility.name} using the ' + f'"{inspection.template.name}" template. ' + f'Overall score: {score_display}.' + ), + link = inspection_link, + inspection_id = inspection.id, + send_email = True, + ) + db.session.commit() # Commit notifications + flash('Inspection submitted successfully!', 'success') return redirect(url_for('inspections.view', inspection_id=inspection_id)) @@ -411,6 +435,31 @@ def flag_issue(inspection_id): inspection.status = 'flagged' db.session.commit() + current_app.logger.info( + 'ISSUE FLAGGED | issue_id=%s | inspection_id=%s | severity=%s | assigned_to=%s | by=%s', + issue.id, inspection_id, issue.severity, issue.assigned_to, current_user.username + ) + + # ── Notify the assignee of the flagged issue ───────────────────── + if issue.assigned_to: + assignee = User.query.get(issue.assigned_to) + if assignee and assignee.id != current_user.id: + notify( + recipient = assignee, + title = f'New Issue #{issue.id} Assigned to You', + body = ( + f'A {issue.severity.title()}-severity issue was flagged during ' + f'inspection #{inspection_id} at {inspection.facility.name} ' + f'and assigned to you. ' + f'Description: {issue.description[:120]}' + f'{"…" if len(issue.description) > 120 else ""}' + ), + link = url_for('issues.view', issue_id=issue.id), + issue_id = issue.id, + send_email = True, + ) + db.session.commit() # Commit notification + flash('Issue logged successfully.', 'success') return redirect(url_for('inspections.execute', inspection_id=inspection_id)) diff --git a/app/routes/issues.py b/app/routes/issues.py index c4efe97..32dcb2c 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -8,6 +8,7 @@ from app.models.facility import Facility, Area from app.models.user import User from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.decorators import supervisor_required +from app.utils.notifications import notify bp = Blueprint('issues', __name__, url_prefix='/issues') @@ -59,6 +60,9 @@ def view(issue_id): form.status.data = form.status.data or issue.status if form.validate_on_submit(): + old_status = issue.status + old_assigned_to = issue.assigned_to + issue.status = form.status.data # Only admin/supervisor can reassign; inspectors can only update status @@ -100,6 +104,79 @@ def view(issue_id): 'ISSUE UPDATED | id=%s | status=%s | result_photos_added=%s | comment=%s | updated_by=%s', issue.id, issue.status, len(new_photos), bool(comment_body), current_user.username ) + + # ── Notifications ──────────────────────────────────────────────── + issue_link = url_for('issues.view', issue_id=issue.id) + new_assigned_to = issue.assigned_to + + # 1. Notify the assignee when status changes + if old_status != issue.status and new_assigned_to: + assignee = User.query.get(new_assigned_to) + if assignee and assignee.id != current_user.id: + notify( + recipient = assignee, + title = f'Issue #{issue.id} Status Updated', + body = ( + f'Issue in {issue.area.name} was updated from ' + f'"{old_status.replace("_", " ").title()}" to ' + f'"{issue.status.replace("_", " ").title()}" ' + f'by {current_user.username}.' + ), + link = issue_link, + issue_id = issue.id, + send_email = True, + ) + + # 2. Notify newly assigned user when the assignee changes + if (old_assigned_to != new_assigned_to) and new_assigned_to: + new_assignee = User.query.get(new_assigned_to) + if new_assignee and new_assignee.id != current_user.id: + notify( + recipient = new_assignee, + title = f'Issue #{issue.id} Assigned to You', + body = ( + f'You have been assigned Issue #{issue.id} ' + f'({issue.severity.title()} severity) in {issue.area.name}. ' + f'Current status: {issue.status.replace("_", " ").title()}.' + ), + link = issue_link, + issue_id = issue.id, + send_email = True, + ) + + # 3. Notify the previously assigned user when unassigned + if old_assigned_to and old_assigned_to != new_assigned_to: + old_assignee = User.query.get(old_assigned_to) + if old_assignee and old_assignee.id != current_user.id: + notify( + recipient = old_assignee, + title = f'Issue #{issue.id} Unassigned', + body = ( + f'You have been removed from Issue #{issue.id} ' + f'in {issue.area.name} by {current_user.username}.' + ), + link = issue_link, + issue_id = issue.id, + send_email = True, + ) + + # 4. Notify the assignee when a comment is added (if not the commenter) + if comment_body and new_assigned_to: + commentee = User.query.get(new_assigned_to) + if commentee and commentee.id != current_user.id: + notify( + recipient = commentee, + title = f'New Comment on Issue #{issue.id}', + body = ( + f'{current_user.username} added a comment on Issue #{issue.id}: ' + f'"{comment_body[:120]}{"…" if len(comment_body) > 120 else ""}"' + ), + link = issue_link, + issue_id = issue.id, + send_email = True, + ) + + db.session.commit() # Commit notifications flash('Issue updated.', 'success') return redirect(url_for('issues.view', issue_id=issue_id)) @@ -134,6 +211,30 @@ def create(): ) db.session.add(issue) db.session.commit() + current_app.logger.info( + 'ISSUE CREATED | id=%s | severity=%s | area_id=%s | assigned_to=%s | created_by=%s', + issue.id, issue.severity, issue.area_id, issue.assigned_to, current_user.username + ) + + # ── Notify the assignee of the new issue ──────────────────────── + if issue.assigned_to: + assignee = User.query.get(issue.assigned_to) + if assignee and assignee.id != current_user.id: + notify( + recipient = assignee, + title = f'New Issue #{issue.id} Assigned to You', + body = ( + f'A new {issue.severity.title()}-severity issue has been logged ' + f'in {issue.area.name} and assigned to you. ' + f'Description: {issue.description[:120]}' + f'{"…" if len(issue.description) > 120 else ""}' + ), + link = url_for('issues.view', issue_id=issue.id), + issue_id = issue.id, + send_email = True, + ) + db.session.commit() # Commit notification + flash('Issue created.', 'success') return redirect(url_for('issues.index')) diff --git a/app/routes/notifications.py b/app/routes/notifications.py new file mode 100644 index 0000000..e98fd9b --- /dev/null +++ b/app/routes/notifications.py @@ -0,0 +1,74 @@ +# app/routes/notifications.py +import logging +from flask import Blueprint, jsonify, request, abort +from flask_login import login_required, current_user +from app import db +from app.models.notification import Notification + +logger = logging.getLogger(__name__) + +bp = Blueprint('notifications', __name__, url_prefix='/notifications') + + +@bp.route('/feed') +@login_required +def feed(): + """Return the 20 most recent notifications for the current user as JSON. + Used by the navbar bell icon to populate the dropdown. + """ + notifs = ( + Notification.query + .filter_by(user_id=current_user.id) + .order_by(Notification.created_at.desc()) + .limit(20) + .all() + ) + unread_count = Notification.query.filter_by( + user_id=current_user.id, is_read=False + ).count() + + items = [] + for n in notifs: + items.append({ + 'id': n.id, + 'title': n.title, + 'body': n.body, + 'link': n.link, + 'is_read': n.is_read, + 'created_at': n.created_at.strftime('%b %d, %Y %I:%M %p'), + }) + + return jsonify({'notifications': items, 'unread_count': unread_count}) + + +@bp.route('//mark-read', methods=['POST']) +@login_required +def mark_read(notif_id): + """Mark a single notification as read.""" + notif = Notification.query.get_or_404(notif_id) + if notif.user_id != current_user.id: + abort(403) + notif.is_read = True + db.session.commit() + logger.info( + 'NOTIFICATION READ | id=%s | user=%s', + notif_id, current_user.username, + ) + return jsonify({'ok': True}) + + +@bp.route('/mark-all-read', methods=['POST']) +@login_required +def mark_all_read(): + """Mark all unread notifications for the current user as read.""" + updated = ( + Notification.query + .filter_by(user_id=current_user.id, is_read=False) + .update({'is_read': True}) + ) + db.session.commit() + logger.info( + 'NOTIFICATIONS ALL READ | user=%s | count=%s', + current_user.username, updated, + ) + return jsonify({'ok': True, 'marked': updated}) \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 82cc64b..9ab15bf 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -12,6 +12,61 @@ {% block extra_css %}{% endblock %} + {% if current_user.is_authenticated %} @@ -49,7 +104,42 @@ {% endif %} -