Updated functionalities 2

This commit is contained in:
2026-04-25 12:41:24 -04:00
parent a515df6a02
commit 111ec5c740
11 changed files with 280 additions and 11 deletions
+8
View File
@@ -4,6 +4,8 @@ from flask_login import LoginManager
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_mail import Mail from flask_mail import Mail
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from config import config from config import config
import os import os
import logging import logging
@@ -14,6 +16,11 @@ login_manager = LoginManager()
migrate = Migrate() migrate = Migrate()
mail = Mail() mail = Mail()
csrf = CSRFProtect() # initialized here; .init_app() called in create_app() csrf = CSRFProtect() # initialized here; .init_app() called in create_app()
limiter = Limiter(
key_func = get_remote_address,
default_limits = [], # no global limit — applied per-route only
storage_uri = 'memory://', # in-process; swap for 'redis://...' in multi-worker setups
)
def create_app(config_name='default'): def create_app(config_name='default'):
@@ -25,6 +32,7 @@ def create_app(config_name='default'):
migrate.init_app(app, db) migrate.init_app(app, db)
mail.init_app(app) mail.init_app(app)
csrf.init_app(app) # enables CSRF protection for all web routes csrf.init_app(app) # enables CSRF protection for all web routes
limiter.init_app(app) # rate limiting — applied per-route via @limiter.limit()
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.' login_manager.login_message = 'Please log in to access this page.'
+20 -1
View File
@@ -31,7 +31,7 @@ GET /api/v1/auth/me
import logging import logging
from flask import Blueprint, request, g from flask import Blueprint, request, g
from app import db from app import db, limiter
from app.models.user import User from app.models.user import User
from app.models.api_token import RefreshToken, DeviceToken from app.models.api_token import RefreshToken, DeviceToken
from app.api.errors import api_ok, api_error from app.api.errors import api_ok, api_error
@@ -59,6 +59,7 @@ def _user_payload(user: User) -> dict:
# ── Login ───────────────────────────────────────────────────────────────────── # ── Login ─────────────────────────────────────────────────────────────────────
@bp.route('/auth/login', methods=['POST']) @bp.route('/auth/login', methods=['POST'])
@limiter.limit('10 per minute; 3 per second')
def login(): def login():
""" """
Authenticate with username + password. Authenticate with username + password.
@@ -116,6 +117,23 @@ def login():
) )
db.session.commit() db.session.commit()
# Passive cleanup — delete expired/revoked tokens for this user only
# so the table never accumulates dead rows without a cron dependency.
try:
from app.utils.time_utils import now_eastern
now = now_eastern()
RefreshToken.query.filter(
RefreshToken.user_id == user.id,
db.or_(
RefreshToken.expires_at < now,
RefreshToken.revoked == True, # noqa: E712
),
).delete(synchronize_session=False)
db.session.commit()
except Exception as _cleanup_exc:
logger.warning('API LOGIN passive token cleanup failed: %s', _cleanup_exc)
db.session.rollback()
log_action(ACTION_LOGIN, 'User', user.id, user.username, log_action(ACTION_LOGIN, 'User', user.id, user.username,
f'source=mobile_api; device_id={device_id}') f'source=mobile_api; device_id={device_id}')
@@ -134,6 +152,7 @@ def login():
# ── Refresh ─────────────────────────────────────────────────────────────────── # ── Refresh ───────────────────────────────────────────────────────────────────
@bp.route('/auth/refresh', methods=['POST']) @bp.route('/auth/refresh', methods=['POST'])
@limiter.limit('30 per minute; 5 per second')
def refresh(): def refresh():
""" """
Exchange a valid refresh token for a new access token. Exchange a valid refresh token for a new access token.
+3 -2
View File
@@ -1,7 +1,7 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from urllib.parse import urlparse from urllib.parse import urlparse
from app import db from app import db, limiter
from app.models.user import User from app.models.user import User
from app.utils.forms import LoginForm, UserForm, ProfileForm from app.utils.forms import LoginForm, UserForm, ProfileForm
from app.utils.decorators import admin_required, supervisor_required from app.utils.decorators import admin_required, supervisor_required
@@ -30,6 +30,7 @@ def _safe_next(next_url: str | None) -> str:
@bp.route('/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
@limiter.limit('20 per minute; 5 per second')
def login(): def login():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -254,7 +255,7 @@ def toggle_active(user_id):
f'account {action_label} by {current_user.username}', f'account {action_label} by {current_user.username}',
) )
flash(f'User {user.username} has been {action_label}.', 'success') flash(f'User {user.username} has been {action_label}.', 'success')
return redirect(request.referrer or url_for('auth.list_users')) return redirect(_safe_next(request.referrer) or url_for('auth.list_users'))
# ── Notification Matrix ─────────────────────────────────────────────────────── # ── Notification Matrix ───────────────────────────────────────────────────────
+29 -5
View File
@@ -13,6 +13,7 @@ Provides a single screen to:
""" """
import logging import logging
from urllib.parse import urlparse
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
@@ -24,6 +25,17 @@ from app.utils.decorators import admin_required, supervisor_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope
def _safe_referrer(fallback: str) -> str:
"""Return request.referrer only if it is a safe relative URL, else fallback."""
ref = request.referrer
if not ref:
return fallback
parsed = urlparse(ref)
if parsed.netloc or parsed.scheme:
return fallback
return ref
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
bp = Blueprint('customers', __name__, url_prefix='/customers') bp = Blueprint('customers', __name__, url_prefix='/customers')
@@ -88,12 +100,24 @@ def index():
# All active projects for the assignment modal # All active projects for the assignment modal
projects = Project.query.filter_by(active=True).order_by(Project.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all()
# ── Expired pending-setup invitations ─────────────────────────────────
# Surface customer accounts whose invitation token has expired but
# password_set is still False — they need a fresh invite to log in.
from app.utils.time_utils import now_eastern
expired_invitations = [
c for c in customers
if not c.password_set
and c.set_password_token_expires is not None
and c.set_password_token_expires < now_eastern()
]
return render_template( return render_template(
'customers/index.html', 'customers/index.html',
customers = customers, customers = customers,
assignment_map = assignment_map, assignment_map = assignment_map,
scope_map = scope_map, scope_map = scope_map,
projects = projects, projects = projects,
expired_invitations = expired_invitations,
) )
@@ -467,7 +491,7 @@ def toggle_active(customer_id):
log_action(ACTION_UPDATE, 'User', customer.id, customer.username, log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
f'account {label} via customer_mgmt by {current_user.username}') f'account {label} via customer_mgmt by {current_user.username}')
flash(f'Customer "{customer.username}" has been {label}.', 'success') flash(f'Customer "{customer.username}" has been {label}.', 'success')
return redirect(request.referrer or url_for('customers.index')) return redirect(_safe_referrer(url_for('customers.index')))
# ── AJAX: facilities for a project (used by add-assignment form) ────────────── # ── AJAX: facilities for a project (used by add-assignment form) ──────────────
+2 -2
View File
@@ -66,10 +66,10 @@ def index():
open_issues_q = open_issues_q.join( open_issues_q = open_issues_q.join(
Area, Issue.area_id == Area.id Area, Issue.area_id == Area.id
).filter(Area.facility_id.in_(customer_facility_ids)) ).filter(Area.facility_id.in_(customer_facility_ids))
open_issues = open_issues_q.count()
# Severity breakdown for the open issues card # Single query — derive count from the list to avoid hitting the DB twice
open_issues_all = open_issues_q.all() open_issues_all = open_issues_q.all()
open_issues = len(open_issues_all)
severity_breakdown = { severity_breakdown = {
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'), 'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
'high': sum(1 for i in open_issues_all if i.severity == 'high'), 'high': sum(1 for i in open_issues_all if i.severity == 'high'),
+43 -1
View File
@@ -223,4 +223,46 @@ def check_sla():
sent = send_sla_alerts() sent = send_sla_alerts()
logger.info('SLA CHECK TRIGGERED | notifications_sent=%s', sent) logger.info('SLA CHECK TRIGGERED | notifications_sent=%s', sent)
return jsonify({'ok': True, 'notifications_sent': sent}) return jsonify({'ok': True, 'notifications_sent': sent})
# ── Expired token cleanup (called by cron) ────────────────────────────────────
@bp.route('/cleanup-tokens', methods=['POST'])
@csrf.exempt
def cleanup_tokens():
"""Purge expired and revoked refresh tokens from api_refresh_tokens.
Safe to run frequently only deletes rows where expires_at has passed
OR revoked=True. Keeps the table lean without touching live sessions.
Recommended cron schedule nightly is sufficient:
0 3 * * * curl -s -X POST https://yourdomain.com/notifications/cleanup-tokens \\
-d "token=YOUR_DIGEST_SECRET"
"""
token = request.form.get('token') or request.args.get('token')
expected = current_app.config.get('DIGEST_SECRET')
if not expected or token != expected:
logger.warning('TOKEN CLEANUP REJECTED | bad or missing token')
abort(403)
from app.models.api_token import RefreshToken
from app.utils.time_utils import now_eastern
now = now_eastern()
deleted = (
RefreshToken.query
.filter(
db.or_(
RefreshToken.expires_at < now,
RefreshToken.revoked == True, # noqa: E712
)
)
.delete(synchronize_session=False)
)
db.session.commit()
logger.info('TOKEN CLEANUP | deleted=%s expired/revoked rows', deleted)
return jsonify({'ok': True, 'deleted': deleted})
+13
View File
@@ -43,6 +43,19 @@
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; } .notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; } .notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; } .notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
/* ── Active nav tab ── */
.navbar-dark .navbar-nav .nav-link.active {
background-color: rgba(255, 255, 255, 0.18);
color: #ffffff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
</style> </style>
</head> </head>
<body> <body>
+29
View File
@@ -18,6 +18,35 @@
</div> </div>
{% if customers %} {% if customers %}
{% if expired_invitations %}
<div class="alert alert-warning d-flex align-items-start gap-3 mb-3" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-5 mt-1 flex-shrink-0"></i>
<div>
<strong>{{ expired_invitations|length }} invitation{{ 's' if expired_invitations|length != 1 else '' }} expired</strong>
— the following customer{{ 's' if expired_invitations|length != 1 else '' }} never completed account setup
and {{ 'their' if expired_invitations|length != 1 else 'their' }} link has expired:
<ul class="mb-2 mt-1">
{% for c in expired_invitations %}
<li>
<strong>{{ c.display_name }}</strong> ({{ c.email }}) —
expired {{ c.set_password_token_expires.strftime('%Y-%m-%d %H:%M') }}
&nbsp;
<form method="POST" action="{{ url_for('customers.resend_invite', customer_id=c.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-warning py-0 px-2"
style="font-size:.75rem;">
<i class="bi bi-send me-1"></i>Resend
</button>
</form>
</li>
{% endfor %}
</ul>
<span class="text-muted small">Resend a fresh 72-hour invitation link or delete the account if it is no longer needed.</span>
</div>
</div>
{% endif %}
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
@@ -21,6 +21,20 @@
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# ── Pause all emails banner ── #}
{% set any_email_on = prefs_map.values() | selectattr('email_enabled') | list | length > 0
or prefs_map | length == 0 %}
<div class="alert alert-light border d-flex align-items-center justify-content-between py-2 mb-3">
<div>
<i class="bi bi-pause-circle me-2 text-secondary"></i>
<strong>Pause all email notifications</strong>
<span class="text-muted ms-2" style="font-size:.85rem;">— in-app notifications are unaffected</span>
</div>
<button type="button" id="pauseAllBtn" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pause-fill me-1"></i>Pause All Emails
</button>
</div>
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-light"> <div class="card-header bg-light">
<div class="row fw-semibold text-muted" style="font-size:.8rem;"> <div class="row fw-semibold text-muted" style="font-size:.8rem;">
@@ -292,6 +306,42 @@
badge.textContent = 'Live'; badge.textContent = 'Live';
} }
} }
// ── Pause all emails button ──────────────────────────────────────────────
var pauseBtn = document.getElementById('pauseAllBtn');
if (pauseBtn) {
pauseBtn.addEventListener('click', function () {
var allEmailToggles = document.querySelectorAll('.email-toggle');
var anyOn = Array.from(allEmailToggles).some(function (c) { return c.checked; });
allEmailToggles.forEach(function (emailChk) {
var event = emailChk.dataset.event;
var digestChk = document.getElementById('digest_' + event);
var freqSelect = document.getElementById('freq_' + event);
var badge = document.getElementById('badge-' + event);
if (anyOn) {
// Pause: turn everything off
emailChk.checked = false;
digestChk.checked = false;
digestChk.disabled = true;
freqSelect.disabled = true;
updateBadge(badge, false, false);
} else {
// Resume: re-enable email (digest stays off until user opts back in)
emailChk.checked = true;
digestChk.disabled = false;
updateBadge(badge, true, false);
}
});
// Update button label to reflect new state
var nowPaused = !anyOn ? false : true;
pauseBtn.innerHTML = nowPaused
? '<i class="bi bi-play-fill me-1"></i>Resume All Emails'
: '<i class="bi bi-pause-fill me-1"></i>Pause All Emails';
});
}
})(); })();
</script> </script>
{% endblock %} {% endblock %}
@@ -0,0 +1,82 @@
"""Phase 12: Add performance indexes on high-filter columns
Revision ID: phase12_performance_indexes
Revises: phase11_director_role
Create Date: 2026-04-25
Rationale
---------
The following columns are filtered or ordered on every page load but had no
DB index, causing full table scans as row counts grow:
inspections
- status filtered on list/dashboard/SLA queries
- facility_id filtered for customer-scoped views and reports
- inspector_id filtered for inspector-scoped views
- inspection_date used in all trend/score queries (ORDER BY, range filter)
issues
- status filtered on every issues list/dashboard load
- severity filtered in dashboard breakdown and issues list
- assigned_to filtered for inspector-scoped views
- reported_at used for ordering
All indexes are created with IF NOT EXISTS so the migration is safe to re-run.
"""
from alembic import op
revision = 'phase12_performance_indexes'
down_revision = 'phase11_director_role'
branch_labels = None
depends_on = None
def upgrade():
# ── inspections ──────────────────────────────────────────────────────────
op.execute(
"CREATE INDEX IF NOT EXISTS ix_inspections_status "
"ON inspections (status)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_inspections_facility_id "
"ON inspections (facility_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_inspections_inspector_id "
"ON inspections (inspector_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_inspections_inspection_date "
"ON inspections (inspection_date)"
)
# ── issues ───────────────────────────────────────────────────────────────
op.execute(
"CREATE INDEX IF NOT EXISTS ix_issues_status "
"ON issues (status)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_issues_severity "
"ON issues (severity)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_issues_assigned_to "
"ON issues (assigned_to)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_issues_reported_at "
"ON issues (reported_at)"
)
def downgrade():
op.execute("DROP INDEX IF EXISTS ix_inspections_status ON inspections")
op.execute("DROP INDEX IF EXISTS ix_inspections_facility_id ON inspections")
op.execute("DROP INDEX IF EXISTS ix_inspections_inspector_id ON inspections")
op.execute("DROP INDEX IF EXISTS ix_inspections_inspection_date ON inspections")
op.execute("DROP INDEX IF EXISTS ix_issues_status ON issues")
op.execute("DROP INDEX IF EXISTS ix_issues_severity ON issues")
op.execute("DROP INDEX IF EXISTS ix_issues_assigned_to ON issues")
op.execute("DROP INDEX IF EXISTS ix_issues_reported_at ON issues")
+1
View File
@@ -4,6 +4,7 @@ Flask-Login
Flask-WTF Flask-WTF
Flask-Mail Flask-Mail
Flask-Migrate Flask-Migrate
Flask-Limiter
PyMySQL PyMySQL
cryptography cryptography
python-dotenv python-dotenv