Add email sending (service + 3 flows) and a general asset label generator
All checks were successful
CI / backend (push) Successful in 1m23s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Email: a stdlib SMTP mailer (settings-first config, graceful no-op when
unconfigured), a test-email endpoint wired to the Email settings page,
forced first-login password change (users.mustchangepassword, migration
7d23, /change-password flow), new-user welcome mail, and on-demand
report/alert delivery (POST /api/reports/email + Email Report buttons)
with an external-cron-with-a-scoped-PAT path documented for automation.
All tests patch smtplib - no network.

Labels: a shared /print/asset-label/<type>/<id> view any asset detail
page opens - card or plain style, QR or barcode, configurable encoding.
Per-type qr_target_* templates plus label_default_style/codetype/encodes
settings on the Printing page. Measuring-tool labels default to encoding
their inspection-operation code (derived from the location name, e.g.
0615), so every tool in an area shares the area code - verified by
decoding the rendered QR. Machine labels default to the machine number;
blank-serial handled gracefully.

808 tests pass; both features verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 11:58:30 -04:00
parent 7d309aabeb
commit a846587f39
34 changed files with 1819 additions and 12 deletions

View File

@@ -11,7 +11,7 @@ from flask_jwt_extended import (
get_jwt_identity,
current_user
)
from werkzeug.security import check_password_hash
from werkzeug.security import check_password_hash, generate_password_hash
from shopdb.extensions import db, cache
from shopdb.core.models import User
@@ -151,7 +151,8 @@ def login():
'email': user.email,
'firstname': user.firstname,
'lastname': user.lastname,
'roles': [r.rolename for r in user.roles]
'roles': [r.rolename for r in user.roles],
'mustchangepassword': bool(user.mustchangepassword)
}
})
@@ -196,10 +197,59 @@ def get_current_user():
'firstname': current_user.firstname,
'lastname': current_user.lastname,
'roles': [r.rolename for r in current_user.roles],
'permissions': current_user.getpermissions()
'permissions': current_user.getpermissions(),
'mustchangepassword': bool(current_user.mustchangepassword)
})
@auth_bp.route('/change-password', methods=['POST'])
@jwt_required()
def change_password():
"""Change the authenticated user's own password.
Request:
{ "current_password": "string", "new_password": "string" }
current_password is required for a normal self-service change. When the
account is flagged mustchangepassword (an admin set a temporary password),
the forced-change case accepts new_password alone. On success the flag is
cleared and any lockout/failed-login state is reset.
"""
data = request.get_json() or {}
new_password = data.get('new_password')
current_password = data.get('current_password')
if not new_password:
return error_response(
ErrorCodes.VALIDATION_ERROR, 'new_password is required')
if len(new_password) < 8:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'New password must be at least 8 characters')
user = current_user
# A normal change must prove knowledge of the current password. The forced
# first-login case (admin-set temp password) may skip it.
if not user.mustchangepassword:
if not current_password:
return error_response(
ErrorCodes.VALIDATION_ERROR, 'current_password is required')
if not check_password_hash(user.passwordhash, current_password):
return error_response(
ErrorCodes.UNAUTHORIZED,
'Current password is incorrect', http_code=401)
user.passwordhash = generate_password_hash(new_password)
user.mustchangepassword = False
user.failedlogins = 0
user.lockeduntil = None
db.session.commit()
return success_response(
{'mustchangepassword': False}, message='Password changed')
@auth_bp.route('/logout', methods=['POST'])
@jwt_required()
def logout():

View File

@@ -12,6 +12,7 @@ from shopdb.core.models import (
Application
)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission
reports_bp = Blueprint('reports', __name__)
@@ -513,6 +514,55 @@ def pc_relationships():
})
@reports_bp.route('/email', methods=['POST'])
@jwt_required()
@require_permission('reports.export')
def email_report():
"""Email a report's data as an HTML table (on-demand delivery).
Request:
{
"subject": "Warranty Report",
"columns": [{"key": "vendor", "label": "Vendor"}, ...],
"rows": [{"vendor": "...", ...}, ...],
"intro": "optional lead paragraph",
"to": "addr@example.com" // optional; defaults to alert_recipients
}
Recipients default to the site's Alert Recipients when `to` is omitted, so
this doubles as the alert delivery path. There is no scheduler in this app:
sending is on-demand. To automate it, point an external cron job at this
endpoint using an API token (PAT) scoped to reports.export.
"""
from shopdb.utils.mailer import get_smtp_config, render_table_email, try_send
data = request.get_json() or {}
subject = data.get('subject') or 'ShopDB report'
columns = data.get('columns') or []
rows = data.get('rows') or []
config = get_smtp_config()
recipient = data.get('to') or config['alert_recipients']
if not config['enabled'] or not config['host']:
return success_response(
{'sent': False, 'reason': 'notconfigured'},
message='Email is not configured (SMTP disabled or host unset).')
if not recipient:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'No recipient. Provide "to" or set Alert Recipients.')
html, text = render_table_email(
subject, columns, rows, intro=data.get('intro'))
ok, error = try_send(recipient, subject, html, text=text)
if ok:
return success_response({'sent': True}, message='Report emailed.')
return success_response(
{'sent': False, 'error': error},
message='Report email failed: ' + (error or 'unknown error'))
@reports_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_reports():

View File

@@ -264,6 +264,44 @@ def update_setting(key: str):
return success_response(_serialize_setting(setting), message='Setting updated')
@settings_bp.route('/test-email', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def test_email():
"""Send a test email to verify SMTP configuration.
Request: { "to": "addr@example.com" } (falls back to alert_recipients)
Returns a 200 with a `sent` flag either way. When SMTP is not configured
the response explains that gracefully; when a real send fails the SMTP error
is surfaced with any credential scrubbed out.
"""
from shopdb.utils.mailer import get_smtp_config, render_email, try_send
data = request.get_json() or {}
config = get_smtp_config()
recipient = data.get('to') or config['alert_recipients']
if not config['enabled'] or not config['host']:
return success_response(
{'sent': False, 'reason': 'notconfigured'},
message='Email is not configured (SMTP disabled or host unset).')
if not recipient:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'No recipient. Provide "to" or set Alert Recipients.')
html, text = render_email(
'ShopDB test email',
'<p>This is a test message confirming your SMTP settings work.</p>')
ok, error = try_send(recipient, 'ShopDB test email', html, text=text)
if ok:
return success_response({'sent': True}, message='Test email sent.')
return success_response(
{'sent': False, 'error': error},
message='Test email failed: ' + (error or 'unknown error'))
@settings_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
@@ -530,6 +568,73 @@ def build_default_settings():
'category': 'printing',
'description': "USB mini-label code style: 'barcode' (CODE128 of the serial number) or 'qr' (QR code linking to the QR target)."
},
{
'key': 'qr_target_machine',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for machine labels. Blank = link to the machine page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
},
{
'key': 'qr_target_computer',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for computer labels. Blank = link to the computer page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
},
{
'key': 'qr_target_network_device',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for network-device labels. Blank = link to the device page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
},
{
'key': 'qr_target_measuring_tool',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}.'
},
{
'key': 'label_default_style',
'value': 'card',
'valuetype': 'string',
'category': 'printing',
'description': "Default asset-label layout: 'card' (badge with image and identity) or 'plain' (just the code and a caption)."
},
{
'key': 'label_default_codetype',
'value': 'qr',
'valuetype': 'string',
'category': 'printing',
'description': "Default asset-label code type: 'qr' (QR code) or 'barcode' (CODE128)."
},
]
# Per-asset-type default for what a label's code encodes. Machines default
# to their machine number (assetnumber), measuring tools to their inspection
# location code, everything else to a link to the asset page. Values:
# assetpage | assetnumber | serialnumber | location | custom.
labelencodesdefaults = {
'machine': 'assetnumber',
'computer': 'assetpage',
'printer': 'assetpage',
'network_device': 'assetpage',
'measuring_tool': 'location',
}
printingdefaults += [
{
'key': f'label_default_encodes_{assettype}',
'value': value,
'valuetype': 'string',
'category': 'printing',
'description': f'What a {assettype} label encodes by default: assetpage, '
'assetnumber, serialnumber'
+ (', location' if assettype == 'measuring_tool' else '')
+ ', or custom.',
}
for assettype, value in labelencodesdefaults.items()
]
# Collector pc-type -> ComputerType mapping is computers-plugin domain;

View File

@@ -60,13 +60,18 @@ def create_user():
if User.query.filter_by(email=data['email']).first():
return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
# Admin-created accounts are forced to change the password on first login
# unless the admin explicitly opts out.
mustchange = data.get('mustchangepassword', True)
user = User(
username=data['username'],
email=data['email'],
passwordhash=generate_password_hash(data['password']),
firstname=data.get('firstname'),
lastname=data.get('lastname'),
isactive=data.get('isactive', True)
isactive=data.get('isactive', True),
mustchangepassword=bool(mustchange)
)
# Assign roles
@@ -82,7 +87,47 @@ def create_user():
db.session.commit()
return success_response(user_to_dict(user), message='User created', http_code=201)
# Best-effort welcome email. The account exists regardless of mail outcome;
# a failure is surfaced as a warning in the response, never a hard error.
warning = None
if data.get('sendwelcome', True) and user.email:
sent = _send_welcome_email(user, data['password'])
if not sent:
warning = 'User created but the welcome email could not be sent.'
payload = user_to_dict(user)
if warning:
payload['warning'] = warning
return success_response(payload, message='User created', http_code=201)
def _send_welcome_email(user, temp_password):
"""Send a new-user welcome email with sign-in details. Returns True on send.
Best-effort: any failure (including email being disabled) returns False so
the caller can surface a soft warning without failing user creation.
"""
from shopdb.core.api.settings import get_cached_settings
from shopdb.utils.mailer import render_email, send_email
settings = get_cached_settings() or {}
facility = settings.get('facility_name') or 'ShopDB'
base_url = (settings.get('site_base_url') or '').rstrip('/')
login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page'
body = (
f'<p>An account has been created for you at <strong>{facility}</strong>.</p>'
'<table style="border-collapse:collapse;font-size:14px;margin:12px 0;">'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Username</td>'
f'<td><strong>{user.username}</strong></td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Temporary password</td>'
f'<td><code>{temp_password}</code></td></tr>'
'</table>'
f'<p>Sign in at {login_link}. You will be asked to set a new password '
'the first time you log in.</p>'
)
html, text = render_email(f'Welcome to {facility}', body)
return send_email(user.email, f'Your {facility} account', html, text=text)
@users_bp.route('/<int:userid>', methods=['PUT'])
@@ -335,6 +380,7 @@ def user_to_dict(user: User) -> dict:
'lastname': user.lastname,
'isactive': user.isactive,
'islocked': user.islocked,
'mustchangepassword': bool(user.mustchangepassword),
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
'failedlogins': user.failedlogins,
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],

View File

@@ -7,7 +7,7 @@ from .vendor import Vendor
from .model import Model
from .businessunit import BusinessUnit
from .dashboarddefault import DashboardDefault
from .location import Location, LocationType
from .location import Location, LocationType, derive_locationcode
from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
from .communication import Communication, CommunicationType
@@ -37,6 +37,7 @@ __all__ = [
'DashboardDefault',
'Location',
'LocationType',
'derive_locationcode',
'OperatingSystem',
# Relationships
'AssetRelationship',

View File

@@ -251,6 +251,12 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
if result.get('mapy') is None:
result['mapy'] = inherited['mapy']
# Operation/short code of the resolved location (own or inherited).
# Derived from the location name's leading token; labels can encode a
# tool's inspection operation instead of the tool. None when unplaced.
from .location import derive_locationcode
result['locationcode'] = derive_locationcode(result.get('locationname'))
# Include extension data if requested
if include_type_data:
ext_data = self._get_extension_data()

View File

@@ -4,6 +4,21 @@ from shopdb.extensions import db
from .base import BaseModel
def derive_locationcode(locationname):
"""Operation/short code for a location = leading token of its name.
Locations have no dedicated code column, so the operation code is the
leading whitespace-delimited token of the location name. Example:
'0615 Blisk Inspection' -> '0615'. Blank/None name -> None. Used by
printed labels that encode a tool's inspection operation rather than the
tool itself.
"""
if not locationname:
return None
parts = str(locationname).strip().split()
return parts[0] if parts else None
class LocationType(BaseModel):
"""Location classification (ADR-001 shared reference data).
@@ -60,10 +75,16 @@ class Location(BaseModel):
locationtype = db.relationship('LocationType')
parent = db.relationship('Location', remote_side=[locationid])
@property
def locationcode(self):
"""Derived operation/short code (leading token of the name)."""
return derive_locationcode(self.locationname)
def to_dict(self):
data = super().to_dict()
data['locationtypename'] = self.locationtype.locationtype if self.locationtype else None
data['parentlocationname'] = self.parent.locationname if self.parent else None
data['locationcode'] = self.locationcode
return data
def __repr__(self):

View File

@@ -198,6 +198,9 @@ class User(BaseModel):
lastlogindate = db.Column(db.DateTime)
failedlogins = db.Column(db.Integer, default=0)
lockeduntil = db.Column(db.DateTime)
# Forced password change: set true when an admin creates the account, so the
# user is steered through a password change before landing in the app.
mustchangepassword = db.Column(db.Boolean, default=False, nullable=False)
# Relationships
roles = db.relationship(

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)