Updated functionalities 2
This commit is contained in:
@@ -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
@@ -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
@@ -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 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -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) ──────────────
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -224,3 +224,45 @@ def check_sla():
|
|||||||
|
|
||||||
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})
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') }}
|
||||||
|
|
||||||
|
<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")
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user