Updated functionalities 2
This commit is contained in:
@@ -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
@@ -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
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+25
-1
@@ -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,
|
||||
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) ──────────────
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -224,3 +224,45 @@ def check_sla():
|
||||
|
||||
logger.info('SLA CHECK TRIGGERED | notifications_sent=%s', 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})
|
||||
@@ -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>
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
<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")
|
||||
@@ -4,6 +4,7 @@ Flask-Login
|
||||
Flask-WTF
|
||||
Flask-Mail
|
||||
Flask-Migrate
|
||||
Flask-Limiter
|
||||
PyMySQL
|
||||
cryptography
|
||||
python-dotenv
|
||||
|
||||
Reference in New Issue
Block a user