Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
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:
@@ -20,6 +20,12 @@ from .plugins import plugin_manager
|
||||
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
|
||||
__contract_version__ = '0.5.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
# bump rules; they happen to coincide at 0.5.0. Not part of the
|
||||
# shopdb.api contract surface, so it is not re-exported there.
|
||||
__version__ = '0.5.0'
|
||||
|
||||
|
||||
def create_app(config_name: str = None) -> Flask:
|
||||
"""
|
||||
|
||||
@@ -85,6 +85,14 @@ class Config:
|
||||
CACHE_TYPE = 'SimpleCache'
|
||||
CACHE_DEFAULT_TIMEOUT = 600
|
||||
|
||||
# IP-based login rate limit (fixed window). Defense in depth atop the
|
||||
# per-account lockout. Backed by the existing cache extension.
|
||||
AUTH_RATELIMIT_ENABLED = os.environ.get(
|
||||
'AUTH_RATELIMIT_ENABLED', 'true').lower() == 'true'
|
||||
AUTH_RATELIMIT_MAX = int(os.environ.get('AUTH_RATELIMIT_MAX', 30))
|
||||
AUTH_RATELIMIT_WINDOW_SECONDS = int(
|
||||
os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300))
|
||||
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
@@ -105,6 +113,9 @@ class TestingConfig(Config):
|
||||
"""Testing configuration."""
|
||||
|
||||
TESTING = True
|
||||
# Off by default so login-heavy fixtures do not trip the limiter; tests
|
||||
# that exercise rate limiting flip it on via app.config override.
|
||||
AUTH_RATELIMIT_ENABLED = False
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'connect_args': {'check_same_thread': False},
|
||||
|
||||
@@ -115,7 +115,7 @@ def list_applications():
|
||||
@jwt_required(optional=True)
|
||||
def get_application(app_id: int):
|
||||
"""Get a single application with details."""
|
||||
app = Application.query.get(app_id)
|
||||
app = db.session.get(Application, app_id)
|
||||
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
@@ -188,7 +188,7 @@ def create_application():
|
||||
@require_permission('applications.edit')
|
||||
def update_application(app_id: int):
|
||||
"""Update an application."""
|
||||
app = Application.query.get(app_id)
|
||||
app = db.session.get(Application, app_id)
|
||||
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
@@ -233,7 +233,7 @@ def update_application(app_id: int):
|
||||
@require_permission('applications.delete')
|
||||
def delete_application(app_id: int):
|
||||
"""Delete (deactivate) an application."""
|
||||
app = Application.query.get(app_id)
|
||||
app = db.session.get(Application, app_id)
|
||||
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
@@ -253,7 +253,7 @@ def delete_application(app_id: int):
|
||||
@jwt_required(optional=True)
|
||||
def list_versions(app_id: int):
|
||||
"""List all versions for an application."""
|
||||
app = Application.query.get(app_id)
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
@@ -266,7 +266,7 @@ def list_versions(app_id: int):
|
||||
@require_permission('applications.create')
|
||||
def create_version(app_id: int):
|
||||
"""Create a new version for an application."""
|
||||
app = Application.query.get(app_id)
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
@@ -300,7 +300,7 @@ def create_version(app_id: int):
|
||||
@jwt_required(optional=True)
|
||||
def list_installed_machines(app_id: int):
|
||||
"""List all computers that have this application installed."""
|
||||
app = Application.query.get(app_id)
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
@@ -344,7 +344,7 @@ def list_machine_applications(machine_id: int):
|
||||
return err
|
||||
Computer, ComputerInstalledApp = models
|
||||
|
||||
comp = Computer.query.get(machine_id)
|
||||
comp = db.session.get(Computer, machine_id)
|
||||
if not comp:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404)
|
||||
|
||||
@@ -362,7 +362,7 @@ def install_application(machine_id: int):
|
||||
return err
|
||||
Computer, ComputerInstalledApp = models
|
||||
|
||||
comp = Computer.query.get(machine_id)
|
||||
comp = db.session.get(Computer, machine_id)
|
||||
if not comp:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404)
|
||||
|
||||
@@ -370,7 +370,7 @@ def install_application(machine_id: int):
|
||||
if not data or not data.get('appid'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required')
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ def list_asset_types():
|
||||
@jwt_required(optional=True)
|
||||
def get_asset_type(type_id: int):
|
||||
"""Get a single asset type."""
|
||||
t = AssetType.query.get(type_id)
|
||||
t = db.session.get(AssetType, type_id)
|
||||
|
||||
if not t:
|
||||
return error_response(
|
||||
@@ -96,7 +96,7 @@ def create_asset_type():
|
||||
def update_asset_type(type_id: int):
|
||||
"""Update an asset type's display fields (color/icon/description). The name,
|
||||
plugin, and table are structural and not editable here."""
|
||||
t = AssetType.query.get(type_id)
|
||||
t = db.session.get(AssetType, type_id)
|
||||
if not t:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset type not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
@@ -134,7 +134,7 @@ def list_asset_statuses():
|
||||
@jwt_required(optional=True)
|
||||
def get_asset_status(status_id: int):
|
||||
"""Get a single asset status."""
|
||||
s = AssetStatus.query.get(status_id)
|
||||
s = db.session.get(AssetStatus, status_id)
|
||||
|
||||
if not s:
|
||||
return error_response(
|
||||
@@ -180,7 +180,7 @@ def create_asset_status():
|
||||
@require_permission('assets.edit')
|
||||
def update_asset_status(status_id: int):
|
||||
"""Update an asset status."""
|
||||
s = AssetStatus.query.get(status_id)
|
||||
s = db.session.get(AssetStatus, status_id)
|
||||
if not s:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found',
|
||||
http_code=404)
|
||||
@@ -209,7 +209,7 @@ def update_asset_status(status_id: int):
|
||||
@require_permission('assets.delete')
|
||||
def delete_asset_status(status_id: int):
|
||||
"""Delete an asset status. Refused if any asset still uses it."""
|
||||
s = AssetStatus.query.get(status_id)
|
||||
s = db.session.get(AssetStatus, status_id)
|
||||
if not s:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found',
|
||||
http_code=404)
|
||||
@@ -285,7 +285,7 @@ def _rel_type_dict(t):
|
||||
@require_permission('assets.edit')
|
||||
def update_relationship_type(type_id: int):
|
||||
"""Update a relationship type."""
|
||||
t = RelationshipType.query.get(type_id)
|
||||
t = db.session.get(RelationshipType, type_id)
|
||||
if not t:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
@@ -305,7 +305,7 @@ def update_relationship_type(type_id: int):
|
||||
@require_permission('assets.delete')
|
||||
def delete_relationship_type(type_id: int):
|
||||
"""Delete a relationship type. Refused if any relationship still uses it."""
|
||||
t = RelationshipType.query.get(type_id)
|
||||
t = db.session.get(RelationshipType, type_id)
|
||||
if not t:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404)
|
||||
inuse = AssetRelationship.query.filter_by(relationshiptypeid=type_id).count()
|
||||
@@ -412,7 +412,7 @@ def get_asset(asset_id: int):
|
||||
Query parameters:
|
||||
- include_type_data: Include category-specific extension data (default: true)
|
||||
"""
|
||||
asset = Asset.query.get(asset_id)
|
||||
asset = db.session.get(Asset, asset_id)
|
||||
|
||||
if not asset:
|
||||
return error_response(
|
||||
@@ -450,7 +450,7 @@ def create_asset():
|
||||
)
|
||||
|
||||
# Validate foreign keys exist
|
||||
if not AssetType.query.get(data['assettypeid']):
|
||||
if not db.session.get(AssetType, data['assettypeid']):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f"Asset type with ID {data['assettypeid']} not found"
|
||||
@@ -480,7 +480,7 @@ def create_asset():
|
||||
@require_permission('assets.edit')
|
||||
def update_asset(asset_id: int):
|
||||
"""Update an asset."""
|
||||
asset = Asset.query.get(asset_id)
|
||||
asset = db.session.get(Asset, asset_id)
|
||||
|
||||
if not asset:
|
||||
return error_response(
|
||||
@@ -521,7 +521,7 @@ def update_asset(asset_id: int):
|
||||
@require_permission('assets.delete')
|
||||
def delete_asset(asset_id: int):
|
||||
"""Delete (soft delete) an asset."""
|
||||
asset = Asset.query.get(asset_id)
|
||||
asset = db.session.get(Asset, asset_id)
|
||||
|
||||
if not asset:
|
||||
return error_response(
|
||||
@@ -568,7 +568,7 @@ def get_asset_relationships(asset_id: int):
|
||||
|
||||
Returns both outgoing (source) and incoming (target) relationships.
|
||||
"""
|
||||
asset = Asset.query.get(asset_id)
|
||||
asset = db.session.get(Asset, asset_id)
|
||||
|
||||
if not asset:
|
||||
return error_response(
|
||||
@@ -628,11 +628,11 @@ def create_asset_relationship():
|
||||
type_id = data['relationshiptypeid']
|
||||
|
||||
# Validate assets exist
|
||||
if not Asset.query.get(source_id):
|
||||
if not db.session.get(Asset, source_id):
|
||||
return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404)
|
||||
if not Asset.query.get(target_id):
|
||||
if not db.session.get(Asset, target_id):
|
||||
return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404)
|
||||
if not RelationshipType.query.get(type_id):
|
||||
if not db.session.get(RelationshipType, type_id):
|
||||
return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404)
|
||||
|
||||
# Check for duplicate relationship
|
||||
@@ -667,7 +667,7 @@ def create_asset_relationship():
|
||||
@require_permission('assets.delete')
|
||||
def delete_asset_relationship(rel_id: int):
|
||||
"""Delete an asset relationship."""
|
||||
rel = AssetRelationship.query.get(rel_id)
|
||||
rel = db.session.get(AssetRelationship, rel_id)
|
||||
|
||||
if not rel:
|
||||
return error_response(
|
||||
@@ -972,7 +972,7 @@ def get_asset_communications(asset_id: int):
|
||||
"""Get all communications for an asset."""
|
||||
from shopdb.core.models import Communication
|
||||
|
||||
asset = Asset.query.get(asset_id)
|
||||
asset = db.session.get(Asset, asset_id)
|
||||
|
||||
if not asset:
|
||||
return error_response(
|
||||
|
||||
@@ -103,7 +103,7 @@ def get_entity_history(entitytype: str, entityid: int):
|
||||
def get_stats():
|
||||
"""Get audit log statistics."""
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Actions by type
|
||||
actions = db_func_count_by(AuditLog.action)
|
||||
@@ -112,7 +112,7 @@ def get_stats():
|
||||
entities = db_func_count_by(AuditLog.entitytype)
|
||||
|
||||
# Recent activity (last 7 days)
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_ago = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=7)
|
||||
recent_count = AuditLog.query.filter(AuditLog.timestamp >= week_ago).count()
|
||||
|
||||
# Most active users (last 7 days)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Authentication API endpoints."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
@@ -12,7 +13,7 @@ from flask_jwt_extended import (
|
||||
)
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.extensions import db, cache
|
||||
from shopdb.core.models import User
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
|
||||
@@ -24,6 +25,39 @@ MAX_FAILED_LOGINS = 5
|
||||
LOCKOUT_MINUTES = 15
|
||||
|
||||
|
||||
def _login_ip():
|
||||
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop."""
|
||||
forwarded = request.headers.get('X-Forwarded-For')
|
||||
if forwarded:
|
||||
return forwarded.split(',')[0].strip()
|
||||
return request.remote_addr or 'unknown'
|
||||
|
||||
|
||||
def _login_ratelimited():
|
||||
"""Fixed-window per-IP login limiter. Returns True when the caller is over
|
||||
budget for the current window.
|
||||
|
||||
Backed by the existing cache extension (no new dependency). Under the
|
||||
default SimpleCache the counter is per-process, so with N gunicorn workers
|
||||
the effective budget is N x AUTH_RATELIMIT_MAX. This is defense in depth
|
||||
layered on top of the per-account lockout (see login()); a shared cache
|
||||
backend (Redis/memcached) tightens it to a true global budget.
|
||||
"""
|
||||
if not current_app.config.get('AUTH_RATELIMIT_ENABLED', True):
|
||||
return False
|
||||
window = current_app.config.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300)
|
||||
maxhits = current_app.config.get('AUTH_RATELIMIT_MAX', 30)
|
||||
# Time bucket makes this a fixed window: the key rolls over at each window
|
||||
# boundary, so a per-hit set() cannot turn it into a sliding window.
|
||||
bucket = int(time.time() // window) if window > 0 else 0
|
||||
key = f'loginratelimit:{_login_ip()}:{bucket}'
|
||||
count = cache.get(key) or 0
|
||||
if count >= maxhits:
|
||||
return True
|
||||
cache.set(key, count + 1, timeout=window)
|
||||
return False
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""
|
||||
@@ -44,6 +78,13 @@ def login():
|
||||
}
|
||||
}
|
||||
"""
|
||||
if _login_ratelimited():
|
||||
return error_response(
|
||||
'RATE_LIMITED',
|
||||
'Too many login attempts. Try again later.',
|
||||
http_code=429
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('username') or not data.get('password'):
|
||||
@@ -72,7 +113,9 @@ def login():
|
||||
if user:
|
||||
user.failedlogins = (user.failedlogins or 0) + 1
|
||||
if user.failedlogins >= MAX_FAILED_LOGINS:
|
||||
user.lockeduntil = datetime.utcnow() + timedelta(minutes=LOCKOUT_MINUTES)
|
||||
# Naive UTC to match the naive lockeduntil column comparisons.
|
||||
user.lockeduntil = datetime.now(timezone.utc).replace(tzinfo=None) \
|
||||
+ timedelta(minutes=LOCKOUT_MINUTES)
|
||||
user.failedlogins = 0
|
||||
db.session.commit()
|
||||
return error_response(
|
||||
|
||||
@@ -49,7 +49,7 @@ def list_businessunits():
|
||||
@jwt_required(optional=True)
|
||||
def get_businessunit(bu_id: int):
|
||||
"""Get a single business unit."""
|
||||
bu = BusinessUnit.query.get(bu_id)
|
||||
bu = db.session.get(BusinessUnit, bu_id)
|
||||
|
||||
if not bu:
|
||||
return error_response(
|
||||
@@ -100,7 +100,7 @@ def create_businessunit():
|
||||
@require_role('admin')
|
||||
def update_businessunit(bu_id: int):
|
||||
"""Update a business unit."""
|
||||
bu = BusinessUnit.query.get(bu_id)
|
||||
bu = db.session.get(BusinessUnit, bu_id)
|
||||
|
||||
if not bu:
|
||||
return error_response(
|
||||
@@ -134,7 +134,7 @@ def update_businessunit(bu_id: int):
|
||||
@require_role('admin')
|
||||
def delete_businessunit(bu_id: int):
|
||||
"""Delete (deactivate) a business unit."""
|
||||
bu = BusinessUnit.query.get(bu_id)
|
||||
bu = db.session.get(BusinessUnit, bu_id)
|
||||
|
||||
if not bu:
|
||||
return error_response(
|
||||
|
||||
@@ -6,7 +6,7 @@ API key (not JWT) for unattended scripts. Writes the asset/computer model
|
||||
(ADR-001), not the retired Machine model.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
from flask import Blueprint, request, current_app
|
||||
|
||||
@@ -37,7 +37,9 @@ def require_api_key(f):
|
||||
"""Require API key authentication."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
|
||||
# Header only. Querystring api_key was dropped so keys do not land in
|
||||
# access logs / proxy history (breaking change, see COLLECTOR-INTEGRATION.md).
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
expected_key = current_app.config.get('COLLECTOR_API_KEY')
|
||||
|
||||
if not expected_key:
|
||||
@@ -138,7 +140,8 @@ def generic_collect(pluginname):
|
||||
if not expected_key:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Collector API key not configured', http_code=500)
|
||||
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
|
||||
# Header only (querystring fallback dropped, see require_api_key).
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if api_key != expected_key:
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
@@ -161,12 +164,17 @@ def generic_collect(pluginname):
|
||||
f'Plugin {pluginname} does not implement apply_collector_payload',
|
||||
http_code=500)
|
||||
except ValueError as exc:
|
||||
# ValueError is the plugin's controlled validation signal; its message
|
||||
# is safe to return to the caller.
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
# Do not leak internals (stack detail, DB errors) to the caller; log it.
|
||||
db.session.rollback()
|
||||
current_app.logger.exception('Collector upsert failed for %s', pluginname)
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500)
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Internal error processing collector payload',
|
||||
http_code=500)
|
||||
|
||||
action = outcome.get('action', 'noop')
|
||||
AuditLog.log(
|
||||
@@ -217,7 +225,7 @@ def update_pc_info():
|
||||
f'PC with hostname {hostname} not found',
|
||||
http_code=404)
|
||||
|
||||
comp.lastreporteddate = datetime.utcnow()
|
||||
comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if data.get('lastboottime'):
|
||||
boot = _parse_boot(data['lastboottime'])
|
||||
@@ -326,7 +334,7 @@ def pc_heartbeat():
|
||||
|
||||
updated = 0
|
||||
not_found = []
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
for hostname in hostnames:
|
||||
comp = _find_pc(hostname)
|
||||
if comp:
|
||||
@@ -359,7 +367,7 @@ def bulk_update():
|
||||
updated = 0
|
||||
not_found = []
|
||||
errors = []
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
for pc_data in pcs:
|
||||
hostname = pc_data.get('hostname')
|
||||
@@ -380,8 +388,11 @@ def bulk_update():
|
||||
if boot:
|
||||
comp.lastboottime = boot
|
||||
updated += 1
|
||||
except Exception as exc:
|
||||
errors.append({'hostname': hostname, 'error': str(exc)})
|
||||
except Exception:
|
||||
# Keep the hostname so the caller knows which PC failed, but do not
|
||||
# leak the exception detail; log it server-side.
|
||||
current_app.logger.exception('Bulk update failed for %s', hostname)
|
||||
errors.append({'hostname': hostname, 'error': 'processing failed'})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@@ -399,7 +410,7 @@ def collector_status():
|
||||
"""Check collector API status."""
|
||||
return success_response({
|
||||
'status': 'ok',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
'endpoints': [
|
||||
'POST /api/collector/<plugin>',
|
||||
'GET /api/collector/_schemas',
|
||||
|
||||
@@ -68,7 +68,7 @@ def create_field():
|
||||
label = (data.get('label') or '').strip()
|
||||
if not assettypeid or not label:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid and label are required')
|
||||
if not AssetType.query.get(assettypeid):
|
||||
if not db.session.get(AssetType, assettypeid):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown assettypeid')
|
||||
|
||||
datatype = data.get('datatype') or 'text'
|
||||
@@ -112,7 +112,7 @@ def create_field():
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def update_field(fieldid):
|
||||
field = CustomField.query.get(fieldid)
|
||||
field = db.session.get(CustomField, fieldid)
|
||||
if not field:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Custom field not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
@@ -137,7 +137,7 @@ def update_field(fieldid):
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def delete_field(fieldid):
|
||||
field = CustomField.query.get(fieldid)
|
||||
field = db.session.get(CustomField, fieldid)
|
||||
if not field:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Custom field not found', http_code=404)
|
||||
# Drop the field and any stored values for it.
|
||||
@@ -172,7 +172,7 @@ def _fields_with_values(asset):
|
||||
@jwt_required(optional=True)
|
||||
def get_asset_fields(assetid):
|
||||
"""Active custom fields for an asset's type, merged with its values."""
|
||||
asset = Asset.query.get(assetid)
|
||||
asset = db.session.get(Asset, assetid)
|
||||
if not asset:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||
return success_response(_fields_with_values(asset))
|
||||
@@ -183,7 +183,7 @@ def get_asset_fields(assetid):
|
||||
@require_role('admin')
|
||||
def save_asset_fields(assetid):
|
||||
"""Upsert values for an asset. Body: {values: {fieldid: value, ...}}."""
|
||||
asset = Asset.query.get(assetid)
|
||||
asset = db.session.get(Asset, assetid)
|
||||
if not asset:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
|
||||
@@ -185,6 +185,8 @@ def get_widgets():
|
||||
@dashboard_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Health check endpoint (no auth required)."""
|
||||
from shopdb import __version__
|
||||
|
||||
try:
|
||||
db.session.execute(db.text('SELECT 1'))
|
||||
db_status = 'healthy'
|
||||
@@ -194,5 +196,5 @@ def health_check():
|
||||
return success_response({
|
||||
'status': 'ok' if db_status == 'healthy' else 'degraded',
|
||||
'database': db_status,
|
||||
'version': '1.0.0'
|
||||
'version': __version__
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ from flask_jwt_extended import jwt_required
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import DashboardDefault, BusinessUnit, AuditLog
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
from shopdb.utils.authz import require_role
|
||||
|
||||
dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
|
||||
|
||||
@@ -64,6 +65,7 @@ def list_defaults():
|
||||
|
||||
@dashboarddefaults_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def create_default():
|
||||
"""Create a visitor-IP -> business-unit mapping."""
|
||||
data = request.get_json() or {}
|
||||
@@ -74,7 +76,7 @@ def create_default():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required')
|
||||
if not businessunitid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunitid is required')
|
||||
if not BusinessUnit.query.get(businessunitid):
|
||||
if not db.session.get(BusinessUnit, businessunitid):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
|
||||
http_code=404)
|
||||
if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first():
|
||||
@@ -94,15 +96,16 @@ def create_default():
|
||||
|
||||
@dashboarddefaults_bp.route('/<int:default_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def update_default(default_id):
|
||||
"""Update a mapping."""
|
||||
default = DashboardDefault.query.get(default_id)
|
||||
default = db.session.get(DashboardDefault, default_id)
|
||||
if not default:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
if 'businessunitid' in data:
|
||||
if not BusinessUnit.query.get(data['businessunitid']):
|
||||
if not db.session.get(BusinessUnit, data['businessunitid']):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
|
||||
http_code=404)
|
||||
default.businessunitid = data['businessunitid']
|
||||
@@ -117,9 +120,10 @@ def update_default(default_id):
|
||||
|
||||
@dashboarddefaults_bp.route('/<int:default_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def delete_default(default_id):
|
||||
"""Delete (deactivate) a mapping."""
|
||||
default = DashboardDefault.query.get(default_id)
|
||||
default = db.session.get(DashboardDefault, default_id)
|
||||
if not default:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
|
||||
default.isactive = False
|
||||
|
||||
@@ -68,7 +68,7 @@ def create_location_type():
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def update_location_type(type_id):
|
||||
t = LocationType.query.get(type_id)
|
||||
t = db.session.get(LocationType, type_id)
|
||||
if not t:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Location type not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
@@ -87,7 +87,7 @@ def update_location_type(type_id):
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def delete_location_type(type_id):
|
||||
t = LocationType.query.get(type_id)
|
||||
t = db.session.get(LocationType, type_id)
|
||||
if not t:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Location type not found', http_code=404)
|
||||
inuse = Location.query.filter_by(locationtypeid=type_id).count()
|
||||
@@ -130,7 +130,7 @@ def list_locations():
|
||||
@jwt_required(optional=True)
|
||||
def get_location(location_id: int):
|
||||
"""Get a single location."""
|
||||
loc = Location.query.get(location_id)
|
||||
loc = db.session.get(Location, location_id)
|
||||
|
||||
if not loc:
|
||||
return error_response(
|
||||
@@ -183,7 +183,7 @@ def create_location():
|
||||
@require_role('admin')
|
||||
def update_location(location_id: int):
|
||||
"""Update a location."""
|
||||
loc = Location.query.get(location_id)
|
||||
loc = db.session.get(Location, location_id)
|
||||
|
||||
if not loc:
|
||||
return error_response(
|
||||
@@ -219,7 +219,7 @@ def update_location(location_id: int):
|
||||
@require_role('admin')
|
||||
def delete_location(location_id: int):
|
||||
"""Delete (deactivate) a location."""
|
||||
loc = Location.query.get(location_id)
|
||||
loc = db.session.get(Location, location_id)
|
||||
|
||||
if not loc:
|
||||
return error_response(
|
||||
|
||||
@@ -47,7 +47,7 @@ def list_machinetypes():
|
||||
@jwt_required(optional=True)
|
||||
def get_machinetype(type_id: int):
|
||||
"""Get a single machine type."""
|
||||
mt = MachineType.query.get(type_id)
|
||||
mt = db.session.get(MachineType, type_id)
|
||||
|
||||
if not mt:
|
||||
return error_response(
|
||||
@@ -94,7 +94,7 @@ def create_machinetype():
|
||||
@require_role('admin')
|
||||
def update_machinetype(type_id: int):
|
||||
"""Update a machine type."""
|
||||
mt = MachineType.query.get(type_id)
|
||||
mt = db.session.get(MachineType, type_id)
|
||||
|
||||
if not mt:
|
||||
return error_response(
|
||||
@@ -129,7 +129,7 @@ def update_machinetype(type_id: int):
|
||||
@require_role('admin')
|
||||
def delete_machinetype(type_id: int):
|
||||
"""Delete (deactivate) a machine type."""
|
||||
mt = MachineType.query.get(type_id)
|
||||
mt = db.session.get(MachineType, type_id)
|
||||
|
||||
if not mt:
|
||||
return error_response(
|
||||
|
||||
@@ -56,7 +56,7 @@ def list_models():
|
||||
@jwt_required(optional=True)
|
||||
def get_model(model_id: int):
|
||||
"""Get a single model."""
|
||||
m = Model.query.get(model_id)
|
||||
m = db.session.get(Model, model_id)
|
||||
|
||||
if not m:
|
||||
return error_response(
|
||||
@@ -115,7 +115,7 @@ def create_model():
|
||||
@require_role('admin')
|
||||
def update_model(model_id: int):
|
||||
"""Update a model."""
|
||||
m = Model.query.get(model_id)
|
||||
m = db.session.get(Model, model_id)
|
||||
|
||||
if not m:
|
||||
return error_response(
|
||||
@@ -141,7 +141,7 @@ def update_model(model_id: int):
|
||||
@require_role('admin')
|
||||
def delete_model(model_id: int):
|
||||
"""Delete (deactivate) a model."""
|
||||
m = Model.query.get(model_id)
|
||||
m = db.session.get(Model, model_id)
|
||||
|
||||
if not m:
|
||||
return error_response(
|
||||
|
||||
@@ -44,7 +44,7 @@ def list_operatingsystems():
|
||||
@jwt_required(optional=True)
|
||||
def get_operatingsystem(os_id: int):
|
||||
"""Get a single operating system."""
|
||||
os = OperatingSystem.query.get(os_id)
|
||||
os = db.session.get(OperatingSystem, os_id)
|
||||
|
||||
if not os:
|
||||
return error_response(
|
||||
@@ -95,7 +95,7 @@ def create_operatingsystem():
|
||||
@require_role('admin')
|
||||
def update_operatingsystem(os_id: int):
|
||||
"""Update an operating system."""
|
||||
os = OperatingSystem.query.get(os_id)
|
||||
os = db.session.get(OperatingSystem, os_id)
|
||||
|
||||
if not os:
|
||||
return error_response(
|
||||
@@ -121,7 +121,7 @@ def update_operatingsystem(os_id: int):
|
||||
@require_role('admin')
|
||||
def delete_operatingsystem(os_id: int):
|
||||
"""Delete (deactivate) an operating system."""
|
||||
os = OperatingSystem.query.get(os_id)
|
||||
os = db.session.get(OperatingSystem, os_id)
|
||||
|
||||
if not os:
|
||||
return error_response(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from flask import Blueprint, request, Response
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
@@ -84,7 +84,7 @@ def equipment_by_type():
|
||||
|
||||
return success_response({
|
||||
'report': 'equipment_by_type',
|
||||
'generated': datetime.utcnow().isoformat(),
|
||||
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
'data': data,
|
||||
'total': total
|
||||
})
|
||||
@@ -143,7 +143,7 @@ def assets_by_status():
|
||||
|
||||
return success_response({
|
||||
'report': 'assets_by_status',
|
||||
'generated': datetime.utcnow().isoformat(),
|
||||
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
'data': data,
|
||||
'total': total
|
||||
})
|
||||
@@ -200,7 +200,7 @@ def kb_popularity():
|
||||
|
||||
return success_response({
|
||||
'report': 'kb_popularity',
|
||||
'generated': datetime.utcnow().isoformat(),
|
||||
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
'data': data,
|
||||
'total': len(data)
|
||||
})
|
||||
@@ -222,7 +222,7 @@ def warranty_status():
|
||||
- assettypeid: Filter by asset type
|
||||
- format: 'json' (default) or 'csv'
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
expiring_threshold = now + timedelta(days=90)
|
||||
|
||||
# Try to get warranty data from equipment or machines
|
||||
@@ -298,7 +298,7 @@ def warranty_status():
|
||||
|
||||
return success_response({
|
||||
'report': 'warranty_status',
|
||||
'generated': datetime.utcnow().isoformat(),
|
||||
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
'data': data,
|
||||
'summary': {
|
||||
'expired': data['expired']['count'],
|
||||
@@ -344,7 +344,7 @@ def software_compliance():
|
||||
if not required_apps:
|
||||
return success_response({
|
||||
'report': 'software_compliance',
|
||||
'generated': datetime.utcnow().isoformat(),
|
||||
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
'data': [],
|
||||
'message': 'No required applications defined'
|
||||
})
|
||||
@@ -422,7 +422,7 @@ def software_compliance():
|
||||
|
||||
return success_response({
|
||||
'report': 'software_compliance',
|
||||
'generated': datetime.utcnow().isoformat(),
|
||||
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
'data': data,
|
||||
'total': len(data)
|
||||
})
|
||||
@@ -507,7 +507,7 @@ def asset_inventory():
|
||||
csv_output = io.StringIO()
|
||||
|
||||
csv_output.write("Asset Inventory Report\n")
|
||||
csv_output.write(f"Generated: {datetime.utcnow().isoformat()}\n\n")
|
||||
csv_output.write(f"Generated: {datetime.now(timezone.utc).replace(tzinfo=None).isoformat()}\n\n")
|
||||
|
||||
csv_output.write("By Type\n")
|
||||
csv_output.write("Type,Count\n")
|
||||
@@ -532,7 +532,7 @@ def asset_inventory():
|
||||
|
||||
return success_response({
|
||||
'report': 'asset_inventory',
|
||||
'generated': datetime.utcnow().isoformat(),
|
||||
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
'data': data,
|
||||
'total': total
|
||||
})
|
||||
@@ -599,7 +599,7 @@ def pc_relationships():
|
||||
|
||||
return success_response({
|
||||
'report': 'pc_relationships',
|
||||
'generated': datetime.utcnow().isoformat(),
|
||||
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
'data': data,
|
||||
'total': len(data)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy.orm import joinedload
|
||||
@@ -14,6 +14,7 @@ from shopdb.core.models import (
|
||||
Application, Setting,
|
||||
Asset, AssetType, Communication, Vendor, Model
|
||||
)
|
||||
from shopdb.core.api.settings import get_cached_settings
|
||||
from shopdb.utils.responses import success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,22 +33,74 @@ def _require_enabled(name):
|
||||
if pm and not pm.registry.is_enabled(name):
|
||||
raise ImportError(f'{name} plugin disabled')
|
||||
|
||||
# ServiceNOW URL template
|
||||
SERVICENOW_URL = (
|
||||
'https://geit.service-now.com/now/nav/ui/search/'
|
||||
# Shipped GE defaults. Settings override these per-site; identical fallbacks
|
||||
# live here so this consumer works even if the settings seed has not run.
|
||||
SERVICENOW_URL_DEFAULT = (
|
||||
'https://geaerospaceqa.service-now.com/now/nav/ui/search/'
|
||||
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
|
||||
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
||||
'back-button-label/IT4IT%20Homepage/search-context/now%2Fnav%2Fui'
|
||||
)
|
||||
SERVICENOW_PREFIXES_DEFAULT = 'GEINC,GECHG,GERIT,GESCT'
|
||||
EMPLOYEEID_PATTERN_DEFAULT = r'^\d{9}$'
|
||||
|
||||
|
||||
def _classify_query(query):
|
||||
def _get_search_integrations():
|
||||
"""Resolve the settings-driven search integration config.
|
||||
|
||||
Reads employeeid_pattern, servicenow_ticket_prefixes, servicenow_enabled
|
||||
and servicenow_search_url from cached settings, falling back to the shipped
|
||||
GE defaults for any missing key. An invalid employeeid_pattern regex falls
|
||||
back to the default rather than raising (search must never 500 on bad
|
||||
config). ServiceNow is inactive when disabled, when the URL is blank, or
|
||||
when no ticket prefixes are configured.
|
||||
"""
|
||||
settings = get_cached_settings()
|
||||
|
||||
# Employee-ID pattern. Bad regex falls back so search never 500s.
|
||||
pattern = settings.get('employeeid_pattern') or EMPLOYEEID_PATTERN_DEFAULT
|
||||
try:
|
||||
employeeid_re = re.compile(pattern)
|
||||
except re.error:
|
||||
employeeid_re = re.compile(EMPLOYEEID_PATTERN_DEFAULT)
|
||||
|
||||
# Ticket prefixes -> case-insensitive alternation built at request time.
|
||||
prefixes_raw = settings.get('servicenow_ticket_prefixes')
|
||||
if prefixes_raw is None:
|
||||
prefixes_raw = SERVICENOW_PREFIXES_DEFAULT
|
||||
prefixes = [p.strip() for p in str(prefixes_raw).split(',') if p.strip()]
|
||||
|
||||
servicenow_enabled = settings.get('servicenow_enabled')
|
||||
if servicenow_enabled is None:
|
||||
servicenow_enabled = True
|
||||
|
||||
servicenow_url = settings.get('servicenow_search_url')
|
||||
if servicenow_url is None:
|
||||
servicenow_url = SERVICENOW_URL_DEFAULT
|
||||
|
||||
servicenow_active = bool(servicenow_enabled) and bool(servicenow_url) and bool(prefixes)
|
||||
|
||||
prefix_re = None
|
||||
if servicenow_active:
|
||||
alternation = '|'.join(re.escape(p) for p in prefixes)
|
||||
prefix_re = re.compile(r'^(' + alternation + r')\d+', re.IGNORECASE)
|
||||
|
||||
return {
|
||||
'employeeid_re': employeeid_re,
|
||||
'prefix_re': prefix_re,
|
||||
'servicenow_active': servicenow_active,
|
||||
'servicenow_url': servicenow_url,
|
||||
}
|
||||
|
||||
|
||||
def _classify_query(query, integrations):
|
||||
"""Analyze the query string to determine its nature."""
|
||||
prefix_re = integrations['prefix_re']
|
||||
sn_match = prefix_re.match(query) if prefix_re else None
|
||||
return {
|
||||
'is_ip': bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query)),
|
||||
'is_sso': bool(re.match(r'^\d{9}$', query)),
|
||||
'is_servicenow': bool(re.match(r'^(GEINC|GECHG|GERIT|GESCT)\d+', query, re.IGNORECASE)),
|
||||
'servicenow_prefix': re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE).group(1) if re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE) else None,
|
||||
'is_sso': bool(integrations['employeeid_re'].match(query)),
|
||||
'is_servicenow': bool(sn_match),
|
||||
'servicenow_prefix': sn_match.group(1) if sn_match else None,
|
||||
'is_fqdn': bool(re.match(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$', query)),
|
||||
}
|
||||
|
||||
@@ -393,7 +446,7 @@ def _search_notifications(query, search_term):
|
||||
)
|
||||
).order_by(Notification.starttime.desc()).limit(15).all()
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
for notif in notifications:
|
||||
base_relevance = 20
|
||||
if notif.ticketnumber and query.lower() == notif.ticketnumber.lower():
|
||||
@@ -671,12 +724,13 @@ def global_search():
|
||||
'message': 'Search query too long'
|
||||
})
|
||||
|
||||
classification = _classify_query(query)
|
||||
integrations = _get_search_integrations()
|
||||
classification = _classify_query(query, integrations)
|
||||
|
||||
# ServiceNOW prefix detection - return redirect immediately
|
||||
if classification['is_servicenow']:
|
||||
from urllib.parse import quote
|
||||
servicenow_url = SERVICENOW_URL.format(ticket=quote(query))
|
||||
servicenow_url = integrations['servicenow_url'].format(ticket=quote(query))
|
||||
return success_response({
|
||||
'results': [],
|
||||
'query': query,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,17 +7,16 @@ from werkzeug.security import generate_password_hash
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import User, Role, Permission, AuditLog
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
from shopdb.utils.authz import require_role
|
||||
|
||||
users_bp = Blueprint('users', __name__)
|
||||
|
||||
|
||||
@users_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def list_users():
|
||||
"""List all users."""
|
||||
if not current_user.hasrole('admin'):
|
||||
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
|
||||
|
||||
users = User.query.order_by(User.username).all()
|
||||
return success_response([user_to_dict(u) for u in users])
|
||||
|
||||
@@ -26,10 +25,11 @@ def list_users():
|
||||
@jwt_required()
|
||||
def get_user(userid: int):
|
||||
"""Get a single user."""
|
||||
# inline: decorators cannot express admin-or-self
|
||||
if not current_user.hasrole('admin') and current_user.userid != userid:
|
||||
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
|
||||
|
||||
user = User.query.get(userid)
|
||||
user = db.session.get(User, userid)
|
||||
if not user:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
|
||||
|
||||
@@ -38,11 +38,9 @@ def get_user(userid: int):
|
||||
|
||||
@users_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def create_user():
|
||||
"""Create a new user."""
|
||||
if not current_user.hasrole('admin'):
|
||||
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
|
||||
@@ -90,10 +88,11 @@ def create_user():
|
||||
@jwt_required()
|
||||
def update_user(userid: int):
|
||||
"""Update a user."""
|
||||
# inline: decorators cannot express admin-or-self
|
||||
if not current_user.hasrole('admin') and current_user.userid != userid:
|
||||
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
|
||||
|
||||
user = User.query.get(userid)
|
||||
user = db.session.get(User, userid)
|
||||
if not user:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
|
||||
|
||||
@@ -120,7 +119,7 @@ def update_user(userid: int):
|
||||
changes['lastname'] = {'old': user.lastname, 'new': data['lastname']}
|
||||
user.lastname = data['lastname']
|
||||
|
||||
# Admin-only fields
|
||||
# Admin-only fields (inline: gates a subset of fields on a shared route)
|
||||
if current_user.hasrole('admin'):
|
||||
if 'isactive' in data:
|
||||
if data['isactive'] != user.isactive:
|
||||
@@ -156,15 +155,13 @@ def update_user(userid: int):
|
||||
|
||||
@users_bp.route('/<int:userid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def delete_user(userid: int):
|
||||
"""Delete a user."""
|
||||
if not current_user.hasrole('admin'):
|
||||
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
|
||||
|
||||
if current_user.userid == userid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account')
|
||||
|
||||
user = User.query.get(userid)
|
||||
user = db.session.get(User, userid)
|
||||
if not user:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
|
||||
|
||||
@@ -225,11 +222,9 @@ def list_roles():
|
||||
|
||||
@users_bp.route('/roles', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def create_role():
|
||||
"""Create a new role."""
|
||||
if not current_user.hasrole('admin'):
|
||||
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
|
||||
|
||||
data = request.get_json()
|
||||
if not data or not data.get('rolename'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required')
|
||||
@@ -263,12 +258,10 @@ def create_role():
|
||||
|
||||
@users_bp.route('/roles/<int:roleid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def update_role(roleid: int):
|
||||
"""Update a role."""
|
||||
if not current_user.hasrole('admin'):
|
||||
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
|
||||
|
||||
role = Role.query.get(roleid)
|
||||
role = db.session.get(Role, roleid)
|
||||
if not role:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
|
||||
|
||||
@@ -308,12 +301,10 @@ def update_role(roleid: int):
|
||||
|
||||
@users_bp.route('/roles/<int:roleid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def delete_role(roleid: int):
|
||||
"""Delete a role."""
|
||||
if not current_user.hasrole('admin'):
|
||||
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
|
||||
|
||||
role = Role.query.get(roleid)
|
||||
role = db.session.get(Role, roleid)
|
||||
if not role:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ def list_vendors():
|
||||
@jwt_required(optional=True)
|
||||
def get_vendor(vendor_id: int):
|
||||
"""Get a single vendor."""
|
||||
v = Vendor.query.get(vendor_id)
|
||||
v = db.session.get(Vendor, vendor_id)
|
||||
|
||||
if not v:
|
||||
return error_response(
|
||||
@@ -93,7 +93,7 @@ def create_vendor():
|
||||
@require_role('admin')
|
||||
def update_vendor(vendor_id: int):
|
||||
"""Update a vendor."""
|
||||
v = Vendor.query.get(vendor_id)
|
||||
v = db.session.get(Vendor, vendor_id)
|
||||
|
||||
if not v:
|
||||
return error_response(
|
||||
@@ -127,7 +127,7 @@ def update_vendor(vendor_id: int):
|
||||
@require_role('admin')
|
||||
def delete_vendor(vendor_id: int):
|
||||
"""Delete (deactivate) a vendor."""
|
||||
v = Vendor.query.get(vendor_id)
|
||||
v = db.session.get(Vendor, vendor_id)
|
||||
|
||||
if not v:
|
||||
return error_response(
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Audit log model for tracking changes."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.extensions import db
|
||||
|
||||
def _utcnow():
|
||||
# naive UTC for DB columns (stored without tzinfo)
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""
|
||||
@@ -19,7 +23,7 @@ class AuditLog(db.Model):
|
||||
username = db.Column(db.String(100), nullable=True) # Denormalized for history
|
||||
|
||||
# When
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False, index=True)
|
||||
|
||||
# Where (client info)
|
||||
ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""Base model class with common fields."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.extensions import db
|
||||
|
||||
|
||||
def _utcnow():
|
||||
# naive UTC for DB columns (stored without tzinfo)
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class BaseModel(db.Model):
|
||||
"""
|
||||
Abstract base model with common fields.
|
||||
@@ -13,13 +18,13 @@ class BaseModel(db.Model):
|
||||
|
||||
createddate = db.Column(
|
||||
db.DateTime,
|
||||
default=datetime.utcnow,
|
||||
default=_utcnow,
|
||||
nullable=False
|
||||
)
|
||||
modifieddate = db.Column(
|
||||
db.DateTime,
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
default=_utcnow,
|
||||
onupdate=_utcnow,
|
||||
nullable=False
|
||||
)
|
||||
isactive = db.Column(db.Boolean, default=True, nullable=False)
|
||||
@@ -55,7 +60,7 @@ class SoftDeleteMixin:
|
||||
def soft_delete(self, deleted_by: str = None):
|
||||
"""Mark record as deleted."""
|
||||
self.isactive = False
|
||||
self.deleteddate = datetime.utcnow()
|
||||
self.deleteddate = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
self.deletedby = deleted_by
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
"""System settings model for key-value configuration storage."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.extensions import db
|
||||
|
||||
|
||||
def _utcnow():
|
||||
"""Naive UTC now for column defaults (matches the app's naive datetime cols)."""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class Setting(db.Model):
|
||||
"""
|
||||
Key-value store for system settings.
|
||||
@@ -19,8 +27,8 @@ class Setting(db.Model):
|
||||
valuetype = db.Column(db.String(20), default='string') # string, boolean, integer, json
|
||||
category = db.Column(db.String(50), default='general') # For grouping in UI
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
createddate = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
modifieddate = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
createddate = db.Column(db.DateTime, default=_utcnow)
|
||||
modifieddate = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -55,19 +63,40 @@ class Setting(db.Model):
|
||||
return setting.get_typed_value()
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _stringify(value):
|
||||
"""Convert a value to its stored string form."""
|
||||
if isinstance(value, bool):
|
||||
return 'true' if value else 'false'
|
||||
return str(value) if value is not None else None
|
||||
|
||||
@classmethod
|
||||
def set(cls, key: str, value, valuetype: str = 'string', category: str = 'general', description: str = None):
|
||||
"""Set a setting value, creating if it doesn't exist."""
|
||||
"""Set a setting value, creating if it doesn't exist.
|
||||
|
||||
Handles the create race: two concurrent callers can both find no row and
|
||||
both try to INSERT the same unique key. The loser's commit raises
|
||||
IntegrityError; we roll back, re-fetch the row the winner created, and
|
||||
apply our value to it.
|
||||
"""
|
||||
setting = cls.query.filter_by(key=key).first()
|
||||
if not setting:
|
||||
setting = cls(key=key, valuetype=valuetype, category=category, description=description)
|
||||
db.session.add(setting)
|
||||
if setting:
|
||||
setting.value = cls._stringify(value)
|
||||
db.session.commit()
|
||||
return setting
|
||||
|
||||
# Convert value to string for storage
|
||||
if isinstance(value, bool):
|
||||
setting.value = 'true' if value else 'false'
|
||||
else:
|
||||
setting.value = str(value) if value is not None else None
|
||||
|
||||
db.session.commit()
|
||||
return setting
|
||||
setting = cls(key=key, valuetype=valuetype, category=category,
|
||||
description=description, value=cls._stringify(value))
|
||||
db.session.add(setting)
|
||||
try:
|
||||
db.session.commit()
|
||||
return setting
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
# Another transaction inserted this key first; update that row.
|
||||
setting = cls.query.filter_by(key=key).first()
|
||||
if setting is None:
|
||||
raise
|
||||
setting.value = cls._stringify(value)
|
||||
db.session.commit()
|
||||
return setting
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""User and authentication models."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
@@ -179,7 +179,7 @@ class User(BaseModel):
|
||||
def islocked(self):
|
||||
"""Check if account is locked."""
|
||||
if self.lockeduntil:
|
||||
return datetime.utcnow() < self.lockeduntil
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None) < self.lockeduntil
|
||||
return False
|
||||
|
||||
def hasrole(self, rolename: str) -> bool:
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -59,7 +59,7 @@ class PluginRegistry:
|
||||
state = PluginState(
|
||||
name=name,
|
||||
version=version,
|
||||
installed_at=datetime.utcnow().isoformat(),
|
||||
installed_at=datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
enabled=enabled
|
||||
)
|
||||
self._plugins[name] = state
|
||||
|
||||
11
shopdb/static/images/floorplan-placeholder.svg
Normal file
11
shopdb/static/images/floorplan-placeholder.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="3300" height="2550" viewBox="0 0 3300 2550">
|
||||
<defs>
|
||||
<pattern id="grid" width="150" height="150" patternUnits="userSpaceOnUse">
|
||||
<path d="M 150 0 L 0 0 0 150" fill="none" stroke="#d0d4d9" stroke-width="2"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="3300" height="2550" fill="#f4f6f8"/>
|
||||
<rect width="3300" height="2550" fill="url(#grid)"/>
|
||||
<rect x="20" y="20" width="3260" height="2510" fill="none" stroke="#b8bec6" stroke-width="4"/>
|
||||
<text x="1650" y="1275" fill="#8a9099" font-family="Arial, Helvetica, sans-serif" font-size="90" font-weight="600" text-anchor="middle" dominant-baseline="central">Upload your facility floor plan in Settings > Map</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 726 B |
@@ -2,7 +2,7 @@
|
||||
|
||||
from flask import jsonify, make_response
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def api_response(
|
||||
response = {
|
||||
'status': status,
|
||||
'meta': {
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||
'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z',
|
||||
'requestid': str(uuid.uuid4())[:8],
|
||||
**(meta or {})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user