Establishes the framework's foundation as a multi-site adoptable platform. ADRs (migrations/adr/): - ADR-001 (ACCEPTED): Asset is the platform contract; Machine retires. Three relationship types (partof, controls, connectedto) with free-text label, position-resolution chain (asset > related > location), hierarchical locations, sibling-bay propagation. - ADR-002 (ACCEPTED): Plugin contract semver via __contract_version__. - ADR-003 (ACCEPTED): Hybrid plugin distribution (in-tree bundled + filesystem-based external). - ADR-004 (ACCEPTED): Per-site instances, not multi-tenant. - ADR-005 (ACCEPTED): Equipment plugin (manufacturing) split from measuringtools plugin (metrology). Subtype-table pattern for protocol data (FOCAS, CLM, MTConnect). - ADR-006 (ACCEPTED): Plugin collector contract via get_collector_schema hook with API-key auth and identity-based upsert. Naming convention v1 (CONTRIBUTING.md): - DB tables/columns: lowercase concatenated, no underscores or dashes - DB-mirrored Python/JS variables match column names exactly; pure code follows host-language convention (PEP 8 / camelCase) - Closed acronym allowlist (universal + shop-floor domain), banned shorthand list with suffix exception (printers_bp etc allowed) - Plain ASCII everywhere: chat, docs, comments, string literals Style enforcement (scripts/check-naming-and-style.sh): - Pre-commit-runnable check script: non-ASCII, banned shorthand, snake_case DB names, snake_case API params in frontend - Fixes 14 violations across 11 files (Unicode arrows, snake_case params, ctx -> canvasContext, res -> response, req -> request_obj) Project state (CLAUDE.md, README.md, frontend/CLAUDE.md): - De-staled CLAUDE.md to reflect actual current state - README unifies DB story (MySQL canonical, SQLite test-only) - frontend/CLAUDE.md points at root convention Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
5.8 KiB
Python
157 lines
5.8 KiB
Python
"""Audit log model for tracking changes."""
|
|
|
|
from datetime import datetime
|
|
from shopdb.extensions import db
|
|
|
|
|
|
class AuditLog(db.Model):
|
|
"""
|
|
Audit log for tracking user actions.
|
|
|
|
Records who did what, when, from where, and what changed.
|
|
"""
|
|
__tablename__ = 'auditlogs'
|
|
|
|
auditlogid = db.Column(db.Integer, primary_key=True)
|
|
|
|
# Who
|
|
userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=True)
|
|
username = db.Column(db.String(100), nullable=True) # Denormalized for history
|
|
|
|
# When
|
|
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
|
|
|
# Where (client info)
|
|
ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length
|
|
useragent = db.Column(db.String(255), nullable=True)
|
|
|
|
# What
|
|
action = db.Column(db.String(20), nullable=False, index=True) # created, updated, deleted
|
|
entitytype = db.Column(db.String(50), nullable=False, index=True) # Asset, Printer, Setting, etc.
|
|
entityid = db.Column(db.Integer, nullable=True) # ID of the affected record
|
|
entityname = db.Column(db.String(255), nullable=True) # Human-readable identifier
|
|
|
|
# Changes (stored as JSON text)
|
|
_changes = db.Column('changes', db.Text, nullable=True) # {"field": {"old": x, "new": y}, ...}
|
|
|
|
@property
|
|
def changes(self):
|
|
"""Get changes as dict."""
|
|
if self._changes:
|
|
import json
|
|
try:
|
|
return json.loads(self._changes)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
return None
|
|
|
|
@changes.setter
|
|
def changes(self, value):
|
|
"""Set changes from dict."""
|
|
if value is not None:
|
|
import json
|
|
self._changes = json.dumps(value)
|
|
else:
|
|
self._changes = None
|
|
|
|
# Additional context
|
|
details = db.Column(db.Text, nullable=True) # Optional description
|
|
|
|
# Relationship
|
|
user = db.relationship('User', backref=db.backref('auditlogs', lazy='dynamic'))
|
|
|
|
def to_dict(self):
|
|
return {
|
|
'auditlogid': self.auditlogid,
|
|
'userid': self.userid,
|
|
'username': self.username,
|
|
'timestamp': self.timestamp.isoformat() + 'Z' if self.timestamp else None,
|
|
'ipaddress': self.ipaddress,
|
|
'useragent': self.useragent,
|
|
'action': self.action,
|
|
'entitytype': self.entitytype,
|
|
'entityid': self.entityid,
|
|
'entityname': self.entityname,
|
|
'changes': self.changes,
|
|
'details': self.details
|
|
}
|
|
|
|
@classmethod
|
|
def log(cls, action: str, entitytype: str, entityid: int = None,
|
|
entityname: str = None, changes: dict = None, details: str = None,
|
|
user=None, request=None):
|
|
"""
|
|
Create an audit log entry.
|
|
|
|
Args:
|
|
action: 'created', 'updated', 'deleted'
|
|
entitytype: Type of entity (e.g., 'Asset', 'Printer', 'Setting')
|
|
entityid: ID of the affected record
|
|
entityname: Human-readable name/identifier
|
|
changes: Dict of field changes {"field": {"old": x, "new": y}}
|
|
details: Optional description
|
|
user: Current user object (or will try to get from flask-jwt-extended)
|
|
request: Flask request object (or will try to get current request)
|
|
"""
|
|
from flask import request as flask_request
|
|
from flask_jwt_extended import current_user, verify_jwt_in_request
|
|
|
|
# Get user info
|
|
if user is None:
|
|
try:
|
|
verify_jwt_in_request(optional=True)
|
|
user = current_user
|
|
except:
|
|
pass
|
|
|
|
userid = user.userid if user else None
|
|
username = user.username if user else None
|
|
|
|
# Get request info
|
|
request_obj = request or flask_request
|
|
ipaddress = None
|
|
useragent = None
|
|
|
|
if request_obj:
|
|
# Handle proxy forwarding
|
|
ipaddress = request_obj.headers.get('X-Forwarded-For', request_obj.remote_addr)
|
|
if ipaddress and ',' in ipaddress:
|
|
ipaddress = ipaddress.split(',')[0].strip()
|
|
useragent = request_obj.headers.get('User-Agent', '')[:255]
|
|
|
|
entry = cls(
|
|
userid=userid,
|
|
username=username,
|
|
ipaddress=ipaddress,
|
|
useragent=useragent,
|
|
action=action,
|
|
entitytype=entitytype,
|
|
entityid=entityid,
|
|
entityname=entityname,
|
|
changes=changes,
|
|
details=details
|
|
)
|
|
|
|
db.session.add(entry)
|
|
# Don't commit here - let the caller handle transaction
|
|
return entry
|
|
|
|
@classmethod
|
|
def log_create(cls, entitytype: str, entity, name_field: str = 'name'):
|
|
"""Log a create action."""
|
|
entityname = getattr(entity, name_field, None) or str(entity)
|
|
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
|
|
return cls.log('created', entitytype, entityid=entityid, entityname=entityname)
|
|
|
|
@classmethod
|
|
def log_update(cls, entitytype: str, entity, changes: dict, name_field: str = 'name'):
|
|
"""Log an update action with changes."""
|
|
entityname = getattr(entity, name_field, None) or str(entity)
|
|
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
|
|
return cls.log('updated', entitytype, entityid=entityid, entityname=entityname, changes=changes)
|
|
|
|
@classmethod
|
|
def log_delete(cls, entitytype: str, entityid: int, entityname: str = None):
|
|
"""Log a delete action."""
|
|
return cls.log('deleted', entitytype, entityid=entityid, entityname=entityname)
|