Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
20
shopdb/utils/__init__.py
Normal file
20
shopdb/utils/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Utility modules."""
|
||||
|
||||
from .responses import (
|
||||
api_response,
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from .pagination import get_pagination_params, paginate_query
|
||||
|
||||
__all__ = [
|
||||
'api_response',
|
||||
'success_response',
|
||||
'error_response',
|
||||
'paginated_response',
|
||||
'ErrorCodes',
|
||||
'get_pagination_params',
|
||||
'paginate_query'
|
||||
]
|
||||
153
shopdb/utils/apitoken_auth.py
Normal file
153
shopdb/utils/apitoken_auth.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Personal API token (PAT) authentication shim.
|
||||
|
||||
A request may send `Authorization: Bearer shopdb_pat_...`. This is recognized
|
||||
BEFORE any JWT decode: a before_request hook validates the PAT (hash lookup,
|
||||
active, not expired, active owner) and, on success, mints a short internal
|
||||
request-scoped JWT for the token's user and swaps it into the request's
|
||||
Authorization header.
|
||||
|
||||
Why mint a JWT instead of only stashing the user on g: every write route in
|
||||
this app stacks a mandatory @jwt_required() ABOVE @require_permission /
|
||||
@require_role. That mandatory decorator decodes the Authorization header
|
||||
itself, so the ONLY way a PAT reaches the whole existing auth+authz stack
|
||||
(jwt_required, require_permission, require_role, import_mode, current_user,
|
||||
get_jwt_identity) unchanged is to present a genuine JWT downstream. The minted
|
||||
token lives only in this request's environ and is never returned to the client.
|
||||
|
||||
Result: a PAT authenticates any route a login JWT would, acting as its owner,
|
||||
with zero changes to the authz decorators or import-mode helpers.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import g, request
|
||||
from flask_jwt_extended import create_access_token
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
|
||||
from shopdb.utils.responses import error_response, ErrorCodes
|
||||
|
||||
|
||||
# Only rewrite lastusedat when it is older than this, to avoid a DB write on
|
||||
# every single request a busy integration makes.
|
||||
_LASTUSED_THROTTLE_SECONDS = 60
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _extract_pat_secret():
|
||||
"""Return the PAT secret from the Authorization header, or None."""
|
||||
header = request.headers.get('Authorization', '')
|
||||
parts = header.split()
|
||||
if len(parts) == 2 and parts[0] == 'Bearer' \
|
||||
and parts[1].startswith(TOKEN_SECRET_PREFIX):
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
|
||||
def resolve_api_token(secret):
|
||||
"""Validate a PAT secret. Return (token, user) or None.
|
||||
|
||||
Shared validator: hash lookup, active token, unexpired, active owner. The
|
||||
before_request shim and the collector API (which does not decode JWT) both
|
||||
call this so the checks live in one place.
|
||||
"""
|
||||
from shopdb.core.models import User
|
||||
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret), isactive=True).first()
|
||||
if token is None or token.is_expired:
|
||||
return None
|
||||
user = db.session.get(User, token.userid)
|
||||
if user is None or not user.isactive:
|
||||
return None
|
||||
return token, user
|
||||
|
||||
|
||||
def touch_apitoken_lastused(token):
|
||||
"""Throttled lastusedat write. Independent commit; nothing else is pending
|
||||
this early in the request, so it cannot clobber route work."""
|
||||
now = _utcnow()
|
||||
if token.lastusedat is None \
|
||||
or (now - token.lastusedat).total_seconds() > _LASTUSED_THROTTLE_SECONDS:
|
||||
token.lastusedat = now
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def service_token_authorized(scope):
|
||||
"""True when the current request carries a managed token scoped for `scope`
|
||||
whose owner is active and holds that permission. Accepts X-API-Key or a
|
||||
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
|
||||
Touches lastusedat on success.
|
||||
|
||||
The single contract-surface entry point for unattended SERVICE tokens
|
||||
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
|
||||
token without reaching into core token internals. Returns False on any
|
||||
miss; the caller returns its own 401.
|
||||
"""
|
||||
from shopdb.core.models import User
|
||||
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
token = None
|
||||
if api_key and api_key.startswith(TOKEN_SECRET_PREFIX):
|
||||
resolved = resolve_api_token(api_key)
|
||||
token = resolved[0] if resolved else None
|
||||
else:
|
||||
tokenid = getattr(g, 'apitokenid', None)
|
||||
if tokenid is not None:
|
||||
token = db.session.get(ApiToken, tokenid)
|
||||
if token is None:
|
||||
return False
|
||||
scopelist = token.scopelist
|
||||
if not scopelist or scope not in scopelist:
|
||||
return False
|
||||
user = db.session.get(User, token.userid)
|
||||
if user is None or not user.isactive or not user.haspermission(scope):
|
||||
return False
|
||||
touch_apitoken_lastused(token)
|
||||
return True
|
||||
|
||||
|
||||
def install_apitoken_auth(app):
|
||||
"""Register the before_request PAT shim on the app."""
|
||||
|
||||
@app.before_request
|
||||
def _apitoken_before_request():
|
||||
secret = _extract_pat_secret()
|
||||
if secret is None:
|
||||
return
|
||||
resolved = resolve_api_token(secret)
|
||||
if resolved is None:
|
||||
# The caller clearly meant to use a PAT (shopdb_pat_ prefix) but it
|
||||
# is unknown, revoked, or expired. Reject with a clear 401 instead
|
||||
# of letting the JWT decoder emit a confusing 422 on the non-JWT.
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Invalid, revoked, or expired API token',
|
||||
http_code=401)
|
||||
token, user = resolved
|
||||
|
||||
# Read claim inputs before the (possible) commit expires the instance.
|
||||
claims = {
|
||||
'username': user.username,
|
||||
'roles': [role.rolename for role in user.roles],
|
||||
}
|
||||
# A scoped token carries a patscopes claim; authz reads it to grant ONLY
|
||||
# the listed permissions and to deny role gates + import mode. Unscoped
|
||||
# tokens carry no such claim and mint exactly as a login JWT would.
|
||||
scopelist = token.scopelist
|
||||
if scopelist is not None:
|
||||
claims['patscopes'] = scopelist
|
||||
# Expose the token/user for audit and introspection if a handler wants it.
|
||||
g.apitokenid = token.tokenid
|
||||
g.apitokenuser = user
|
||||
|
||||
touch_apitoken_lastused(token)
|
||||
|
||||
# Mint a request-scoped JWT for the owner and swap it into the header
|
||||
# so the whole downstream auth stack authenticates as that user.
|
||||
access_token = create_access_token(
|
||||
identity=str(user.userid), additional_claims=claims)
|
||||
request.environ['HTTP_AUTHORIZATION'] = f'Bearer {access_token}'
|
||||
106
shopdb/utils/authz.py
Normal file
106
shopdb/utils/authz.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Authorization decorators for role and permission gating.
|
||||
|
||||
Authentication (is the caller logged in?) is handled by Flask-JWT-Extended's
|
||||
@jwt_required. Authorization (is the caller ALLOWED to do this?) is handled
|
||||
here. The two are separate concerns; a route needs both on any state-changing
|
||||
action.
|
||||
|
||||
These decorators call verify_jwt_in_request() themselves, so they work whether
|
||||
or not a separate @jwt_required is also present. The admin role bypasses every
|
||||
permission check (see User.haspermission), so an admin never needs individual
|
||||
permissions granted.
|
||||
|
||||
Usage:
|
||||
|
||||
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('assets.delete')
|
||||
def delete_asset(asset_id):
|
||||
...
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt
|
||||
|
||||
from shopdb.utils.responses import error_response, ErrorCodes
|
||||
|
||||
|
||||
def require_permission(permission_name: str):
|
||||
"""Gate a route behind a single permission. Admin role bypasses.
|
||||
|
||||
A scoped personal API token (patscopes claim present) does NOT bypass: it
|
||||
grants only the permissions in its scope list, intersected with what the
|
||||
owner actually holds. See shopdb/utils/apitoken_auth.py.
|
||||
"""
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# verify jwt first so current_user is loaded (idempotent if the
|
||||
# route also has @jwt_required)
|
||||
verify_jwt_in_request()
|
||||
if current_user is None:
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Authentication required',
|
||||
http_code=401
|
||||
)
|
||||
patscopes = get_jwt().get('patscopes')
|
||||
if patscopes is not None:
|
||||
# Scoped PAT: allowed only when this permission is in the scope
|
||||
# list AND the owner actually holds it. haspermission still
|
||||
# returns True for an admin owner (who legitimately holds
|
||||
# everything), so the scope list is the real limiter - the
|
||||
# admin bypass is suspended.
|
||||
allowed = (permission_name in patscopes
|
||||
and current_user.haspermission(permission_name))
|
||||
if not allowed:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This API token is not scoped for this action',
|
||||
http_code=403
|
||||
)
|
||||
return view_func(*args, **kwargs)
|
||||
if not current_user.haspermission(permission_name):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to perform this action',
|
||||
http_code=403
|
||||
)
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def require_role(rolename: str):
|
||||
"""Gate a route behind a single role (e.g. 'admin').
|
||||
|
||||
A scoped personal API token (patscopes claim present) is ALWAYS denied here:
|
||||
scopes gate individual permissions, not roles, so role-gated admin surfaces
|
||||
require an unscoped token. See shopdb/utils/apitoken_auth.py.
|
||||
"""
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
verify_jwt_in_request()
|
||||
if current_user is None:
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Authentication required',
|
||||
http_code=401
|
||||
)
|
||||
if get_jwt().get('patscopes') is not None:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'Scoped API tokens cannot access role-gated endpoints',
|
||||
http_code=403
|
||||
)
|
||||
if not current_user.hasrole(rolename):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
f'{rolename.capitalize()} access required',
|
||||
http_code=403
|
||||
)
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
38
shopdb/utils/cmmc_usb_db.py
Normal file
38
shopdb/utils/cmmc_usb_db.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Connection helper for the CMMC USB check-in/out database.
|
||||
|
||||
Credentials are pulled from app config (env-backed, see Config.CMMC_USB_DB_*),
|
||||
never hardcoded. Used by the USB plugin to track device check-in/out against a
|
||||
separate MySQL database (cmmc_usb), mirroring the read-only employee-directory
|
||||
pattern in employee_db.py. Unlike the employee DB this one is read-write, so
|
||||
callers commit their own writes and close the connection in a finally block.
|
||||
"""
|
||||
|
||||
import pymysql
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def _setting_or_config(setting_key, config_key):
|
||||
"""Non-secret config: a saved Setting wins, else the env-backed app config.
|
||||
|
||||
Lets the setup wizard edit host/name/user without touching .env, while the
|
||||
password stays env-only.
|
||||
"""
|
||||
from shopdb.core.models import Setting
|
||||
try:
|
||||
row = Setting.query.filter_by(key=setting_key).first()
|
||||
if row and row.value:
|
||||
return row.value
|
||||
except Exception:
|
||||
pass
|
||||
return current_app.config[config_key]
|
||||
|
||||
|
||||
def cmmc_usb_connection():
|
||||
"""Open a pymysql connection to the cmmc_usb DB."""
|
||||
return pymysql.connect(
|
||||
host=_setting_or_config('cmmc_usb_db_host', 'CMMC_USB_DB_HOST'),
|
||||
user=_setting_or_config('cmmc_usb_db_user', 'CMMC_USB_DB_USER'),
|
||||
password=current_app.config['CMMC_USB_DB_PASSWORD'],
|
||||
database=_setting_or_config('cmmc_usb_db_name', 'CMMC_USB_DB_NAME'),
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
36
shopdb/utils/employee_db.py
Normal file
36
shopdb/utils/employee_db.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Connection helper for the read-only employee directory database.
|
||||
|
||||
Credentials are pulled from app config (env-backed, see Config.EMPLOYEE_DB_*),
|
||||
never hardcoded. Used by the employee lookup API and the notification
|
||||
recognition feature.
|
||||
"""
|
||||
|
||||
import pymysql
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def _setting_or_config(setting_key, config_key):
|
||||
"""Non-secret config: a saved Setting wins, else the env-backed app config.
|
||||
|
||||
Lets the setup wizard edit host/name/user without touching .env, while the
|
||||
password stays env-only.
|
||||
"""
|
||||
from shopdb.core.models import Setting
|
||||
try:
|
||||
row = Setting.query.filter_by(key=setting_key).first()
|
||||
if row and row.value:
|
||||
return row.value
|
||||
except Exception:
|
||||
pass
|
||||
return current_app.config[config_key]
|
||||
|
||||
|
||||
def employee_connection():
|
||||
"""Open a pymysql connection to the employee directory DB."""
|
||||
return pymysql.connect(
|
||||
host=_setting_or_config('employee_db_host', 'EMPLOYEE_DB_HOST'),
|
||||
user=_setting_or_config('employee_db_user', 'EMPLOYEE_DB_USER'),
|
||||
password=current_app.config['EMPLOYEE_DB_PASSWORD'],
|
||||
database=_setting_or_config('employee_db_name', 'EMPLOYEE_DB_NAME'),
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
111
shopdb/utils/import_mode.py
Normal file
111
shopdb/utils/import_mode.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Import-mode helpers: let admins replay legacy history through the API.
|
||||
|
||||
A migration script importing the classic ASP shopdb
|
||||
needs two things the normal API withholds:
|
||||
|
||||
1. Preserve each row's original createddate/modifieddate instead of stamping
|
||||
"now" on insert/update.
|
||||
2. Backdate event history such as USB checkouts to when they really happened.
|
||||
|
||||
Both are gated. The caller must BOTH be an admin AND send the request header
|
||||
`X-Import-Mode: true`. Outside import mode every helper here is a no-op, so
|
||||
wiring a call into a normal create/update path never changes behavior for
|
||||
regular users. See the IMPORT-API wiki page for the operator manual.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request
|
||||
from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt
|
||||
|
||||
|
||||
# Request header a migration client sets to opt a request into import mode.
|
||||
IMPORT_MODE_HEADER = 'X-Import-Mode'
|
||||
|
||||
# strptime formats accepted for import timestamps, tried in order. Covers the
|
||||
# ISO 'T' form and the legacy MySQL 'space' form, with and without fractions,
|
||||
# plus a bare date.
|
||||
_IMPORT_DATETIME_FORMATS = (
|
||||
'%Y-%m-%dT%H:%M:%S.%f',
|
||||
'%Y-%m-%dT%H:%M:%S',
|
||||
'%Y-%m-%dT%H:%M',
|
||||
'%Y-%m-%d %H:%M:%S.%f',
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y-%m-%d %H:%M',
|
||||
'%Y-%m-%d',
|
||||
)
|
||||
|
||||
|
||||
def import_mode_active():
|
||||
"""True when the request is an admin-authenticated import request.
|
||||
|
||||
Needs header `X-Import-Mode: true` AND an admin caller. Safe to call from
|
||||
any request context: verify_jwt_in_request is optional here so a missing or
|
||||
bad token yields False instead of raising, and it is idempotent when the
|
||||
route already ran @jwt_required."""
|
||||
header = (request.headers.get(IMPORT_MODE_HEADER) or '').strip().lower()
|
||||
if header != 'true':
|
||||
return False
|
||||
verify_jwt_in_request(optional=True)
|
||||
user = current_user
|
||||
if user is None or not user.hasrole('admin'):
|
||||
return False
|
||||
# A scoped PAT never gets import mode: import mode is an admin-role
|
||||
# capability and a scoped token suspends the admin bypass. get_jwt is safe
|
||||
# here - an admin user means a valid JWT was decoded above.
|
||||
if get_jwt().get('patscopes') is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def parse_import_datetime(value):
|
||||
"""Parse an import timestamp into naive UTC, or None.
|
||||
|
||||
Accepts ISO '2020-01-05T12:00:00', legacy 'YYYY-MM-DD HH:MM:SS', a bare
|
||||
date, or an already-parsed datetime. A trailing 'Z' is treated as UTC.
|
||||
Timezone-aware input is converted to UTC then stripped to naive, matching
|
||||
the repo convention of storing naive-UTC in DB DateTime columns."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text[-1] in ('Z', 'z'):
|
||||
text = text[:-1]
|
||||
parsed = None
|
||||
for fmt in _IMPORT_DATETIME_FORMATS:
|
||||
try:
|
||||
parsed = datetime.strptime(text, fmt)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if parsed is None:
|
||||
# last resort: let fromisoformat try (handles offsets like +00:00)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def apply_import_timestamps(instance, data):
|
||||
"""Stamp createddate/modifieddate on a row from the payload, in import mode.
|
||||
|
||||
Call this in a create/update path after building the instance and before
|
||||
commit. It is a no-op unless ALL hold: import mode is active (admin +
|
||||
header), the payload carries the field, and the model actually has the
|
||||
column. Explicitly setting modifieddate also suppresses the column's
|
||||
onupdate=now default on updates, so legacy history survives edits too."""
|
||||
if not data or not import_mode_active():
|
||||
return
|
||||
created = parse_import_datetime(data.get('createddate'))
|
||||
if created is not None and hasattr(instance, 'createddate'):
|
||||
instance.createddate = created
|
||||
modified = parse_import_datetime(data.get('modifieddate'))
|
||||
if modified is not None and hasattr(instance, 'modifieddate'):
|
||||
instance.modifieddate = modified
|
||||
259
shopdb/utils/mailer.py
Normal file
259
shopdb/utils/mailer.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Email sending service (stdlib smtplib/ssl/email only).
|
||||
|
||||
Reads SMTP configuration settings-first (via the cached settings map) with an
|
||||
environment-variable fallback when any SMTP_* env var is present. When email is
|
||||
disabled or the host is unset the sender is a graceful no-op that logs a warning
|
||||
and returns False, so an unconfigured site never crashes on a send attempt.
|
||||
|
||||
Public helpers:
|
||||
send_email(to, subject, html, text=None) -> bool
|
||||
try_send(to, subject, html, text=None) -> (bool, error_or_None)
|
||||
send_alert(subject, html, text=None) -> bool
|
||||
render_email(title, body_html, intro=None) -> (html, text)
|
||||
render_table_email(title, columns, rows, intro=None) -> (html, text)
|
||||
|
||||
The SMTP password is never logged.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
|
||||
from flask import current_app, has_app_context
|
||||
|
||||
# Env var names that back each SMTP setting when settings are blank.
|
||||
_ENV_MAP = {
|
||||
'smtp_host': 'SMTP_HOST',
|
||||
'smtp_port': 'SMTP_PORT',
|
||||
'smtp_username': 'SMTP_USERNAME',
|
||||
'smtp_password': 'SMTP_PASSWORD',
|
||||
'smtp_from_address': 'SMTP_FROM_ADDRESS',
|
||||
'smtp_from_name': 'SMTP_FROM_NAME',
|
||||
'alert_recipients': 'SMTP_ALERT_RECIPIENTS',
|
||||
}
|
||||
|
||||
# Connect/send timeout in seconds. Keeps a wedged relay from hanging a request.
|
||||
_SMTP_TIMEOUT = 10
|
||||
|
||||
|
||||
def _log():
|
||||
"""App logger when in an app context, else a module logger."""
|
||||
if has_app_context():
|
||||
return current_app.logger
|
||||
import logging
|
||||
return logging.getLogger('shopdb.mailer')
|
||||
|
||||
|
||||
def _env_active():
|
||||
"""True when the deployment supplies SMTP_* env overrides."""
|
||||
return any(k.startswith('SMTP_') for k in os.environ)
|
||||
|
||||
|
||||
def get_smtp_config():
|
||||
"""Resolve the SMTP config settings-first with env fallback.
|
||||
|
||||
Returns a dict with typed fields. `enabled` is False when the site has not
|
||||
turned email on; callers should treat that as a no-op signal.
|
||||
"""
|
||||
settings = {}
|
||||
if has_app_context():
|
||||
# Local import avoids a circular import at module load.
|
||||
from shopdb.core.api.settings import get_cached_settings
|
||||
try:
|
||||
settings = get_cached_settings() or {}
|
||||
except Exception:
|
||||
settings = {}
|
||||
|
||||
env_active = _env_active()
|
||||
|
||||
def pick(key, default=''):
|
||||
val = settings.get(key)
|
||||
if (val is None or val == '') and env_active:
|
||||
val = os.environ.get(_ENV_MAP.get(key, ''), default)
|
||||
return default if val is None else val
|
||||
|
||||
enabled = bool(settings.get('smtp_enabled'))
|
||||
if not enabled and env_active:
|
||||
enabled = os.environ.get('SMTP_ENABLED', '').lower() in ('true', '1', 'yes')
|
||||
|
||||
use_tls = settings.get('smtp_use_tls')
|
||||
if use_tls is None:
|
||||
if env_active:
|
||||
use_tls = os.environ.get('SMTP_USE_TLS', 'true').lower() in ('true', '1', 'yes')
|
||||
else:
|
||||
use_tls = True
|
||||
|
||||
try:
|
||||
port = int(pick('smtp_port', 587) or 587)
|
||||
except (ValueError, TypeError):
|
||||
port = 587
|
||||
|
||||
return {
|
||||
'enabled': enabled,
|
||||
'host': pick('smtp_host'),
|
||||
'port': port,
|
||||
'username': pick('smtp_username'),
|
||||
'password': pick('smtp_password'),
|
||||
'use_tls': bool(use_tls),
|
||||
'from_address': pick('smtp_from_address'),
|
||||
'from_name': pick('smtp_from_name') or 'ShopDB',
|
||||
'alert_recipients': pick('alert_recipients'),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_recipients(to):
|
||||
"""Coerce a recipient spec (string, comma/semicolon list, or iterable) to a
|
||||
clean list of addresses."""
|
||||
if not to:
|
||||
return []
|
||||
if isinstance(to, str):
|
||||
parts = re.split(r'[,;]', to)
|
||||
else:
|
||||
parts = list(to)
|
||||
return [p.strip() for p in parts if p and p.strip()]
|
||||
|
||||
|
||||
def try_send(to, subject, html, text=None):
|
||||
"""Send an email. Returns (ok, error).
|
||||
|
||||
ok is False with error=None when email is not configured (a graceful
|
||||
no-op). ok is False with an error string when a real send failed. The SMTP
|
||||
password is never included in the error.
|
||||
"""
|
||||
config = get_smtp_config()
|
||||
recipients = _normalize_recipients(to)
|
||||
|
||||
if not config['enabled'] or not config['host']:
|
||||
_log().warning('Email not sent: SMTP is disabled or host is unset.')
|
||||
return False, None
|
||||
if not recipients:
|
||||
_log().warning('Email not sent: no recipients.')
|
||||
return False, 'No recipients specified'
|
||||
if not config['from_address']:
|
||||
_log().warning('Email not sent: from address is unset.')
|
||||
return False, 'From address is not configured'
|
||||
|
||||
message = MIMEMultipart('alternative')
|
||||
message['Subject'] = subject
|
||||
message['From'] = formataddr((config['from_name'], config['from_address']))
|
||||
message['To'] = ', '.join(recipients)
|
||||
# Plaintext first so alternative-aware clients prefer the HTML part.
|
||||
message.attach(MIMEText(text or _html_to_text(html), 'plain', 'utf-8'))
|
||||
message.attach(MIMEText(html, 'html', 'utf-8'))
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
if config['port'] == 465:
|
||||
server = smtplib.SMTP_SSL(
|
||||
config['host'], config['port'],
|
||||
timeout=_SMTP_TIMEOUT, context=context)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
config['host'], config['port'], timeout=_SMTP_TIMEOUT)
|
||||
with server:
|
||||
if config['port'] != 465 and config['use_tls']:
|
||||
server.starttls(context=context)
|
||||
if config['username']:
|
||||
server.login(config['username'], config['password'])
|
||||
server.sendmail(config['from_address'], recipients, message.as_string())
|
||||
_log().info('Email sent to %d recipient(s): %s', len(recipients), subject)
|
||||
return True, None
|
||||
except Exception as exception:
|
||||
# Never let the password reach the log or the caller.
|
||||
error = _scrub(str(exception), config['password'])
|
||||
_log().error('Email send failed: %s', error)
|
||||
return False, error
|
||||
|
||||
|
||||
def send_email(to, subject, html, text=None):
|
||||
"""Send an email. Returns True on success, False otherwise (no-op safe)."""
|
||||
ok, _error = try_send(to, subject, html, text=text)
|
||||
return ok
|
||||
|
||||
|
||||
def send_alert(subject, html, text=None):
|
||||
"""Send an alert to the site's configured alert_recipients. Returns False
|
||||
when email is off or no alert recipients are configured."""
|
||||
config = get_smtp_config()
|
||||
recipients = _normalize_recipients(config['alert_recipients'])
|
||||
if not recipients:
|
||||
_log().warning('Alert not sent: no alert_recipients configured.')
|
||||
return False
|
||||
return send_email(recipients, subject, html, text=text)
|
||||
|
||||
|
||||
def _scrub(value, secret):
|
||||
"""Remove a secret substring from a string (defensive log hygiene)."""
|
||||
if secret and secret in value:
|
||||
return value.replace(secret, '***')
|
||||
return value
|
||||
|
||||
|
||||
def _html_to_text(html):
|
||||
"""Very small HTML-to-text fallback for the plaintext alternative."""
|
||||
text = re.sub(r'(?i)<br\s*/?>', '\n', html)
|
||||
text = re.sub(r'(?i)</(p|tr|div|h[1-6]|li)>', '\n', text)
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _escape(value):
|
||||
"""HTML-escape a cell value."""
|
||||
return (str('' if value is None else value)
|
||||
.replace('&', '&').replace('<', '<').replace('>', '>'))
|
||||
|
||||
|
||||
def render_email(title, body_html, intro=None):
|
||||
"""Wrap body HTML in a simple branded shell. Returns (html, text)."""
|
||||
intro_html = f'<p style="margin:0 0 16px;color:#444;">{_escape(intro)}</p>' if intro else ''
|
||||
html = (
|
||||
'<div style="font-family:Arial,Helvetica,sans-serif;max-width:640px;'
|
||||
'margin:0 auto;color:#222;">'
|
||||
f'<h2 style="color:#1a1a1a;margin:0 0 12px;">{_escape(title)}</h2>'
|
||||
f'{intro_html}{body_html}'
|
||||
'<hr style="border:none;border-top:1px solid #ddd;margin:24px 0 12px;">'
|
||||
'<p style="font-size:12px;color:#888;margin:0;">Sent by ShopDB.</p>'
|
||||
'</div>'
|
||||
)
|
||||
return html, _html_to_text(html)
|
||||
|
||||
|
||||
def render_table_email(title, columns, rows, intro=None):
|
||||
"""Render tabular report data as an HTML table email. Returns (html, text).
|
||||
|
||||
columns: list of {'key','label'} dicts or of plain strings.
|
||||
rows: list of dicts keyed by the column keys.
|
||||
"""
|
||||
normalized = []
|
||||
for column in columns or []:
|
||||
if isinstance(column, dict):
|
||||
normalized.append((column.get('key'), column.get('label', column.get('key'))))
|
||||
else:
|
||||
normalized.append((column, column))
|
||||
|
||||
header_cells = ''.join(
|
||||
f'<th style="text-align:left;padding:8px 10px;border-bottom:2px solid #ccc;'
|
||||
f'background:#f4f4f4;">{_escape(label)}</th>'
|
||||
for _key, label in normalized)
|
||||
|
||||
body_rows = []
|
||||
for row in rows or []:
|
||||
cells = ''.join(
|
||||
f'<td style="padding:8px 10px;border-bottom:1px solid #eee;">'
|
||||
f'{_escape(row.get(key) if isinstance(row, dict) else row)}</td>'
|
||||
for key, _label in normalized)
|
||||
body_rows.append(f'<tr>{cells}</tr>')
|
||||
|
||||
table = (
|
||||
'<table style="border-collapse:collapse;width:100%;font-size:14px;">'
|
||||
f'<thead><tr>{header_cells}</tr></thead>'
|
||||
f'<tbody>{"".join(body_rows) or "<tr><td>No data</td></tr>"}</tbody>'
|
||||
'</table>'
|
||||
)
|
||||
count_line = f'<p style="color:#666;font-size:13px;">{len(rows or [])} row(s).</p>'
|
||||
return render_email(title, table + count_line, intro=intro)
|
||||
43
shopdb/utils/pagination.py
Normal file
43
shopdb/utils/pagination.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Pagination utilities."""
|
||||
|
||||
from flask import request, current_app
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def get_pagination_params(request_obj=None) -> Tuple[int, int]:
|
||||
"""
|
||||
Extract pagination parameters from request.
|
||||
|
||||
Returns:
|
||||
Tuple of (page, per_page)
|
||||
"""
|
||||
if request_obj is None:
|
||||
request_obj = request
|
||||
|
||||
default_size = current_app.config.get('DEFAULT_PAGE_SIZE', 20)
|
||||
max_size = current_app.config.get('MAX_PAGE_SIZE', 100)
|
||||
|
||||
try:
|
||||
page = max(1, int(request_obj.args.get('page', 1)))
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
|
||||
try:
|
||||
per_page = int(request_obj.args.get('perpage', request_obj.args.get('per_page', default_size)))
|
||||
per_page = max(1, min(per_page, max_size))
|
||||
except (TypeError, ValueError):
|
||||
per_page = default_size
|
||||
|
||||
return page, per_page
|
||||
|
||||
|
||||
def paginate_query(query, page: int, per_page: int):
|
||||
"""
|
||||
Apply pagination to a SQLAlchemy query.
|
||||
|
||||
Returns:
|
||||
Tuple of (items, total)
|
||||
"""
|
||||
total = query.count()
|
||||
items = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
return items, total
|
||||
175
shopdb/utils/responses.py
Normal file
175
shopdb/utils/responses.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Standardized API response helpers."""
|
||||
|
||||
from flask import jsonify, make_response
|
||||
from typing import Any, Dict, List
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class ErrorCodes:
|
||||
"""Standard error codes."""
|
||||
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR'
|
||||
NOT_FOUND = 'NOT_FOUND'
|
||||
UNAUTHORIZED = 'UNAUTHORIZED'
|
||||
FORBIDDEN = 'FORBIDDEN'
|
||||
CONFLICT = 'CONFLICT'
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR'
|
||||
BAD_REQUEST = 'BAD_REQUEST'
|
||||
PLUGIN_ERROR = 'PLUGIN_ERROR'
|
||||
|
||||
|
||||
def api_response(
|
||||
data: Any = None,
|
||||
message: str = None,
|
||||
status: str = 'success',
|
||||
meta: Dict = None,
|
||||
http_code: int = 200
|
||||
):
|
||||
"""
|
||||
Create standardized API response.
|
||||
|
||||
Response format:
|
||||
{
|
||||
"status": "success" | "error",
|
||||
"data": {...} | [...],
|
||||
"message": "Optional message",
|
||||
"meta": {
|
||||
"timestamp": "2025-01-12T...",
|
||||
"request_id": "uuid"
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = {
|
||||
'status': status,
|
||||
'meta': {
|
||||
'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z',
|
||||
'requestid': str(uuid.uuid4())[:8],
|
||||
**(meta or {})
|
||||
}
|
||||
}
|
||||
|
||||
if data is not None:
|
||||
response['data'] = data
|
||||
|
||||
if message:
|
||||
response['message'] = message
|
||||
|
||||
return make_response(jsonify(response), http_code)
|
||||
|
||||
|
||||
def success_response(
|
||||
data: Any = None,
|
||||
message: str = None,
|
||||
meta: Dict = None,
|
||||
http_code: int = 200
|
||||
):
|
||||
"""Success response helper."""
|
||||
return api_response(
|
||||
data=data,
|
||||
message=message,
|
||||
meta=meta,
|
||||
status='success',
|
||||
http_code=http_code
|
||||
)
|
||||
|
||||
|
||||
def error_response(
|
||||
code: str,
|
||||
message: str,
|
||||
details: Dict = None,
|
||||
http_code: int = 400
|
||||
):
|
||||
"""Error response helper.
|
||||
|
||||
Response format:
|
||||
{
|
||||
"status": "error",
|
||||
"data": {
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Human-readable message",
|
||||
"details": {...}
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"timestamp": "...",
|
||||
"requestid": "..."
|
||||
}
|
||||
}
|
||||
"""
|
||||
error_data = {
|
||||
'code': code,
|
||||
'message': message
|
||||
}
|
||||
if details:
|
||||
error_data['details'] = details
|
||||
|
||||
return api_response(
|
||||
data={'error': error_data},
|
||||
status='error',
|
||||
http_code=http_code
|
||||
)
|
||||
|
||||
|
||||
def api_error(
|
||||
message: str,
|
||||
code: str = ErrorCodes.BAD_REQUEST,
|
||||
details: Dict = None,
|
||||
http_code: int = 400
|
||||
):
|
||||
"""
|
||||
Simplified error response helper.
|
||||
|
||||
Args:
|
||||
message: Human-readable error message
|
||||
code: Error code (default BAD_REQUEST)
|
||||
details: Optional error details
|
||||
http_code: HTTP status code (default 400)
|
||||
"""
|
||||
return error_response(code=code, message=message, details=details, http_code=http_code)
|
||||
|
||||
|
||||
def paginated_response(
|
||||
items: List,
|
||||
page: int,
|
||||
per_page: int,
|
||||
total: int,
|
||||
schema=None
|
||||
):
|
||||
"""Paginated list response.
|
||||
|
||||
Response format:
|
||||
{
|
||||
"status": "success",
|
||||
"data": [...],
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"perpage": 20,
|
||||
"total": 150,
|
||||
"totalpages": 8,
|
||||
"hasnext": true,
|
||||
"hasprev": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
total_pages = (total + per_page - 1) // per_page if per_page > 0 else 0
|
||||
|
||||
if schema:
|
||||
items = schema.dump(items, many=True)
|
||||
|
||||
return api_response(
|
||||
data=items,
|
||||
meta={
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'perpage': per_page,
|
||||
'total': total,
|
||||
'totalpages': total_pages,
|
||||
'hasnext': page < total_pages,
|
||||
'hasprev': page > 1
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user