First commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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.')
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user