First commit

This commit is contained in:
2026-06-26 09:04:34 -04:00
commit 77678ed724
166 changed files with 34842 additions and 0 deletions
View File
+98
View File
@@ -0,0 +1,98 @@
"""
audit.py
--------
Centralised helper for writing AuditLog entries.
Usage (inside any route after db.session.commit()):
from app.utils.audit import log_action
log_action(
action = 'CREATE',
entity_type = 'Facility',
entity_id = facility.id,
entity_label = facility.name,
details = f'address={facility.address}',
)
``action`` should be one of the ACTION_* constants defined below.
``entity_type`` should match the model class name for consistency.
"""
import logging
from flask import request
from flask_login import current_user
from app import db
from app.models.audit import AuditLog
logger = logging.getLogger(__name__)
# ── Canonical action constants ────────────────────────────────────────────────
ACTION_CREATE = 'CREATE'
ACTION_UPDATE = 'UPDATE'
ACTION_DELETE = 'DELETE'
ACTION_LOGIN = 'LOGIN'
ACTION_LOGOUT = 'LOGOUT'
ACTION_EXPORT = 'EXPORT'
def log_action(action: str,
entity_type: str,
entity_id: int | None = None,
entity_label: str | None = None,
details: str | None = None) -> None:
"""
Write a single AuditLog row. Safe to call from any request context.
⚠️ This function calls db.session.commit() internally.
Always call it AFTER the primary db.session.commit() for the business
transaction — never before. Calling it mid-transaction will commit any
dirty ORM state accumulated in the session up to that point.
Parameters
----------
action : One of the ACTION_* constants (or a custom string ≤ 50 chars).
entity_type : Model name — 'User', 'Facility', 'Area', 'Template',
'Inspection', 'Issue', etc.
entity_id : Primary key of the affected record (optional).
entity_label : Human-readable name / description snapshot (optional).
details : Extra context string, e.g. 'status=open→resolved' (optional).
"""
try:
# Resolve actor — fall back gracefully if called outside request context
if current_user and current_user.is_authenticated:
uid = current_user.id
uname = current_user.username
urole = current_user.role
else:
uid, uname, urole = None, 'system', 'system'
# Best-effort IP extraction; respects X-Forwarded-For from Nginx
ip = None
try:
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.remote_addr)
except RuntimeError:
pass # outside request context
entry = AuditLog(
user_id = uid,
username = uname,
user_role = urole,
action = action[:50],
entity_type = entity_type[:50],
entity_id = entity_id,
entity_label = (entity_label or '')[:255],
details = details,
ip_address = (ip or '')[:45],
)
db.session.add(entry)
db.session.commit()
except Exception as exc:
# Audit logging must never break the primary request flow
logger.error('AuditLog write failed: %s', exc, exc_info=True)
try:
db.session.rollback()
except Exception:
pass
+80
View File
@@ -0,0 +1,80 @@
from functools import wraps
from flask import flash, redirect, url_for, request
from flask_login import current_user
from urllib.parse import urlparse
# ── Open-redirect guard ───────────────────────────────────────────────────────
def safe_redirect_url(url: str | None, fallback: str | None = None) -> str:
"""Return *url* only if it is a safe relative URL on this host.
Rejects any URL that carries a network location (netloc) or an explicit
scheme, preventing open-redirect attacks where a crafted link contains
next=https://evil.com.
Parameters
----------
url : The candidate redirect target (may be None).
fallback : Returned when *url* is absent or unsafe.
Defaults to the dashboard index.
"""
if fallback is None:
fallback = url_for('dashboard.index')
if not url:
return fallback
parsed = urlparse(url)
if parsed.netloc or parsed.scheme:
return fallback
return url
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role != 'admin':
flash('Administrator access required.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_function
def supervisor_required(f):
"""Grants access to admin and director roles.
The decorator is intentionally kept as 'supervisor_required' so that all
existing route decorators (@supervisor_required) continue to work without
any changes to the route files. The access list now reflects the renamed
Director role instead of the retired Supervisor role.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role not in ['admin', 'director']:
flash('Director access required.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_function
def project_manager_required(f):
"""Grants access to admin, director, and project_manager roles."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role not in [
'admin', 'director', 'project_manager'
]:
flash('Project Manager access required.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_function
def customer_required(f):
"""Restricts access to customer-role users only.
Internal staff (admin, director, inspector, project_manager) should
never be routed through customer-scoped views — use their own routes.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role != 'customer':
flash('Customer portal access required.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_function
+277
View File
@@ -0,0 +1,277 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, MultipleFileField
from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
DecimalField, BooleanField, IntegerField, HiddenField,
RadioField)
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
Optional, NumberRange, ValidationError)
from app.models.user import User
# ── Auth ─────────────────────────────────────────────────────────────────────
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Keep me logged in')
class ProfileForm(FlaskForm):
"""Self-service profile update form — available to all authenticated users."""
full_name = StringField('Full Name', validators=[Optional(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
current_password = PasswordField('Current Password', validators=[Optional()])
new_password = PasswordField('New Password', validators=[Optional(), Length(min=6, max=100)])
confirm_password = PasswordField('Confirm New Password', validators=[EqualTo('new_password', message='Passwords must match.')])
def __init__(self, user=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
def validate_email(self, field):
q = User.query.filter_by(email=field.data).first()
if self.user and field.data != self.user.email and q:
raise ValidationError('Email already registered.')
elif not self.user and q:
raise ValidationError('Email already registered.')
def validate_current_password(self, field):
"""Require current password only when the user wants to set a new one."""
if self.new_password.data:
if not field.data:
raise ValidationError('Please enter your current password to set a new one.')
if self.user and not self.user.check_password(field.data):
raise ValidationError('Current password is incorrect.')
class UserForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
full_name = StringField('Full Name', validators=[Optional(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
password = PasswordField('Password', validators=[Optional(), Length(min=6, max=100)])
confirm_password = PasswordField('Confirm Password', validators=[Optional(), EqualTo('password')])
role = SelectField('Role', choices=[
('admin', 'Administrator'),
('director', 'Director'),
('inspector', 'Inspector'),
('project_manager', 'Project Manager'),
# 'customer' is intentionally excluded — customer accounts are managed via /customers
], validators=[Optional()])
# NOTE: Optional() here because directors submit no role value (the field is
# hidden in user_form.html for them). Role enforcement is handled in the
# route: directors always keep/default to 'inspector'; only admins may set
# an arbitrary role. DataRequired() would cause validate_on_submit() to
# fail silently for directors, preventing any save at all.
def __init__(self, user=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
def validate_password(self, field):
"""Enforce minimum length only when a new password is actually provided."""
if field.data and len(field.data) < 6:
raise ValidationError('Password must be at least 6 characters.')
def validate_confirm_password(self, field):
"""Require confirmation to match only when a new password is provided."""
if self.password.data and field.data != self.password.data:
raise ValidationError('Passwords must match.')
def validate_username(self, field):
q = User.query.filter_by(username=field.data).first()
if self.user:
if field.data != self.user.username and q:
raise ValidationError('Username already exists.')
elif q:
raise ValidationError('Username already exists.')
def validate_email(self, field):
q = User.query.filter_by(email=field.data).first()
if self.user:
if field.data != self.user.email and q:
raise ValidationError('Email already registered.')
elif q:
raise ValidationError('Email already registered.')
# ── Facility / Area ──────────────────────────────────────────────────────────
class FacilityForm(FlaskForm):
name = StringField('Facility Name', validators=[DataRequired(), Length(max=255)])
address = TextAreaField('Address', validators=[Optional()])
contact_person = StringField('Contact Person', validators=[Optional(), Length(max=100)])
contact_phone = StringField('Contact Phone', validators=[Optional(), Length(max=20)])
project_id = SelectField('Contract', coerce=int, validators=[Optional()])
active = BooleanField('Active', default=True)
class AreaForm(FlaskForm):
name = StringField('Area Name', validators=[DataRequired(), Length(max=255)])
area_type = SelectField('Area Type', choices=[
('restroom','Restroom'), ('lobby','Lobby'), ('hallway','Hallway'),
('office','Office'), ('kitchen','Kitchen'), ('storage','Storage'),
('floor','Floor'),
('outdoor','Outdoor'), ('other','Other'),
], validators=[Optional()])
facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()])
# ── Templates ────────────────────────────────────────────────────────────────
class InspectionTemplateForm(FlaskForm):
name = StringField('Template Name', validators=[DataRequired(), Length(max=255)])
description = TextAreaField('Description', validators=[Optional()])
frequency = SelectField('Inspection Frequency', choices=[
('daily','Daily'), ('weekly','Weekly'),
('monthly','Monthly'), ('quarterly','Quarterly'),
], validators=[DataRequired()])
class ChecklistItemForm(FlaskForm):
category = StringField('Category', validators=[DataRequired(), Length(max=100)])
item_description = TextAreaField('Item Description', validators=[DataRequired()])
scoring_type = SelectField('Scoring Type', choices=[
('pass_fail','Pass/Fail'), ('rating_5','5-Point Rating'), ('rating_10','10-Point Rating'),
], validators=[DataRequired()])
weight = DecimalField('Weight', validators=[Optional(), NumberRange(min=0.1, max=10.0)], default=1.00)
requires_photo = BooleanField('Requires Photo Evidence', default=False)
display_order = IntegerField('Display Order', validators=[Optional()], default=0)
# ── Inspections ──────────────────────────────────────────────────────────────
class StartInspectionForm(FlaskForm):
template_id = SelectField('Template', coerce=int, validators=[DataRequired()])
project_id = SelectField('Contract', coerce=int, validators=[DataRequired()])
facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()])
area_id = SelectField('Area', coerce=int, validators=[Optional()])
class ChecklistResultForm(FlaskForm):
"""Dynamically rendered per checklist item — base validators only."""
score = DecimalField('Score', validators=[Optional(), NumberRange(min=0, max=10)])
passed = HiddenField('Passed') # 'true' / 'false' / ''
comments = TextAreaField('Comments', validators=[Optional(), Length(max=1000)])
photo = FileField('Photo', validators=[
Optional(),
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
])
# ── Issues ───────────────────────────────────────────────────────────────────
class IssueForm(FlaskForm):
facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()])
severity = SelectField('Severity', choices=[
('low','Low'), ('medium','Medium'), ('high','High'), ('critical','Critical'),
], validators=[DataRequired()])
description = TextAreaField('Description', validators=[DataRequired(), Length(max=2000)])
photo = FileField('Photo Evidence', validators=[
Optional(),
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
])
assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()])
class IssueUpdateForm(FlaskForm):
status = SelectField('Status', choices=[
('open','Open'), ('in_progress','In Progress'),
('pending_verification','Pending Verification'), ('resolved','Resolved'),
], validators=[DataRequired()])
assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()])
update_notes = TextAreaField('Update Notes', validators=[Optional(), Length(max=1000)])
result_notes = TextAreaField('Result Notes', validators=[Optional(), Length(max=2000)])
result_photos = MultipleFileField('Result Photos', validators=[
Optional(),
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
])
# External contractor / vendor fields (phase26)
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
# ── Projects ─────────────────────────────────────────────────────────────────
class ProjectForm(FlaskForm):
name = StringField('Contract Name', validators=[DataRequired(), Length(max=255)])
description = TextAreaField('Description', validators=[Optional()])
project_manager_id = SelectField('Project Manager', coerce=int, validators=[Optional()])
active = BooleanField('Active', default=True)
class CustomerAssignmentForm(FlaskForm):
user_id = SelectField('Customer User', coerce=int, validators=[DataRequired()])
facility_id = SelectField('Facility Scope', coerce=int, validators=[Optional()])
class CustomerUserForm(FlaskForm):
"""Create / edit a customer-role user account.
Used exclusively in the Customer Management UI.
Password is required on create; optional on edit.
"""
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
full_name = StringField('Full Name', validators=[Optional(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
password = PasswordField('Password', validators=[Optional(), Length(min=8)])
confirm_password = PasswordField('Confirm Password',
validators=[Optional(), EqualTo('password',
message='Passwords must match.')])
def __init__(self, user=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user = user # existing user instance (edit mode) or None (create mode)
def validate_username(self, field):
from app.models.user import User
existing = User.query.filter_by(username=field.data).first()
if existing and (self._user is None or existing.id != self._user.id):
raise ValidationError('Username already in use.')
def validate_email(self, field):
from app.models.user import User
existing = User.query.filter_by(email=field.data).first()
if existing and (self._user is None or existing.id != self._user.id):
raise ValidationError('Email address already in use.')
def validate_password(self, field):
"""Password is required when creating a new account."""
if self._user is None and not field.data:
raise ValidationError('Password is required for new accounts.')
class CustomerInviteForm(FlaskForm):
"""Simplified form for creating a customer account via email invitation.
Admin enters Full Name and Email only. A username is auto-generated
from the email address. The customer sets their own username and
password via the emailed link.
"""
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
def validate_email(self, field):
if User.query.filter_by(email=field.data.strip().lower()).first():
raise ValidationError('An account with this email address already exists.')
class ForgotPasswordForm(FlaskForm):
email = StringField('Email Address', validators=[DataRequired(), Email(), Length(max=255)])
class ResetPasswordForm(FlaskForm):
password = PasswordField('New Password', validators=[DataRequired(), Length(min=8, max=100)])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(),
EqualTo('password', message='Passwords must match.')])
class SetPasswordForm(FlaskForm):
"""Public form for customer to choose their username and password via emailed link."""
username = StringField('Choose a Username', validators=[DataRequired(), Length(min=3, max=100)])
password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=100)])
confirm_password = PasswordField('Confirm Password',
validators=[DataRequired(),
EqualTo('password', message='Passwords must match.')])
def validate_username(self, field):
existing = User.query.filter_by(username=field.data.strip()).first()
if existing:
raise ValidationError('This username is already taken. Please choose another.')
+650
View File
@@ -0,0 +1,650 @@
"""
app/utils/notifications.py
~~~~~~~~~~~~~~~~~~~~~~~~~~
Central helper for creating in-app notifications and dispatching email alerts.
Usage
-----
from app.utils.notifications import notify
notify(
recipient = some_user,
title = 'Issue #12 Updated',
body = 'Status changed to In Progress by admin.',
link = url_for('issues.view', issue_id=12),
issue_id = 12,
event_type = EVENT_ISSUE_STATUS, # controls preference lookup
send_email = True,
)
Email delivery is best-effort: a failure to send will be logged but will NOT
raise an exception or roll back the DB transaction.
Digest emails are sent by calling send_pending_digests(frequency) from the
/notifications/send-digest route, which is triggered by a server cron job.
"""
import logging, threading
from flask import current_app, render_template_string
from flask_mail import Message
from app import db, mail
from app.models.notification import (
Notification, NotificationPreference,
ALL_EVENT_TYPES,
)
logger = logging.getLogger(__name__)
# ── Email templates ────────────────────────────────────────────────────────────
_EMAIL_HTML_SINGLE = """\
<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
<h2 style="color:#0d6efd;">{{ title }}</h2>
<p>{{ body }}</p>
{% if link %}
<p>
<a href="{{ base_url }}{{ link }}"
style="background:#0d6efd;color:#fff;padding:10px 20px;
text-decoration:none;border-radius:4px;display:inline-block;">
View Details
</a>
</p>
{% endif %}
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
<p style="font-size:12px;color:#888;">
Janitorial QC System — automated notification. Do not reply to this email.<br>
<a href="{{ base_url }}/notifications/preferences" style="color:#888;">
Manage notification preferences
</a>
</p>
</body>
</html>
"""
_EMAIL_TEXT_SINGLE = """\
{{ title }}
{{ body }}
{% if link %}
View: {{ base_url }}{{ link }}
{% endif %}
--
Janitorial QC System — automated notification.
Manage preferences: {{ base_url }}/notifications/preferences
"""
_EMAIL_HTML_DIGEST = """\
<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
<h2 style="color:#0d6efd;">Your {{ frequency|title }} JQC Notification Digest</h2>
<p>You have <strong>{{ notifications|length }}</strong> new notification(s):</p>
<hr style="border:none;border-top:1px solid #eee;">
{% for n in notifications %}
<div style="margin-bottom:20px;padding:12px;background:#f8f9fa;border-radius:6px;
border-left:4px solid #0d6efd;">
<p style="margin:0 0 4px;font-weight:bold;">{{ n.title }}</p>
<p style="margin:0 0 8px;font-size:.9em;color:#555;">{{ n.body }}</p>
{% if n.link %}
<a href="{{ base_url }}{{ n.link }}"
style="font-size:.85em;color:#0d6efd;text-decoration:none;">
View Details →
</a>
{% endif %}
<p style="margin:6px 0 0;font-size:.75em;color:#999;">
{{ n.created_at.strftime('%b %d, %Y %I:%M %p') }}
</p>
</div>
{% endfor %}
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
<p style="font-size:12px;color:#888;">
Janitorial QC System — automated digest. Do not reply to this email.<br>
<a href="{{ base_url }}/notifications/preferences" style="color:#888;">
Manage notification preferences
</a>
</p>
</body>
</html>
"""
_EMAIL_TEXT_DIGEST = """\
Your {{ frequency|title }} JQC Notification Digest
{{ notifications|length }} new notification(s):
{% for n in notifications %}
---
{{ n.title }}
{{ n.body }}
{% if n.link %}View: {{ base_url }}{{ n.link }}{% endif %}
{{ n.created_at.strftime('%b %d, %Y %I:%M %p') }}
{% endfor %}
--
Janitorial QC System — automated digest.
Manage preferences: {{ base_url }}/notifications/preferences
"""
# ── Preference helpers ─────────────────────────────────────────────────────────
def _get_preference(user_id, event_type):
"""Return the NotificationPreference for a user+event, or None if not set."""
if not event_type:
return None
return NotificationPreference.query.filter_by(
user_id=user_id, event_type=event_type
).first()
def _email_enabled_for(user, event_type):
"""Return True if the user wants an immediate email for this event type."""
pref = _get_preference(user.id, event_type)
if pref is None:
return True # Default: email on, immediate
if not pref.email_enabled:
return False # User opted out of email entirely for this event
if pref.digest_mode:
return False # User prefers digest — suppress immediate email
return True
def _digest_mode_for(user, event_type):
"""Return True if this notification should be held for digest delivery."""
pref = _get_preference(user.id, event_type)
if pref is None:
return False
return pref.email_enabled and pref.digest_mode
# ── Core notify function ───────────────────────────────────────────────────────
def notify(
recipient,
title: str,
body: str,
link: str = None,
issue_id: int = None,
inspection_id: int = None,
event_type: str = None,
send_email: bool = True,
respect_preferences: bool = True,
):
"""Create an in-app Notification record and optionally send an email.
Parameters
----------
recipient : User ORM instance
title : Short notification headline
body : Full notification message
link : Relative URL for the 'View Details' button/link
issue_id : FK to issues.id (optional)
inspection_id : FK to inspections.id (optional)
event_type : One of the EVENT_* constants from models.notification
Used to look up the user's preference for this event.
send_email : Master switch — set False to suppress all email (overrides prefs)
respect_preferences : When True (default), per-user email preferences gate delivery.
Set False for matrix-routed broadcasts — the matrix is the
authority; individual opt-out should not override admin config.
"""
# Determine digest flag before creating the record.
# Digest mode is only respected when individual preferences are in effect.
hold_for_digest = (
respect_preferences
and send_email
and bool(event_type)
and _digest_mode_for(recipient, event_type)
)
# ── 1. Persist in-app notification ──────────────────────────────────────
notif = Notification(
user_id = recipient.id,
title = title,
body = body,
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
event_type = event_type,
is_read = False,
digest_pending = hold_for_digest,
)
db.session.add(notif)
# NOTE: Caller is responsible for db.session.commit()
logger.info(
'NOTIFICATION CREATED | user=%s | event=%s | title=%s | digest=%s',
recipient.username, event_type, title, hold_for_digest,
)
# ── 2. Send immediate email if applicable ────────────────────────────────
if not send_email:
logger.info('EMAIL SKIP | user=%s | event=%s | reason=send_email_False',
recipient.username, event_type)
elif hold_for_digest:
logger.info('EMAIL SKIP | user=%s | event=%s | reason=digest_mode',
recipient.username, event_type)
else:
if respect_preferences:
pref_enabled = _email_enabled_for(recipient, event_type)
should_send = (event_type is None or pref_enabled)
if not should_send:
logger.info('EMAIL SKIP | user=%s | event=%s | reason=user_pref_disabled',
recipient.username, event_type)
else:
should_send = True
mail_server = current_app.config.get('MAIL_SERVER')
if should_send and not recipient.email:
logger.warning('EMAIL SKIP | user=%s | event=%s | reason=no_email_address',
recipient.username, event_type)
elif should_send and not mail_server:
logger.warning('EMAIL SKIP | user=%s | event=%s | reason=MAIL_SERVER_not_configured',
recipient.username, event_type)
elif should_send:
logger.info('EMAIL SEND | user=%s | event=%s | to=%s',
recipient.username, event_type, recipient.email)
_send_single_email(recipient, title, body, link)
def _send_single_email(recipient, title, body, link):
"""Dispatch a single immediate notification email in a background thread.
Sending is offloaded to a daemon thread so SMTP latency never blocks the
HTTP response. The Flask application context is pushed explicitly so that
Flask-Mail and config lookups work outside the request context.
"""
# Render templates while still inside the request context
try:
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
sender = current_app.config.get(
'MAIL_DEFAULT_SENDER',
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
)
html_body = render_template_string(
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
)
text_body = render_template_string(
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
)
msg = Message(
subject = f'[JQC] {title}',
sender = sender,
recipients = [recipient.email],
body = text_body,
html = html_body,
)
except Exception as exc:
logger.error('NOTIFICATION EMAIL BUILD FAILED | to=%s | error=%s', recipient.email, exc)
return
# Capture app instance before leaving the request context
app = current_app._get_current_object()
recipient_email = recipient.email
subject = msg.subject
def _send():
with app.app_context():
try:
mail.send(msg)
logger.info(
'NOTIFICATION EMAIL SENT | to=%s | subject=%s',
recipient_email, subject,
)
except Exception as exc:
logger.error(
'NOTIFICATION EMAIL FAILED | to=%s | error=%s',
recipient_email, exc,
)
t = threading.Thread(target=_send, daemon=True)
t.start()
# ── Digest delivery ────────────────────────────────────────────────────────────
# ── Customer portal notifications ─────────────────────────────────────────────
def notify_customers_for_facility(
facility_id: int,
event_type: str,
title: str,
body: str,
link: str = None,
issue_id: int = None,
inspection_id: int = None,
):
"""Dispatch in-app + email notifications to all customer users assigned
to the given facility.
Resolves assignments via CustomerAssignment rows:
- facility-scoped assignment (facility_id matches exactly)
- project-scoped assignment (facility belongs to the project, no facility_id set)
Respects each customer's NotificationPreference for the supplied event_type.
Best-effort: a failure on one recipient does not block others.
Parameters
----------
facility_id : The facility where the event occurred.
event_type : EVENT_CUSTOMER_INSPECTION_DONE or EVENT_CUSTOMER_ISSUE_UPDATED.
title : Short notification headline.
body : Full notification message.
link : Relative URL for 'View Details'.
issue_id : FK to issues.id (optional).
inspection_id : FK to inspections.id (optional).
"""
try:
from app.models.project import CustomerAssignment
from app.models.facility import Facility
from app.models.user import User
facility = db.session.get(Facility, facility_id)
if not facility:
logger.warning(
'notify_customers_for_facility | facility_id=%s not found', facility_id
)
return
# Collect distinct customer user IDs that have access to this facility
notified_user_ids = set()
# 1. Direct facility-scoped assignments
direct = CustomerAssignment.query.filter_by(facility_id=facility_id).all()
for a in direct:
notified_user_ids.add(a.user_id)
# 2. Project-scoped assignments (no facility_id) — if facility belongs to a project
if facility.project_id:
project_wide = CustomerAssignment.query.filter_by(
project_id=facility.project_id,
facility_id=None,
).all()
for a in project_wide:
notified_user_ids.add(a.user_id)
if not notified_user_ids:
logger.debug(
'notify_customers_for_facility | facility_id=%s | no customer assignments found',
facility_id,
)
return
for user_id in notified_user_ids:
user = db.session.get(User, user_id)
if not user or not user.active or user.role != 'customer':
continue
try:
notify(
recipient = user,
title = title,
body = body,
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
event_type = event_type,
send_email = True,
)
logger.info(
'CUSTOMER NOTIFY | user=%s | facility_id=%s | event=%s',
user.username, facility_id, event_type,
)
except Exception as exc:
logger.error(
'CUSTOMER NOTIFY FAILED | user=%s | facility_id=%s | event=%s | error=%s',
user_id, facility_id, event_type, exc,
)
except Exception as exc:
logger.error(
'notify_customers_for_facility | unexpected error | facility_id=%s | error=%s',
facility_id, exc,
)
def send_pending_digests(frequency: str = 'daily'):
"""Send digest emails for all users who have pending digest notifications.
Called from the /notifications/send-digest route, which is hit by cron.
Parameters
----------
frequency : 'hourly' or 'daily' — matches digest_frequency in preferences
"""
if not current_app.config.get('MAIL_SERVER'):
logger.warning('DIGEST SKIPPED | MAIL_SERVER not configured')
return 0
# Find all users with pending digest notifications
from app.models.user import User
pending_user_ids = (
db.session.query(Notification.user_id)
.filter_by(digest_pending=True)
.distinct()
.all()
)
pending_user_ids = [row[0] for row in pending_user_ids]
sent_count = 0
for user_id in pending_user_ids:
user = db.session.get(User, user_id)
if not user or not user.email:
continue
# Collect only the notifications that match this frequency for this user
# A notification is included in a frequency's digest if at least one of
# the user's digest preferences matches that frequency.
# Simple approach: include all pending if user has any pref with this frequency.
has_freq_pref = NotificationPreference.query.filter_by(
user_id=user_id,
digest_mode=True,
digest_frequency=frequency,
email_enabled=True,
).first()
if not has_freq_pref:
continue
notifications = Notification.query.filter_by(
user_id=user_id,
digest_pending=True,
).order_by(Notification.created_at.asc()).all()
if not notifications:
continue
try:
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
sender = current_app.config.get(
'MAIL_DEFAULT_SENDER',
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
)
html_body = render_template_string(
_EMAIL_HTML_DIGEST,
notifications=notifications,
frequency=frequency,
base_url=base_url,
)
text_body = render_template_string(
_EMAIL_TEXT_DIGEST,
notifications=notifications,
frequency=frequency,
base_url=base_url,
)
msg = Message(
subject = f'[JQC] Your {frequency.title()} Notification Digest '
f'({len(notifications)} update{"s" if len(notifications) != 1 else ""})',
sender = sender,
recipients = [user.email],
body = text_body,
html = html_body,
)
mail.send(msg)
# Clear the pending flag on all notifications just sent
for n in notifications:
n.digest_pending = False
db.session.commit()
sent_count += 1
logger.info(
'DIGEST EMAIL SENT | to=%s | frequency=%s | count=%s',
user.email, frequency, len(notifications),
)
except Exception as exc:
logger.error(
'DIGEST EMAIL FAILED | to=%s | frequency=%s | error=%s',
user.email, frequency, exc,
)
return sent_count
# ── Matrix-driven broadcast helpers ───────────────────────────────────────────
def notify_by_matrix(
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,
):
"""
Dispatch in-app + email notifications for a broadcast event according
to the admin-configured notification matrix.
For each enabled role in the matrix, all active users with that role
are notified (optionally scoped to facility via CustomerAssignment for
the 'customer' role). Custom email addresses are sent a plain email
without creating an in-app Notification record.
Parameters
----------
event_type : One of the MATRIX_EVENTS keys from notification_matrix.
title : Short notification headline.
body : Full notification body.
link : Relative URL for 'View Details'.
issue_id : FK to issues.id (optional).
inspection_id : FK to inspections.id (optional).
facility_id : Used to scope 'customer' role to assigned facility.
exclude_user_ids : Set of user IDs to skip (e.g. the actor themselves).
"""
from app.models.notification_matrix import (
is_enabled, get_custom_emails_for, MATRIX_ROLES,
)
from app.models.user import User
exclude = set(exclude_user_ids or [])
notified = set() # deduplicate across roles
role_to_db = {
'admin': 'admin',
'director': 'director',
'inspector': 'inspector',
'project_manager': 'project_manager',
'customer': 'customer',
}
logger.info('MATRIX NOTIFY START | event=%s | exclude=%s', event_type, exclude)
for role_key, _ in MATRIX_ROLES:
if role_key == 'custom':
continue # handled separately below
enabled = is_enabled(event_type, role_key)
logger.info('MATRIX NOTIFY | event=%s | role=%s | enabled=%s',
event_type, role_key, enabled)
if not enabled:
continue
db_role = role_to_db.get(role_key)
if not db_role:
continue
users = User.query.filter_by(role=db_role, active=True).all()
logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s',
event_type, role_key, [u.username for u in users])
# Scope customer role to facility if provided
if role_key == 'customer' and facility_id:
from app.utils.notifications import notify_customers_for_facility
notify_customers_for_facility(
facility_id = facility_id,
event_type = event_type,
title = title,
body = body,
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
)
continue # notify_customers_for_facility handles dedup internally
for user in users:
if user.id in exclude or user.id in notified:
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, # matrix is the authority for broadcasts
)
notified.add(user.id)
# ── Custom email recipients ───────────────────────────────────────────
custom_emails = get_custom_emails_for(event_type)
for email in custom_emails:
_send_custom_email(email, title, body, link)
logger.info(
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
event_type, len(notified), len(custom_emails),
)
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:
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
sender = current_app.config.get(
'MAIL_DEFAULT_SENDER',
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
)
html_body = render_template_string(
_EMAIL_HTML_SINGLE, title=title, body=body,
link=link, base_url=base_url,
)
text_body = render_template_string(
_EMAIL_TEXT_SINGLE, title=title, body=body,
link=link, base_url=base_url,
)
msg = Message(
subject = f'[JQC] {title}',
sender = sender,
recipients = [to_email],
body = text_body,
html = html_body,
)
except Exception as exc:
logger.error('CUSTOM EMAIL BUILD FAILED | to=%s | error=%s', to_email, exc)
return
app = current_app._get_current_object()
def _send():
with app.app_context():
try:
mail.send(msg)
logger.info('CUSTOM EMAIL SENT | to=%s', to_email)
except Exception as exc:
logger.error('CUSTOM EMAIL FAILED | to=%s | error=%s', to_email, exc)
import threading
threading.Thread(target=_send, daemon=True).start()
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
"""
app/utils/scope.py
------------------
Facility-scoping utilities for the Janitorial QC portal.
get_customer_scope(user) -> list[int] | None
Facility IDs a customer may access via CustomerAssignment rows.
get_inspector_scope(user) -> list[int] | None
Facility IDs an inspector may access via InspectorAssignment rows.
Returns [] (empty list) when the inspector has no contract assignments,
meaning they see nothing (strict mode).
For non-customer / non-inspector roles both functions return None, signalling
that no facility-level scoping is required (full access applies).
"""
import logging
from app.models.project import CustomerAssignment
from app.models.facility import Facility
logger = logging.getLogger(__name__)
def get_customer_scope(user) -> list[int] | None:
"""Return the list of facility IDs accessible to a customer user.
Parameters
----------
user : User
The currently authenticated user.
Returns
-------
list[int]
Facility IDs the customer may access. May be empty if no assignments
exist yet callers should treat an empty list as "no access".
None
Returned for non-customer roles, indicating unrestricted access.
"""
if user.role != 'customer':
return None # no scoping needed for internal staff
assignments = CustomerAssignment.query.filter_by(user_id=user.id).all()
if not assignments:
return []
# Separate direct facility assignments from project-level assignments
direct_facility_ids = {a.facility_id for a in assignments if a.facility_id}
project_ids = {a.project_id for a in assignments if not a.facility_id}
facility_ids = set(direct_facility_ids)
# Single bulk query for all project-scoped facilities — replaces the
# previous per-assignment Facility.query loop (N+1 pattern).
if project_ids:
project_facilities = (
Facility.query
.filter(
Facility.project_id.in_(project_ids),
Facility.active == True,
)
.all()
)
for f in project_facilities:
facility_ids.add(f.id)
logger.debug(
'SCOPE | customer_scope | user_id=%s username=%s facility_ids=%s',
user.id, user.username, sorted(facility_ids),
)
return sorted(facility_ids)
def get_inspector_scope(user) -> list[int] | None:
"""Return the list of facility IDs accessible to a contract-scoped inspector.
Parameters
----------
user : User
The currently authenticated user.
Returns
-------
list[int]
Facility IDs the inspector may access. An empty list means the
inspector has no contract assignments and should see nothing.
None
Returned for non-inspector roles, indicating unrestricted access.
"""
if user.role != 'inspector':
return None
from app.models.inspector_assignment import InspectorAssignment
project_ids = [
a.project_id
for a in InspectorAssignment.query.filter_by(user_id=user.id).all()
]
if not project_ids:
return [] # strict: no assignments = no access
facility_ids = [
f.id for f in Facility.query.filter(
Facility.project_id.in_(project_ids),
Facility.active == True,
).all()
]
logger.debug(
'SCOPE | inspector_scope | user_id=%s username=%s facility_ids=%s',
user.id, user.username, sorted(facility_ids),
)
return sorted(facility_ids)
+345
View File
@@ -0,0 +1,345 @@
"""
sla.py
------
Issue SLA (Service Level Agreement) helpers.
SLA thresholds define the maximum number of hours an issue of a given
severity may remain unresolved before it is considered breached.
Statuses
--------
ok within the allowed window
at_risk past 75% of the allowed window but not yet breached
breached past the deadline
None issue is already resolved; SLA no longer applies
"""
from datetime import timedelta
from app.utils.time_utils import now_eastern
# ── Configurable thresholds (hours) ──────────────────────────────────────────
SLA_HOURS = {
'critical': 4,
'high': 24,
'medium': 72,
'low': 120, # 5 days
}
# Fraction of the window at which an issue becomes "at risk"
AT_RISK_THRESHOLD = 0.75
def sla_deadline(issue):
"""
Return the datetime by which the issue must be resolved,
or None if the severity is not recognized.
"""
hours = SLA_HOURS.get(issue.severity)
if hours is None:
return None
return issue.reported_at + timedelta(hours=hours)
def sla_status(issue):
"""
Return one of: 'ok', 'at_risk', 'breached', or None.
None is returned when the issue is already resolved SLA no longer
applies. None is also returned for unrecognized severity values.
"""
if issue.status == 'resolved':
return None
hours = SLA_HOURS.get(issue.severity)
if hours is None:
return None
deadline = issue.reported_at + timedelta(hours=hours)
at_risk_at = issue.reported_at + timedelta(hours=hours * AT_RISK_THRESHOLD)
now = now_eastern()
if now >= deadline:
return 'breached'
if now >= at_risk_at:
return 'at_risk'
return 'ok'
def sla_hours_remaining(issue):
"""
Return the number of hours remaining before the SLA deadline.
Negative values indicate the deadline has already passed.
Returns None for resolved issues or unrecognized severities.
"""
if issue.status == 'resolved':
return None
deadline = sla_deadline(issue)
if deadline is None:
return None
delta = deadline - now_eastern()
return round(delta.total_seconds() / 3600, 1)
# ── SLA alert dispatcher ──────────────────────────────────────────────────────
def send_sla_alerts():
"""
Check all open/in-progress issues for SLA breaches or at-risk status and
dispatch in-app + email notifications to the appropriate recipients.
Recipient rules:
- Admins always receive alerts
- If the issue is assigned, the assignee also receives an alert
- All followers of the issue also receive an alert
- Deduplication ensures each user gets at most one notification per call
Deduplication across cron runs:
- Issue.sla_notified tracks the highest alert level already sent
('at_risk' or 'breached'). A notification is only sent once per level.
- 'breached' supersedes 'at_risk': if a user was already notified
at-risk, they will receive a second notification when it breaches.
Returns the number of notifications created.
"""
from flask import current_app, url_for
from app import db
from app.models.issue import Issue
from app.models.user import User
from app.utils.notifications import notify, notify_by_matrix
import logging
logger = logging.getLogger(__name__)
# yield_per streams rows in batches of 100 rather than loading all open
# issues into memory at once. At current scale this is a no-op difference,
# but it prevents a memory spike if the issue count grows large.
open_issues = Issue.query.filter(
Issue.status.in_(['open', 'in_progress', 'pending_verification'])
).yield_per(100)
total_sent = 0
for issue in open_issues:
status = sla_status(issue)
# Only act on at_risk or breached
if status not in ('at_risk', 'breached'):
continue
# Skip if this level (or higher) was already notified
already = issue.sla_notified
if already == 'breached':
continue # highest level already sent
if already == 'at_risk' and status == 'at_risk':
continue # at_risk already sent, not yet breached
# Compose message
hrs = sla_hours_remaining(issue)
deadline = sla_deadline(issue)
facility_name = issue.resolved_facility.name if issue.resolved_facility else "\u2014"
if status == 'breached':
title = f'🚨 SLA Breached — Issue #{issue.id} ({issue.severity.title()})'
body = (
f'Issue #{issue.id} at {facility_name} '
f'has breached its SLA deadline. '
f'Severity: {issue.severity.title()}. '
f'Deadline was {deadline.strftime("%Y-%m-%d %H:%M") if deadline else "N/A"}. '
f'Current status: {issue.status.replace("_", " ").title()}.'
)
else: # at_risk
title = f'⚠️ SLA At Risk — Issue #{issue.id} ({issue.severity.title()})'
body = (
f'Issue #{issue.id} at {facility_name} '
f'is approaching its SLA deadline with approximately '
f'{abs(hrs):.1f}h remaining. '
f'Severity: {issue.severity.title()}. '
f'Deadline: {deadline.strftime("%Y-%m-%d %H:%M") if deadline else "N/A"}. '
f'Current status: {issue.status.replace("_", " ").title()}.'
)
try:
link = url_for('issues.view', issue_id=issue.id)
except RuntimeError:
link = f'/issues/{issue.id}'
# Always notify the assignee and followers (implicit, not matrix-controlled)
implicit_notified = set()
if issue.assigned_to and issue.assigned_user:
notify(
recipient = issue.assigned_user,
title = title,
body = body,
link = link,
issue_id = issue.id,
event_type = 'sla_alert',
send_email = True,
)
implicit_notified.add(issue.assigned_user.id)
total_sent += 1
for follower_link in issue.followers.all():
if follower_link.user_id not in implicit_notified:
notify(
recipient = follower_link.user,
title = title,
body = body,
link = link,
issue_id = issue.id,
event_type = 'sla_alert',
send_email = True,
)
implicit_notified.add(follower_link.user_id)
total_sent += 1
# Matrix-controlled broadcast (admin, supervisor, etc.)
notify_by_matrix(
event_type = 'sla_alert',
title = title,
body = body,
link = link,
issue_id = issue.id,
exclude_user_ids = implicit_notified,
)
total_sent += 1 # approximate — matrix count not returned
# Mark this issue as notified at the current level
issue.sla_notified = status
logger.info(
'SLA ALERT SENT | issue_id=%s | status=%s',
issue.id, status,
)
if total_sent:
db.session.commit()
return total_sent
# ── Score trend alert dispatcher ──────────────────────────────────────────────
# Default drop threshold in percentage points that triggers an alert.
SCORE_DROP_THRESHOLD = 5.0
def send_score_alerts(threshold=SCORE_DROP_THRESHOLD):
"""
Compare each active facility's avg inspection score for the last 30 days
against the prior 30-day period. When the score has dropped by more than
*threshold* points, dispatch an in-app + email alert via notify_by_matrix
and record the alert in facility_score_alerts for deduplication.
A facility is skipped if it already received an alert within the last 24
hours (prevents repeat storms on persistent low scores).
Returns the number of alert notifications dispatched.
"""
from datetime import timedelta
from flask import current_app, url_for
from sqlalchemy import func
from app import db
from app.models.facility import Facility
from app.models.inspection import Inspection
from app.models.score_alert import FacilityScoreAlert
from app.utils.notifications import notify_by_matrix
import logging
logger = logging.getLogger(__name__)
now = now_eastern()
cur_start = now - timedelta(days=30)
pri_start = now - timedelta(days=60)
pri_end = cur_start
# Current-period avg score per facility
cur_rows = db.session.query(
Facility.id,
Facility.name,
func.avg(Inspection.overall_score).label('avg'),
).join(Inspection, Facility.id == Inspection.facility_id)\
.filter(
Facility.active == True,
Inspection.inspection_date >= cur_start,
Inspection.inspection_date <= now,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
).group_by(Facility.id, Facility.name).all()
# Prior-period avg score per facility
pri_rows = db.session.query(
Facility.id,
func.avg(Inspection.overall_score).label('avg'),
).join(Inspection, Facility.id == Inspection.facility_id)\
.filter(
Facility.active == True,
Inspection.inspection_date >= pri_start,
Inspection.inspection_date <= pri_end,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
).group_by(Facility.id).all()
prior_map = {r.id: float(r.avg) for r in pri_rows}
# Facilities that already received an alert in the last 24 hours
cutoff = now - timedelta(hours=24)
recent_alerts = db.session.query(FacilityScoreAlert.facility_id)\
.filter(FacilityScoreAlert.sent_at >= cutoff).all()
already_alerted = {r.facility_id for r in recent_alerts}
total_sent = 0
for row in cur_rows:
fid = row.id
cur_avg = float(row.avg)
pri_avg = prior_map.get(fid)
if pri_avg is None:
continue # no prior period data — nothing to compare
delta = cur_avg - pri_avg # negative = score dropped
if delta >= -threshold:
continue # drop is within acceptable range
if fid in already_alerted:
logger.debug('SCORE ALERT SKIPPED (already alerted) | facility_id=%s', fid)
continue
title = f'📉 Score Drop Alert — {row.name}'
body = (
f'{row.name} avg score has dropped {abs(delta):.1f} points '
f'(from {pri_avg:.1f}% to {cur_avg:.1f}%) over the last 30 days '
f'vs. the prior 30-day period.'
)
try:
link = url_for('reports.facility_scorecard', facility_id=fid)
except RuntimeError:
link = f'/reports/facility/{fid}/scorecard'
notify_by_matrix(
event_type = 'score_alert',
title = title,
body = body,
link = link,
)
total_sent += 1
db.session.add(FacilityScoreAlert(
facility_id = fid,
sent_at = now,
current_avg = round(cur_avg, 2),
prior_avg = round(pri_avg, 2),
delta = round(delta, 2),
))
logger.info(
'SCORE ALERT SENT | facility_id=%s | facility=%s | cur=%.1f | prior=%.1f | delta=%.1f',
fid, row.name, cur_avg, pri_avg, delta,
)
if total_sent:
db.session.commit()
return total_sent
+24
View File
@@ -0,0 +1,24 @@
"""
time_utils.py
-------------
Centralised time helpers for the JQC application.
All timestamps are stored as Eastern Time (America/New_York) so that
displayed dates reflect the local business timezone without any
conversion layer in templates or reports.
"""
from datetime import datetime
import pytz
EASTERN = pytz.timezone('America/New_York')
def now_eastern() -> datetime:
"""Return the current wall-clock time in US/Eastern (naive datetime).
Stored as a naive datetime in the database so that existing DateTime
columns require no schema change. The value is always Eastern local
time (auto-adjusts for EDT / EST).
"""
return datetime.now(tz=EASTERN).replace(tzinfo=None)