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:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

20
shopdb/utils/__init__.py Normal file
View 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'
]

View 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
View 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

View 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,
)

View 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
View 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
View 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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;'))
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)

View 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
View 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
}
}
)