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(