Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-10 15:02:07 -04:00
parent bf9e60e607
commit b8c22244a1
96 changed files with 3818 additions and 1942 deletions

View File

@@ -3,7 +3,7 @@
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
@@ -42,7 +42,7 @@ def list_computer_types():
@jwt_required(optional=True)
def get_computer_type(type_id: int):
"""Get a single computer type."""
t = ComputerType.query.get(type_id)
t = db.session.get(ComputerType, type_id)
if not t:
return error_response(
@@ -97,7 +97,7 @@ def create_computer_type():
@require_permission('computers.edit')
def update_computer_type(type_id: int):
"""Update a computer type."""
t = ComputerType.query.get(type_id)
t = db.session.get(ComputerType, type_id)
if not t:
return error_response(
@@ -131,7 +131,7 @@ def update_computer_type(type_id: int):
@require_permission('computers.delete')
def delete_computer_type(type_id: int):
"""Delete a computer type. Refused if any PC still uses it."""
t = ComputerType.query.get(type_id)
t = db.session.get(ComputerType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Computer type not found', http_code=404)
inuse = Computer.query.filter_by(computertypeid=type_id).count()
@@ -183,7 +183,7 @@ def create_protocol():
@jwt_required()
@require_permission('computers.edit')
def update_protocol(protocol_id):
p = AccessProtocol.query.get(protocol_id)
p = db.session.get(AccessProtocol, protocol_id)
if not p:
return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404)
data = request.get_json() or {}
@@ -202,7 +202,7 @@ def update_protocol(protocol_id):
@jwt_required()
@require_permission('computers.edit')
def delete_protocol(protocol_id):
p = AccessProtocol.query.get(protocol_id)
p = db.session.get(AccessProtocol, protocol_id)
if not p:
return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404)
# If any PC still references it, deactivate rather than hard-delete.
@@ -215,13 +215,19 @@ def delete_protocol(protocol_id):
return success_response(message='Protocol deleted')
def _computer_access_links(comp):
def _pc_access_domain():
# Contract-pure read of the pc_access_domain setting (no core.api import).
row = Setting.query.filter_by(key='pc_access_domain').first()
return ((row.value if row else '') or '').strip()
def _computer_access_links(comp, domain=None):
"""Resolved remote-access links for a computer: each enabled protocol's
template filled with the PC hostname joined to the pc_access_domain setting.
A hostname that is already an FQDN (has a dot) is used as-is."""
from shopdb.core.api.settings import get_cached_settings
settings = get_cached_settings()
domain = (settings.get('pc_access_domain') or '').strip()
A hostname that is already an FQDN (has a dot) is used as-is. Pass domain
when calling in a loop to avoid one settings lookup per computer."""
if domain is None:
domain = _pc_access_domain()
hostname = (comp.hostname or '').strip()
if not hostname:
host = ''
@@ -375,10 +381,11 @@ def list_computers():
# Build response with both asset and computer data
data = []
accessdomain = _pc_access_domain()
for comp in items:
item = comp.asset.to_dict() if comp.asset else {}
item['computer'] = comp.to_dict()
item['accessmethods'] = _computer_access_links(comp)
item['accessmethods'] = _computer_access_links(comp, domain=accessdomain)
data.append(item)
return paginated_response(data, page, per_page, total)
@@ -388,7 +395,7 @@ def list_computers():
@jwt_required(optional=True)
def get_computer(computer_id: int):
"""Get a single computer with full details."""
comp = Computer.query.get(computer_id)
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
@@ -562,7 +569,7 @@ def create_computer():
@require_permission('computers.edit')
def update_computer(computer_id: int):
"""Update computer (both Asset and Computer records)."""
comp = Computer.query.get(computer_id)
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
@@ -662,7 +669,7 @@ def update_computer(computer_id: int):
@require_permission('computers.delete')
def delete_computer(computer_id: int):
"""Delete (soft delete) computer."""
comp = Computer.query.get(computer_id)
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
@@ -691,7 +698,7 @@ def delete_computer(computer_id: int):
@jwt_required(optional=True)
def get_installed_apps(computer_id: int):
"""Get all installed applications for a computer."""
comp = Computer.query.get(computer_id)
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
@@ -715,7 +722,7 @@ def get_installed_apps(computer_id: int):
@require_permission('computers.create')
def add_installed_app(computer_id: int):
"""Add an installed application to a computer."""
comp = Computer.query.get(computer_id)
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
@@ -731,7 +738,7 @@ def add_installed_app(computer_id: int):
appid = data['appid']
# Validate app exists
if not Application.query.get(appid):
if not db.session.get(Application, appid):
return error_response(ErrorCodes.NOT_FOUND, f'Application {appid} not found', http_code=404)
# Check for duplicate
@@ -805,7 +812,7 @@ def report_status(computer_id: int):
This endpoint can be called periodically by a client agent
to update status information.
"""
comp = Computer.query.get(computer_id)
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
@@ -817,8 +824,8 @@ def report_status(computer_id: int):
data = request.get_json() or {}
# Update status fields
from datetime import datetime
comp.lastreporteddate = datetime.utcnow()
from datetime import datetime, timezone
comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None)
if 'loggedinuser' in data:
comp.loggedinuser = data['loggedinuser']

View File

@@ -97,7 +97,7 @@ class ComputersPlugin(BasePlugin):
def apply_collector_payload(self, payload: Dict) -> Dict:
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
from datetime import datetime
from datetime import datetime, timezone
from shopdb.api import (
Asset, Application, Communication, CommunicationType,
Vendor, Model, OperatingSystem,
@@ -136,7 +136,7 @@ class ComputersPlugin(BasePlugin):
elif machinenumber and comp.asset:
comp.asset.assetnumber = machinenumber
comp.lastreporteddate = datetime.utcnow()
comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None)
if payload.get('lastboottime'):
try:
comp.lastboottime = datetime.fromisoformat(

View File

@@ -22,7 +22,7 @@ from shopdb.api import (
employee_connection,
require_role,
)
from shopdb.core.models import Setting
from shopdb.api import Setting
from ..models import DirectoryEmployee
@@ -112,7 +112,7 @@ def lookup_employee(sso):
)
if _selfhosted():
emp = DirectoryEmployee.query.get(int(sso))
emp = db.session.get(DirectoryEmployee, int(sso))
if not emp:
return error_response(ErrorCodes.NOT_FOUND,
f'Employee with SSO {sso} not found', http_code=404)
@@ -247,7 +247,7 @@ def create_directory_employee():
sso = int(fields['sso'])
except (ValueError, TypeError):
return error_response(ErrorCodes.VALIDATION_ERROR, 'sso must be numeric')
if DirectoryEmployee.query.get(sso):
if db.session.get(DirectoryEmployee, sso):
return error_response(ErrorCodes.CONFLICT, f'SSO {sso} already exists', http_code=409)
emp = DirectoryEmployee(sso=sso, firstname=fields['firstname'], lastname=fields['lastname'],
team=fields['team'], role=fields['role'], picture=fields['picture'])
@@ -263,7 +263,7 @@ def update_directory_employee(sso):
guard = _require_selfhosted()
if guard:
return guard
emp = DirectoryEmployee.query.get(sso)
emp = db.session.get(DirectoryEmployee, sso)
if not emp:
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
fields = _employee_from_payload(request.get_json() or {})
@@ -285,7 +285,7 @@ def delete_directory_employee(sso):
guard = _require_selfhosted()
if guard:
return guard
emp = DirectoryEmployee.query.get(sso)
emp = db.session.get(DirectoryEmployee, sso)
if not emp:
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
db.session.delete(emp)
@@ -326,7 +326,7 @@ def import_directory():
team = row.get('team') or None
role = row.get('role') or None
picture = row.get('picture') or None
emp = DirectoryEmployee.query.get(sso)
emp = db.session.get(DirectoryEmployee, sso)
if emp:
emp.firstname, emp.lastname, emp.team, emp.role, emp.picture = first, last, team, role, picture
updated += 1

View File

@@ -42,7 +42,7 @@ def list_equipment_types():
@jwt_required(optional=True)
def get_equipment_type(type_id: int):
"""Get a single equipment type."""
t = EquipmentType.query.get(type_id)
t = db.session.get(EquipmentType, type_id)
if not t:
return error_response(
@@ -96,7 +96,7 @@ def create_equipment_type():
@require_permission('equipment.edit')
def update_equipment_type(type_id: int):
"""Update an equipment type."""
t = EquipmentType.query.get(type_id)
t = db.session.get(EquipmentType, type_id)
if not t:
return error_response(
@@ -130,7 +130,7 @@ def update_equipment_type(type_id: int):
@require_permission('equipment.delete')
def delete_equipment_type(type_id: int):
"""Delete an equipment type. Refused if any asset still uses it."""
t = EquipmentType.query.get(type_id)
t = db.session.get(EquipmentType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Equipment type not found', http_code=404)
inuse = Equipment.query.filter_by(equipmenttypeid=type_id).count()
@@ -225,7 +225,7 @@ def list_equipment():
@jwt_required(optional=True)
def get_equipment(equipment_id: int):
"""Get a single equipment item with full details."""
equip = Equipment.query.get(equipment_id)
equip = db.session.get(Equipment, equipment_id)
if not equip:
return error_response(
@@ -354,7 +354,7 @@ def create_equipment():
@require_permission('equipment.edit')
def update_equipment(equipment_id: int):
"""Update equipment (both Asset and Equipment records)."""
equip = Equipment.query.get(equipment_id)
equip = db.session.get(Equipment, equipment_id)
if not equip:
return error_response(
@@ -425,7 +425,7 @@ def update_equipment(equipment_id: int):
@require_permission('equipment.delete')
def delete_equipment(equipment_id: int):
"""Delete (soft delete) equipment."""
equip = Equipment.query.get(equipment_id)
equip = db.session.get(Equipment, equipment_id)
if not equip:
return error_response(

View File

@@ -102,7 +102,7 @@ def get_stats():
@jwt_required(optional=True)
def get_article(link_id: int):
"""Get a single knowledge base article."""
article = KnowledgeBase.query.get(link_id)
article = db.session.get(KnowledgeBase, link_id)
if not article or not article.isactive:
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
@@ -123,7 +123,7 @@ def get_article(link_id: int):
@jwt_required(optional=True)
def track_click(link_id: int):
"""Increment click counter and return the URL to redirect to."""
article = KnowledgeBase.query.get(link_id)
article = db.session.get(KnowledgeBase, link_id)
if not article or not article.isactive:
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
@@ -152,7 +152,7 @@ def create_article():
# Validate application if provided
if data.get('appid'):
app = Application.query.get(data['appid'])
app = db.session.get(Application, data['appid'])
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
@@ -175,7 +175,7 @@ def create_article():
@require_permission('kb.edit')
def update_article(link_id: int):
"""Update a knowledge base article."""
article = KnowledgeBase.query.get(link_id)
article = db.session.get(KnowledgeBase, link_id)
if not article:
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
@@ -186,7 +186,7 @@ def update_article(link_id: int):
# Validate application if being changed
if 'appid' in data and data['appid']:
app = Application.query.get(data['appid'])
app = db.session.get(Application, data['appid'])
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
@@ -204,7 +204,7 @@ def update_article(link_id: int):
@require_permission('kb.delete')
def delete_article(link_id: int):
"""Delete (deactivate) a knowledge base article."""
article = KnowledgeBase.query.get(link_id)
article = db.session.get(KnowledgeBase, link_id)
if not article:
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)

View File

@@ -42,7 +42,7 @@ def list_network_device_types():
@jwt_required(optional=True)
def get_network_device_type(type_id: int):
"""Get a single network device type."""
t = NetworkDeviceType.query.get(type_id)
t = db.session.get(NetworkDeviceType, type_id)
if not t:
return error_response(
@@ -96,7 +96,7 @@ def create_network_device_type():
@require_permission('network.edit')
def update_network_device_type(type_id: int):
"""Update a network device type."""
t = NetworkDeviceType.query.get(type_id)
t = db.session.get(NetworkDeviceType, type_id)
if not t:
return error_response(
@@ -130,7 +130,7 @@ def update_network_device_type(type_id: int):
@require_permission('network.delete')
def delete_network_device_type(type_id: int):
"""Delete a network device type. Refused if any device still uses it."""
t = NetworkDeviceType.query.get(type_id)
t = db.session.get(NetworkDeviceType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Network device type not found', http_code=404)
inuse = NetworkDevice.query.filter_by(networkdevicetypeid=type_id).count()
@@ -238,7 +238,7 @@ def list_network_devices():
@jwt_required(optional=True)
def get_network_device(device_id: int):
"""Get a single network device with full details."""
netdev = NetworkDevice.query.get(device_id)
netdev = db.session.get(NetworkDevice, device_id)
if not netdev:
return error_response(
@@ -393,7 +393,7 @@ def create_network_device():
@require_permission('network.edit')
def update_network_device(device_id: int):
"""Update network device (both Asset and NetworkDevice records)."""
netdev = NetworkDevice.query.get(device_id)
netdev = db.session.get(NetworkDevice, device_id)
if not netdev:
return error_response(
@@ -471,7 +471,7 @@ def update_network_device(device_id: int):
@require_permission('network.delete')
def delete_network_device(device_id: int):
"""Delete (soft delete) network device."""
netdev = NetworkDevice.query.get(device_id)
netdev = db.session.get(NetworkDevice, device_id)
if not netdev:
return error_response(
@@ -581,7 +581,7 @@ def list_vlans():
@jwt_required(optional=True)
def get_vlan(vlan_id: int):
"""Get a single VLAN with its subnets."""
vlan = VLAN.query.get(vlan_id)
vlan = db.session.get(VLAN, vlan_id)
if not vlan:
return error_response(
@@ -644,7 +644,7 @@ def create_vlan():
@require_permission('network.edit')
def update_vlan(vlan_id: int):
"""Update a VLAN."""
vlan = VLAN.query.get(vlan_id)
vlan = db.session.get(VLAN, vlan_id)
if not vlan:
return error_response(
@@ -690,7 +690,7 @@ def update_vlan(vlan_id: int):
@require_permission('network.delete')
def delete_vlan(vlan_id: int):
"""Delete (soft delete) a VLAN."""
vlan = VLAN.query.get(vlan_id)
vlan = db.session.get(VLAN, vlan_id)
if not vlan:
return error_response(
@@ -767,7 +767,7 @@ def list_subnets():
@jwt_required(optional=True)
def get_subnet(subnet_id: int):
"""Get a single subnet."""
subnet = Subnet.query.get(subnet_id)
subnet = db.session.get(Subnet, subnet_id)
if not subnet:
return error_response(
@@ -809,7 +809,7 @@ def create_subnet():
# Validate VLAN if provided
if data.get('vlanid'):
if not VLAN.query.get(data['vlanid']):
if not db.session.get(VLAN, data['vlanid']):
return error_response(
ErrorCodes.VALIDATION_ERROR,
f"VLAN with ID {data['vlanid']} not found"
@@ -850,7 +850,7 @@ def create_subnet():
@require_permission('network.edit')
def update_subnet(subnet_id: int):
"""Update a subnet."""
subnet = Subnet.query.get(subnet_id)
subnet = db.session.get(Subnet, subnet_id)
if not subnet:
return error_response(
@@ -901,7 +901,7 @@ def update_subnet(subnet_id: int):
@require_permission('network.delete')
def delete_subnet(subnet_id: int):
"""Delete (soft delete) a subnet."""
subnet = Subnet.query.get(subnet_id)
subnet = db.session.get(Subnet, subnet_id)
if not subnet:
return error_response(

View File

@@ -227,7 +227,7 @@ def create_notification_type():
@require_permission('notifications.create')
def update_notification_type(type_id: int):
"""Update a notification type, including its auto-expiry rule."""
t = NotificationType.query.get(type_id)
t = db.session.get(NotificationType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, f'Notification type {type_id} not found', http_code=404)
@@ -290,7 +290,7 @@ def list_notifications():
# Current filter (active based on dates)
if request.args.get('current', 'false').lower() == 'true':
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
query = query.filter(
Notification.starttime <= now,
db.or_(
@@ -317,7 +317,7 @@ def list_notifications():
@notifications_bp.route('/<int:notification_id>', methods=['GET'])
def get_notification(notification_id: int):
"""Get a single notification."""
n = Notification.query.get(notification_id)
n = db.session.get(Notification, notification_id)
if not n:
return error_response(
@@ -345,7 +345,7 @@ def create_notification():
return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required')
# Parse dates
starttime = datetime.utcnow()
starttime = datetime.now(timezone.utc).replace(tzinfo=None)
if data.get('starttime') or data.get('startdate'):
try:
date_str = data.get('starttime') or data.get('startdate')
@@ -364,7 +364,7 @@ def create_notification():
# No explicit end time: apply the per-type display window (recognition
# clears at the next 8 AM Eastern, recertification runs two weeks).
if endtime is None and data.get('notificationtypeid'):
ntype = NotificationType.query.get(data['notificationtypeid'])
ntype = db.session.get(NotificationType, data['notificationtypeid'])
if ntype:
endtime = _auto_endtime(ntype, starttime)
@@ -394,7 +394,7 @@ def create_notification():
@require_permission('notifications.edit')
def update_notification(notification_id: int):
"""Update a notification."""
n = Notification.query.get(notification_id)
n = db.session.get(Notification, notification_id)
if not n:
return error_response(
@@ -440,7 +440,7 @@ def update_notification(notification_id: int):
except ValueError:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
else:
n.starttime = datetime.utcnow()
n.starttime = datetime.now(timezone.utc).replace(tzinfo=None)
if 'endtime' in data or 'enddate' in data:
date_str = data.get('endtime') or data.get('enddate')
@@ -461,7 +461,7 @@ def update_notification(notification_id: int):
@require_permission('notifications.delete')
def delete_notification(notification_id: int):
"""Delete (soft delete) a notification."""
n = Notification.query.get(notification_id)
n = db.session.get(Notification, notification_id)
if not n:
return error_response(
@@ -485,7 +485,7 @@ def get_active_notifications():
"""
Get currently active notifications for display.
"""
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
from datetime import timedelta
lookahead = now + timedelta(days=10)
@@ -551,7 +551,7 @@ def get_calendar_events():
@notifications_bp.route('/dashboard/summary', methods=['GET'])
def dashboard_summary():
"""Get notifications dashboard summary."""
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
# Total active notifications
total_active = Notification.query.filter(
@@ -643,7 +643,7 @@ def get_shopfloor_notifications():
"""
from datetime import timedelta
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
business_unit = request.args.get('businessunit')
# Base query for shopfloor notifications

View File

@@ -1,6 +1,6 @@
"""Notifications plugin models - adapted to existing database schema."""
from datetime import datetime
from datetime import datetime, timezone
from shopdb.api import db
@@ -93,7 +93,7 @@ class Notification(db.Model):
@property
def is_current(self):
"""Check if notification is currently active based on dates."""
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if not self.isactive:
return False
if self.starttime and now < self.starttime:

View File

@@ -145,10 +145,10 @@ class NotificationsPlugin(BasePlugin):
def stats():
"""Show notification statistics."""
from flask import current_app
from datetime import datetime
from datetime import datetime, timezone
with current_app.app_context():
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
total = Notification.query.filter(
Notification.isactive == True

View File

@@ -54,7 +54,7 @@ def list_printer_types():
@jwt_required(optional=True)
def get_printer_type(type_id: int):
"""Get a single printer type."""
t = PrinterType.query.get(type_id)
t = db.session.get(PrinterType, type_id)
if not t:
return error_response(
@@ -108,7 +108,7 @@ def create_printer_type():
@require_permission('printers.edit')
def update_printer_type(type_id: int):
"""Update a printer type."""
t = PrinterType.query.get(type_id)
t = db.session.get(PrinterType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND,
f'Printer type with ID {type_id} not found', http_code=404)
@@ -132,7 +132,7 @@ def update_printer_type(type_id: int):
@require_permission('printers.delete')
def delete_printer_type(type_id: int):
"""Delete a printer type. Refused if any printer still uses it."""
t = PrinterType.query.get(type_id)
t = db.session.get(PrinterType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Printer type not found', http_code=404)
inuse = Printer.query.filter_by(printertypeid=type_id).count()
@@ -182,7 +182,7 @@ def create_driver():
@jwt_required()
@require_permission('printers.edit')
def update_driver(driver_id):
d = PrinterDriver.query.get(driver_id)
d = db.session.get(PrinterDriver, driver_id)
if not d:
return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404)
data = request.get_json() or {}
@@ -199,7 +199,7 @@ def update_driver(driver_id):
@jwt_required()
@require_permission('printers.delete')
def delete_driver(driver_id):
d = PrinterDriver.query.get(driver_id)
d = db.session.get(PrinterDriver, driver_id)
if not d:
return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404)
db.session.delete(d)
@@ -398,7 +398,7 @@ def pc_default_printer():
@jwt_required(optional=True)
def get_printer(printer_id: int):
"""Get a single printer with full details."""
printer = Printer.query.get(printer_id)
printer = db.session.get(Printer, printer_id)
if not printer:
return error_response(
@@ -551,7 +551,7 @@ def create_printer():
@require_permission('printers.edit')
def update_printer(printer_id: int):
"""Update printer (both Asset and Printer records)."""
printer = Printer.query.get(printer_id)
printer = db.session.get(Printer, printer_id)
if not printer:
return error_response(
@@ -626,7 +626,7 @@ def update_printer(printer_id: int):
@require_permission('printers.delete')
def delete_printer(printer_id: int):
"""Delete (soft delete) printer."""
printer = Printer.query.get(printer_id)
printer = db.session.get(Printer, printer_id)
if not printer:
return error_response(
@@ -650,7 +650,7 @@ def delete_printer(printer_id: int):
@jwt_required(optional=True)
def get_printer_supplies(printer_id: int):
"""Get supply levels from Zabbix (real-time lookup)."""
printer = Printer.query.get(printer_id)
printer = db.session.get(Printer, printer_id)
if not printer:
return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404)
@@ -776,7 +776,7 @@ def _get_low_supplies_data():
location_name = None
if asset.locationid:
from shopdb.api import Location
loc = Location.query.get(asset.locationid)
loc = db.session.get(Location, asset.locationid)
if loc:
location_name = loc.locationname
@@ -1031,7 +1031,7 @@ def list_supply_models():
@jwt_required(optional=True)
def list_model_supplies(modelnumberid: int):
"""List all supplies mapped to a model."""
model = Model.query.get(modelnumberid)
model = db.session.get(Model, modelnumberid)
if not model:
return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404)
@@ -1053,7 +1053,7 @@ def list_model_supplies(modelnumberid: int):
@require_permission('printers.create')
def create_model_supply(modelnumberid: int):
"""Add a supply to a model."""
model = Model.query.get(modelnumberid)
model = db.session.get(Model, modelnumberid)
if not model:
return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404)
@@ -1094,7 +1094,7 @@ def create_model_supply(modelnumberid: int):
@require_permission('printers.edit')
def update_model_supply(modelsupplyid: int):
"""Update a model supply."""
supply = ModelSupply.query.get(modelsupplyid)
supply = db.session.get(ModelSupply, modelsupplyid)
if not supply:
return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404)
@@ -1139,7 +1139,7 @@ def update_model_supply(modelsupplyid: int):
@require_permission('printers.delete')
def delete_model_supply(modelsupplyid: int):
"""Delete a model supply."""
supply = ModelSupply.query.get(modelsupplyid)
supply = db.session.get(ModelSupply, modelsupplyid)
if not supply:
return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404)

View File

@@ -197,7 +197,7 @@ def delete_slides(surface):
def update_slide(surface, slideid):
if not _valid_surface(surface):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface')
row = TvSlide.query.get(slideid)
row = db.session.get(TvSlide, slideid)
if not row or row.surface != surface:
return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404)
data = request.get_json() or {}

View File

@@ -37,7 +37,7 @@ from shopdb.api import (
get_pagination_params,
require_permission,
)
from shopdb.core.models import Setting
from shopdb.api import Setting
from . import selfhosted

View File

@@ -16,7 +16,7 @@ History rows are synthesized from usbcheckouts (each row = a check-out event and
if returned, a check-in event).
"""
from datetime import datetime
from datetime import datetime, timezone
from shopdb.api import (
db, success_response, error_response, ErrorCodes,
@@ -36,7 +36,7 @@ def _resolve_name(sso):
try:
from plugins.employees.models import DirectoryEmployee
if sso and str(sso).isdigit():
emp = DirectoryEmployee.query.get(int(sso))
emp = db.session.get(DirectoryEmployee, int(sso))
if emp:
return f'{emp.firstname} {emp.lastname}'.strip()
except Exception:
@@ -188,7 +188,7 @@ def checkout_device(device_id, data):
if device.ischeckedout:
return error_response(ErrorCodes.CONFLICT, 'Device is already checked out', http_code=409)
name = _resolve_name(badge)
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, machineid=0, sso=badge,
checkoutname=name, checkouttime=now,
checkoutreason=data.get('reason')))
@@ -215,7 +215,7 @@ def checkin_device(device_id, data):
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
.order_by(USBCheckout.checkouttime.desc()).first())
if open_checkout:
open_checkout.checkintime = datetime.utcnow()
open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None)
open_checkout.waswiped = bool(data.get('sanitized'))
open_checkout.checkinnotes = data.get('notes')
device.ischeckedout = False

View File

@@ -1,9 +1,14 @@
"""USB device plugin models."""
from datetime import datetime
from datetime import datetime, timezone
from shopdb.api import db, BaseModel, AuditMixin
def _utcnow():
# naive UTC for DB columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
class USBDeviceType(BaseModel):
"""
USB device type classification.
@@ -125,7 +130,7 @@ class USBCheckout(BaseModel):
checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user')
# Checkout details
checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
checkouttime = db.Column(db.DateTime, nullable=False, default=_utcnow)
checkintime = db.Column(db.DateTime, nullable=True)
# Metadata
@@ -147,7 +152,7 @@ class USBCheckout(BaseModel):
@property
def duration_days(self):
"""Get duration of checkout in days."""
end = self.checkintime or datetime.utcnow()
end = self.checkintime or datetime.now(timezone.utc).replace(tzinfo=None)
delta = end - self.checkouttime
return delta.days

View File

@@ -5,7 +5,7 @@ never stored. Warranties link to assets many-to-many via warrantyassets, though
the common case is one warranty per asset.
"""
from datetime import date, datetime
from datetime import date, datetime, timezone
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
@@ -46,7 +46,7 @@ def _warranty_payload(warranty, today=None):
data = warranty.to_dict(today)
assets = []
for link in warranty.links:
asset = Asset.query.get(link.assetid)
asset = db.session.get(Asset, link.assetid)
if asset:
assets.append(_asset_summary(asset))
data['assets'] = assets
@@ -60,7 +60,7 @@ def _apply_links(warranty, assetids):
wanted = {int(a) for a in assetids if str(a).strip()}
existing = {link.assetid: link for link in warranty.links}
for assetid in wanted - set(existing):
if Asset.query.get(assetid):
if db.session.get(Asset, assetid):
warranty.links.append(WarrantyAsset(assetid=assetid))
for assetid in set(existing) - wanted:
warranty.links.remove(existing[assetid])
@@ -100,7 +100,7 @@ def warranties_for_asset(assetid):
today = date.today()
items = []
for link in links:
w = Warranty.query.get(link.warrantyid)
w = db.session.get(Warranty, link.warrantyid)
if w and w.isactive:
items.append(_warranty_payload(w, today))
return success_response(items)
@@ -109,7 +109,7 @@ def warranties_for_asset(assetid):
@warranty_bp.route('/<int:warrantyid>', methods=['GET'])
@jwt_required(optional=True)
def get_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
return success_response(_warranty_payload(warranty))
@@ -142,7 +142,7 @@ def create_warranty():
@jwt_required()
@require_permission('warranty.edit')
def update_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
data = request.get_json() or {}
@@ -172,7 +172,7 @@ def update_warranty(warrantyid):
@jwt_required()
@require_permission('warranty.delete')
def delete_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
db.session.delete(warranty)
@@ -188,7 +188,7 @@ def delete_warranty(warrantyid):
@jwt_required()
@require_permission('warranty.edit')
def refresh_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
provider = get_provider(warranty.provider)
@@ -205,7 +205,7 @@ def refresh_warranty(warrantyid):
warranty.startdate = _parse_date(result['startdate'])
if result.get('enddate'):
warranty.enddate = _parse_date(result['enddate'])
warranty.lastcheckeddate = datetime.utcnow()
warranty.lastcheckeddate = datetime.now(timezone.utc).replace(tzinfo=None)
db.session.commit()
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
@@ -232,7 +232,7 @@ def sync_dell():
covered = set()
if not recheck_all:
for link in WarrantyAsset.query.all():
w = Warranty.query.get(link.warrantyid)
w = db.session.get(Warranty, link.warrantyid)
if w and w.isactive and w.enddate:
covered.add(link.assetid)
@@ -259,7 +259,7 @@ def sync_dell():
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
created = updated = matched = 0
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for tag, assetids in by_tag.items():
found = results.get(tag)
if not found:
@@ -269,7 +269,7 @@ def sync_dell():
# Reuse an existing Dell warranty for this asset if there is one.
existing = None
for link in WarrantyAsset.query.filter_by(assetid=assetid).all():
candidate = Warranty.query.get(link.warrantyid)
candidate = db.session.get(Warranty, link.warrantyid)
if candidate and candidate.provider == 'dell':
existing = candidate
break

View File

@@ -17,7 +17,7 @@ import requests
from flask import current_app
from shopdb.api import db
from shopdb.core.models import Setting
from shopdb.api import Setting
# Two-level Dell token cache. Dell rate-limits the token endpoint, so a fresh
# request per refresh (or per app restart) trips a 401 cooldown. Tokens live ~1h.