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_mail import Mail
from flask_wtf.csrf import CSRFProtect
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from config import config
import os
import logging
@@ -14,6 +16,11 @@ login_manager = LoginManager()
migrate = Migrate()
mail = Mail()
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'):
@@ -25,6 +32,7 @@ def create_app(config_name='default'):
migrate.init_app(app, db)
mail.init_app(app)
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_message = 'Please log in to access this page.'
+20 -1
View File
@@ -31,7 +31,7 @@ GET /api/v1/auth/me
import logging
from flask import Blueprint, request, g
from app import db
from app import db, limiter
from app.models.user import User
from app.models.api_token import RefreshToken, DeviceToken
from app.api.errors import api_ok, api_error
@@ -59,6 +59,7 @@ def _user_payload(user: User) -> dict:
# ── Login ─────────────────────────────────────────────────────────────────────
@bp.route('/auth/login', methods=['POST'])
@limiter.limit('10 per minute; 3 per second')
def login():
"""
Authenticate with username + password.
@@ -116,6 +117,23 @@ def login():
)
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,
f'source=mobile_api; device_id={device_id}')
@@ -134,6 +152,7 @@ def login():
# ── Refresh ───────────────────────────────────────────────────────────────────
@bp.route('/auth/refresh', methods=['POST'])
@limiter.limit('30 per minute; 5 per second')
def refresh():
"""
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_login import login_user, logout_user, login_required, current_user
from urllib.parse import urlparse
from app import db
from app import db, limiter
from app.models.user import User
from app.utils.forms import LoginForm, UserForm, ProfileForm
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'])
@limiter.limit('20 per minute; 5 per second')
def login():
if current_user.is_authenticated:
return redirect(url_for('dashboard.index'))
@@ -254,7 +255,7 @@ def toggle_active(user_id):
f'account {action_label} by {current_user.username}',
)
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 ───────────────────────────────────────────────────────
+29 -5
View File
@@ -13,6 +13,7 @@ Provides a single screen to:
"""
import logging
from urllib.parse import urlparse
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user
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.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__)
bp = Blueprint('customers', __name__, url_prefix='/customers')
@@ -88,12 +100,24 @@ def index():
# All active projects for the assignment modal
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(
'customers/index.html',
customers = customers,
assignment_map = assignment_map,
scope_map = scope_map,
projects = projects,
customers = customers,
assignment_map = assignment_map,
scope_map = scope_map,
projects = projects,
expired_invitations = expired_invitations,
)
@@ -467,7 +491,7 @@ def toggle_active(customer_id):
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
f'account {label} via customer_mgmt by {current_user.username}')
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) ──────────────
+2 -2
View File
@@ -66,10 +66,10 @@ def index():
open_issues_q = open_issues_q.join(
Area, Issue.area_id == Area.id
).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 = len(open_issues_all)
severity_breakdown = {
'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'),
+43 -1
View File
@@ -223,4 +223,46 @@ def check_sla():
sent = send_sla_alerts()
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-time { font-size: 0.7rem; color: #999; }
.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>
</head>
<body>
+29
View File
@@ -18,6 +18,35 @@
</div>
{% 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-body p-0">
<div class="table-responsive">
@@ -21,6 +21,20 @@
<form method="post">
<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-header bg-light">
<div class="row fw-semibold text-muted" style="font-size:.8rem;">
@@ -292,6 +306,42 @@
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>
{% 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-Mail
Flask-Migrate
Flask-Limiter
PyMySQL
cryptography
python-dotenv