Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
"""Core SQLAlchemy models."""
from .base import BaseModel, SoftDeleteMixin, AuditMixin
from .asset import Asset, AssetType, AssetStatus
from .modeltype import ModelType
from .vendor import Vendor
from .model import Model
from .businessunit import BusinessUnit
from .dashboarddefault import DashboardDefault
from .location import Location, LocationType, derive_locationcode
from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
from .communication import Communication, CommunicationType
from .user import User, Role, Permission, full_permission_catalog
from .application import Application, AppVersion
from .supportteam import SupportTeam, SupportTeamContact
from .setting import Setting
from .auditlog import AuditLog
from .customfield import CustomField, CustomFieldValue
from .apitoken import ApiToken
__all__ = [
# Base
'BaseModel',
'SoftDeleteMixin',
'AuditMixin',
# Asset (new architecture)
'Asset',
'AssetType',
'AssetStatus',
# Model-type lookup (referenced by models.modeltypeid)
'ModelType',
# Reference
'Vendor',
'Model',
'BusinessUnit',
'DashboardDefault',
'Location',
'LocationType',
'derive_locationcode',
'OperatingSystem',
# Relationships
'AssetRelationship',
'RelationshipType',
'RelationshipTypePropagation',
# Communication
'Communication',
'CommunicationType',
# Auth
'User',
'Role',
'Permission',
'full_permission_catalog',
# Applications
'Application',
'AppVersion',
# Support teams
'SupportTeam',
'SupportTeamContact',
# Knowledge Base
# Settings
'Setting',
# Audit
'AuditLog',
# Custom fields
'CustomField',
'CustomFieldValue',
# Personal API tokens
'ApiToken',
]

View File

@@ -0,0 +1,128 @@
"""Personal API token model.
A personal API token (PAT) lets a script or integration authenticate as a
user without the hourly-expiring login JWT. The secret is shown ONCE at
creation; only its sha256 hash is stored. By default the token acts as its
owning user, so the existing role/permission decorators authorize it unchanged.
A token MAY optionally carry a scopes list (see scopes/scopelist): a scoped
token grants only the listed permissions, intersected with what the owner holds,
and suspends the admin bypass. See shopdb/utils/authz.py for the enforcement.
"""
import hashlib
import json
import secrets
from datetime import datetime, timezone
from shopdb.extensions import db
from .base import BaseModel
def _utcnow():
# naive UTC to match the other DB DateTime columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
# Wire label on the full secret. Scripts send "Authorization: Bearer <secret>".
TOKEN_SECRET_PREFIX = 'shopdb_pat_'
# Hex chars of randomness after the label (secrets.token_hex(20) => 40 hex).
_TOKEN_RANDOM_BYTES = 20
# How many leading random-hex chars we keep in the clear for display/lookup.
_TOKEN_PREFIX_LEN = 8
class ApiToken(BaseModel):
"""Personal API token. Stores only the hash of the secret."""
__tablename__ = 'apitokens'
tokenid = db.Column(db.Integer, primary_key=True)
# The token acts as this user; NOT NULL so authz always has a principal.
userid = db.Column(db.Integer, db.ForeignKey('users.userid'),
nullable=False, index=True)
# What the token is for (e.g. "legacy import runner").
name = db.Column(db.String(100), nullable=False)
# First few random-hex chars, kept clear so a user can tell tokens apart.
tokenprefix = db.Column(db.String(16), nullable=True, index=True)
# sha256 hex of the full secret. Unique so a hash lookup finds one row.
tokenhash = db.Column(db.String(64), unique=True, nullable=False, index=True)
# Null expiresat means the token never expires.
expiresat = db.Column(db.DateTime, nullable=True)
# Last time the token authenticated a request (throttled write).
lastusedat = db.Column(db.DateTime, nullable=True)
# JSON array of permission-name strings. NULL = unscoped (full owner
# authority). A scoped token grants ONLY these, intersected with what the
# owner holds, and suspends the admin bypass. See scopelist below.
scopes = db.Column(db.Text, nullable=True)
user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic'))
@staticmethod
def generate_secret() -> str:
"""Return a fresh full secret: shopdb_pat_<40 hex>. Never stored."""
return TOKEN_SECRET_PREFIX + secrets.token_hex(_TOKEN_RANDOM_BYTES)
@staticmethod
def hash_secret(secret: str) -> str:
"""sha256 hex of the full secret. The token has 160 bits of entropy,
so a plain hash lookup (not a slow password hash) is appropriate."""
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
@staticmethod
def prefix_of(secret: str) -> str:
"""The clear display prefix (leading random-hex chars) of a secret."""
randompart = secret[len(TOKEN_SECRET_PREFIX):]
return randompart[:_TOKEN_PREFIX_LEN]
@property
def is_expired(self) -> bool:
"""True when expiresat is set and in the past."""
return self.expiresat is not None and self.expiresat < _utcnow()
@property
def scopelist(self):
"""Parsed scope names as a list, or None when the token is unscoped."""
if self.scopes is None:
return None
try:
value = json.loads(self.scopes)
except (ValueError, TypeError):
return None
return value if isinstance(value, list) else None
@scopelist.setter
def scopelist(self, names):
"""Store a scope list, or None to clear scoping. Does NOT validate the
names; callers reject bad input first via unknown_scope_names."""
if names is None:
self.scopes = None
else:
self.scopes = json.dumps(list(names))
@staticmethod
def unknown_scope_names(names) -> list:
"""Return the subset of names that are not in the permission catalog.
The catalog is core plus every ENABLED plugin's permissions, so a scope
naming a disabled plugin's permission is treated as unknown."""
from shopdb.core.models.user import full_permission_catalog
known = {name for name, _desc, _cat in full_permission_catalog()}
return [n for n in names if n not in known]
def to_dict(self, include_owner: bool = False) -> dict:
"""Serialize for the API. NEVER includes the hash or the secret."""
result = {
'tokenid': self.tokenid,
'userid': self.userid,
'name': self.name,
'tokenprefix': self.tokenprefix,
'displayprefix': f'{TOKEN_SECRET_PREFIX}{self.tokenprefix or ""}',
'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None,
'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None,
'scopes': self.scopelist,
'isactive': self.isactive,
'isexpired': self.is_expired,
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
}
if include_owner:
result['username'] = self.user.username if self.user else None
return result

View File

@@ -0,0 +1,95 @@
"""Application tracking models."""
from datetime import datetime, timezone
from shopdb.extensions import db
from .base import BaseModel
def _utcnow():
# naive UTC to match the other DB DateTime columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
# SupportTeam / SupportTeamContact live in supportteam.py; imported by the
# models package so the Application.supportteam relationship resolves.
class Application(BaseModel):
"""Application catalog."""
__tablename__ = 'applications'
appid = db.Column(db.Integer, primary_key=True)
appname = db.Column(db.String(100), unique=True, nullable=False)
appdescription = db.Column(db.String(255))
supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid'))
isinstallable = db.Column(db.Boolean, default=False)
applicationnotes = db.Column(db.Text)
installpath = db.Column(db.String(255))
applicationlink = db.Column(db.String(512))
documentationpath = db.Column(db.String(512))
ishidden = db.Column(db.Boolean, default=False)
isprinter = db.Column(db.Boolean, default=False)
islicenced = db.Column(db.Boolean, default=False)
isrequired = db.Column(
db.Boolean, default=False,
comment='Required on all PCs (drives the software-compliance report)'
)
image = db.Column(db.String(255))
# Relationships
supportteam = db.relationship('SupportTeam', back_populates='applications')
versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic')
def to_dict(self):
"""Serialize, flattening the support team + its active contacts.
Emits supportteamname, teamurl, and the team's active contacts so the
frontend needs a single call to render the Support card.
"""
result = super().to_dict()
team = self.supportteam
result['supportteamname'] = team.teamname if team else None
result['teamurl'] = team.teamurl if team else None
result['contacts'] = [
{'name': c.name, 'sso': c.sso}
for c in team.active_contacts()
] if team else []
return result
def __repr__(self):
return f"<Application {self.appname}>"
class AppVersion(db.Model):
"""Application version tracking."""
__tablename__ = 'appversions'
appversionid = db.Column(db.Integer, primary_key=True)
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False)
version = db.Column(db.String(50), nullable=False)
releasedate = db.Column(db.Date)
notes = db.Column(db.String(255))
dateadded = db.Column(db.DateTime, default=_utcnow)
isactive = db.Column(db.Boolean, default=True)
# Relationships
application = db.relationship('Application', back_populates='versions')
# Unique constraint on app + version
__table_args__ = (
db.UniqueConstraint('appid', 'version', name='uq_app_version'),
)
def to_dict(self):
"""Convert to dictionary."""
return {
'appversionid': self.appversionid,
'appid': self.appid,
'version': self.version,
'releasedate': self.releasedate.isoformat() if self.releasedate else None,
'notes': self.notes,
'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None,
'isactive': self.isactive
}
def __repr__(self):
return f"<AppVersion {self.application.appname if self.application else self.appid} v{self.version}>"

285
shopdb/core/models/asset.py Normal file
View File

@@ -0,0 +1,285 @@
"""Polymorphic Asset models - core of the new asset architecture."""
from shopdb.extensions import db
from .base import BaseModel, SoftDeleteMixin, AuditMixin
class AssetType(BaseModel):
"""
Registry of asset categories.
Each type maps to a plugin-owned extension table.
Examples: machine, computer, network_device, printer
"""
__tablename__ = 'assettypes'
assettypeid = db.Column(db.Integer, primary_key=True)
assettype = db.Column(
db.String(50),
unique=True,
nullable=False,
comment='Category name: machine, computer, network_device, printer'
)
pluginname = db.Column(
db.String(100),
nullable=True,
comment='Plugin that owns this type'
)
tablename = db.Column(
db.String(100),
nullable=True,
comment='Extension table name for this type'
)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<AssetType {self.assettype}>"
class AssetStatus(BaseModel):
"""Asset status options."""
__tablename__ = 'assetstatuses'
statusid = db.Column(db.Integer, primary_key=True)
status = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
color = db.Column(db.String(20), comment='CSS color for UI')
def __repr__(self):
return f"<AssetStatus {self.status}>"
class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
"""
Core asset model - minimal shared fields.
Category-specific data lives in plugin extension tables
(machines, computers, network_devices, printers).
The assetid matches original machineid for migration compatibility.
"""
__tablename__ = 'assets'
assetid = db.Column(db.Integer, primary_key=True)
# Identification
assetnumber = db.Column(
db.String(50),
unique=True,
nullable=False,
index=True,
comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'
)
name = db.Column(
db.String(100),
comment='Display name/alias'
)
gaugelabreference = db.Column(
db.String(50),
index=True,
comment='Gauge lab asset reference (authoritative tag the gauge lab '
'assigns to machines); distinct from assetnumber'
)
maintenancereference = db.Column(
db.String(50),
index=True,
comment='Maintenance system asset reference; distinct from assetnumber'
)
serialnumber = db.Column(
db.String(100),
index=True,
comment='Hardware serial number'
)
# Classification
assettypeid = db.Column(
db.Integer,
db.ForeignKey('assettypes.assettypeid'),
nullable=False
)
statusid = db.Column(
db.Integer,
db.ForeignKey('assetstatuses.statusid'),
default=1,
comment='In Use, Spare, Retired, etc.'
)
# Location and organization
locationid = db.Column(
db.Integer,
db.ForeignKey('locations.locationid'),
nullable=True
)
businessunitid = db.Column(
db.Integer,
db.ForeignKey('businessunits.businessunitid'),
nullable=True
)
# Floor map position (ADR-001: asset-specific override; nullable)
mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)')
mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)')
# Notes
notes = db.Column(db.Text, nullable=True)
# Relationships
assettype = db.relationship('AssetType', backref='assets')
status = db.relationship('AssetStatus', backref='assets')
location = db.relationship('Location', backref='assets')
businessunit = db.relationship('BusinessUnit', backref='assets')
# Communications (one-to-many) - will be migrated to use assetid
communications = db.relationship(
'Communication',
foreign_keys='Communication.assetid',
backref='asset',
cascade='all, delete-orphan',
lazy='dynamic'
)
# Indexes
__table_args__ = (
db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'),
db.Index('idx_asset_location', 'locationid'),
db.Index('idx_asset_active', 'isactive'),
db.Index('idx_asset_status', 'statusid'),
)
def __repr__(self):
return f"<Asset {self.assetnumber}>"
@property
def display_name(self):
"""Get display name (name if set, otherwise assetnumber)."""
return self.name or self.assetnumber
@property
def primary_ip(self):
"""Get primary IP address from communications."""
comm = self.communications.filter_by(
isprimary=True,
comtypeid=1 # IP type
).first()
if comm:
return comm.ipaddress
# Fall back to any IP
comm = self.communications.filter_by(comtypeid=1).first()
return comm.ipaddress if comm else None
def get_inherited_location(self):
"""
Get location data from a related asset if this asset has none.
Returns dict with locationid, location_name, mapx, mapy, and
inherited_from (assetnumber of source asset) if location was inherited.
Returns None if no location data available.
"""
if self.locationid is not None or (self.mapx is not None and self.mapy is not None):
return None
related_assets = []
if hasattr(self, 'incoming_relationships'):
for rel in self.incoming_relationships:
if rel.sourceasset and rel.isactive:
related_assets.append(rel.sourceasset)
if hasattr(self, 'outgoing_relationships'):
for rel in self.outgoing_relationships:
if rel.targetasset and rel.isactive:
related_assets.append(rel.targetasset)
for related in related_assets:
if related.locationid is not None or (related.mapx is not None and related.mapy is not None):
return {
'locationid': related.locationid,
'locationname': related.location.locationname if related.location else None,
'mapx': related.mapx,
'mapy': related.mapy,
'inheritedfrom': related.assetnumber
}
return None
def to_dict(self, include_type_data=False, include_inherited_location=True):
"""
Convert model to dictionary.
Args:
include_type_data: If True, include category-specific data from extension table
include_inherited_location: If True, include location from related assets when missing
"""
result = super().to_dict()
# Add related object names for convenience
if self.assettype:
result['assettypename'] = self.assettype.assettype
result['assettypecolor'] = getattr(self.assettype, 'color', None)
if self.status:
result['statusname'] = self.status.status
result['statuscolor'] = self.status.color
if self.location:
result['locationname'] = self.location.locationname
if self.businessunit:
result['businessunitname'] = self.businessunit.businessunit
# Add plugin-specific ID for navigation purposes
if hasattr(self, 'machine') and self.machine:
result['pluginid'] = self.machine.machineid
elif hasattr(self, 'computer') and self.computer:
result['pluginid'] = self.computer.computerid
elif hasattr(self, 'network_device') and self.network_device:
result['pluginid'] = self.network_device.networkdeviceid
elif hasattr(self, 'printer') and self.printer:
result['pluginid'] = self.printer.printerid
elif hasattr(self, 'measuringtool') and self.measuringtool:
result['pluginid'] = self.measuringtool.measuringtoolid
# Include inherited location if this asset has no location data
if include_inherited_location:
inherited = self.get_inherited_location()
if inherited:
result['inheritedlocation'] = inherited
# Also set the location fields if they're missing
if result.get('locationid') is None:
result['locationid'] = inherited['locationid']
result['locationname'] = inherited['locationname']
if result.get('mapx') is None:
result['mapx'] = inherited['mapx']
if result.get('mapy') is None:
result['mapy'] = inherited['mapy']
# Operation/short code of the resolved location (own or inherited).
# Derived from the location name's leading token; labels can encode a
# tool's inspection operation instead of the tool. None when unplaced.
from .location import derive_locationcode
result['locationcode'] = derive_locationcode(result.get('locationname'))
# Include extension data if requested
if include_type_data:
ext_data = self._get_extension_data()
if ext_data:
result['typedata'] = ext_data
return result
def _get_extension_data(self):
"""Get category-specific data from extension table."""
# Check for machine extension
if hasattr(self, 'machine') and self.machine:
return self.machine.to_dict()
# Check for computer extension
if hasattr(self, 'computer') and self.computer:
return self.computer.to_dict()
# Check for network_device extension
if hasattr(self, 'network_device') and self.network_device:
return self.network_device.to_dict()
# Check for printer extension
if hasattr(self, 'printer') and self.printer:
return self.printer.to_dict()
# Check for measuring-tool extension
if hasattr(self, 'measuringtool') and self.measuringtool:
return self.measuringtool.to_dict()
return None

View File

@@ -0,0 +1,160 @@
"""Audit log model for tracking changes."""
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):
"""
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=_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)

View File

@@ -0,0 +1,71 @@
"""Base model class with common fields."""
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.
All models should inherit from this.
"""
__abstract__ = True
createddate = db.Column(
db.DateTime,
default=_utcnow,
nullable=False
)
modifieddate = db.Column(
db.DateTime,
default=_utcnow,
onupdate=_utcnow,
nullable=False
)
isactive = db.Column(db.Boolean, default=True, nullable=False)
def to_dict(self):
"""Convert model to dictionary."""
result = {}
for c in self.__table__.columns:
value = getattr(self, c.name)
if isinstance(value, datetime):
value = value.isoformat() + 'Z'
result[c.name] = value
return result
def update(self, **kwargs):
"""Update model attributes."""
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
@classmethod
def get_active(cls):
"""Return query for active records only."""
return cls.query.filter_by(isactive=True)
class SoftDeleteMixin:
"""Mixin for soft delete functionality."""
deleteddate = db.Column(db.DateTime, nullable=True)
deletedby = db.Column(db.String(100), nullable=True)
def soft_delete(self, deleted_by: str = None):
"""Mark record as deleted."""
self.isactive = False
self.deleteddate = datetime.now(timezone.utc).replace(tzinfo=None)
self.deletedby = deleted_by
class AuditMixin:
"""Mixin for audit fields."""
createdby = db.Column(db.String(100), nullable=True)
modifiedby = db.Column(db.String(100), nullable=True)

View File

@@ -0,0 +1,31 @@
"""Business Unit model."""
from shopdb.extensions import db
from .base import BaseModel
class BusinessUnit(BaseModel):
"""Business unit / department model."""
__tablename__ = 'businessunits'
businessunitid = db.Column(db.Integer, primary_key=True)
businessunit = db.Column(db.String(100), unique=True, nullable=False)
code = db.Column(db.String(20), unique=True, comment='Short code')
description = db.Column(db.Text)
# Optional parent for hierarchy
parentid = db.Column(
db.Integer,
db.ForeignKey('businessunits.businessunitid'),
nullable=True
)
# Self-referential relationship for hierarchy
parent = db.relationship(
'BusinessUnit',
remote_side=[businessunitid],
backref='children'
)
def __repr__(self):
return f"<BusinessUnit {self.businessunit}>"

View File

@@ -0,0 +1,93 @@
"""Communication/network interface models."""
from shopdb.extensions import db
from .base import BaseModel
class CommunicationType(BaseModel):
"""Types of communication interfaces."""
__tablename__ = 'communicationtypes'
comtypeid = db.Column(db.Integer, primary_key=True)
comtype = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
# Types: IP, Serial, USB, VNC, FTP, DNC, Parallel, Network Interface
def __repr__(self):
return f"<CommunicationType {self.comtype}>"
class Communication(BaseModel):
"""
Communication interface for an asset (or legacy machine).
Stores network config, serial settings, etc.
"""
__tablename__ = 'communications'
communicationid = db.Column(db.Integer, primary_key=True)
# New asset-based FK (preferred)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid'),
nullable=True,
comment='FK to assets table (new architecture)'
)
comtypeid = db.Column(
db.Integer,
db.ForeignKey('communicationtypes.comtypeid'),
nullable=False
)
# Network configuration (for IP type)
ipaddress = db.Column(db.String(50))
subnetmask = db.Column(db.String(50))
gateway = db.Column(db.String(50))
dns1 = db.Column(db.String(50))
dns2 = db.Column(db.String(50))
macaddress = db.Column(db.String(50))
isdhcp = db.Column(db.Boolean, default=False)
# Serial configuration (for Serial type)
comport = db.Column(db.String(20))
baudrate = db.Column(db.Integer)
databits = db.Column(db.Integer)
stopbits = db.Column(db.String(10))
parity = db.Column(db.String(20))
flowcontrol = db.Column(db.String(20))
# VNC/FTP configuration
port = db.Column(db.Integer)
username = db.Column(db.String(100))
# Note: passwords should not be stored here - use secure vault
# DNC configuration
pathname = db.Column(db.String(255))
pathname2 = db.Column(db.String(255), comment='Secondary path for dualpath')
# Flags
isprimary = db.Column(
db.Boolean,
default=False,
comment='Primary communication method'
)
ismachinenetwork = db.Column(
db.Boolean,
default=False,
comment='On machine network vs office network'
)
notes = db.Column(db.Text)
# Relationships
comtype = db.relationship('CommunicationType', backref='communications')
__table_args__ = (
db.Index('idx_comm_asset', 'assetid'),
db.Index('idx_comm_ip', 'ipaddress'),
)
def __repr__(self):
return f"<Communication {self.assetid}:{self.comtype.comtype if self.comtype else 'Unknown'}>"

View File

@@ -0,0 +1,94 @@
"""Custom fields: site-defined extra attributes per asset type.
A CustomField is a definition scoped to one asset type (machine, computer,
printer, network_device). A CustomFieldValue holds one asset's value for one
field. This is the generic form of the built-in identifier columns - sites add
their own attributes without a schema change.
"""
from shopdb.extensions import db
# Allowed datatypes for a custom field. Values are always stored as text and
# cast on render/input by the frontend.
CUSTOM_FIELD_DATATYPES = ('text', 'number', 'date', 'boolean', 'select')
class CustomField(db.Model):
__tablename__ = 'customfields'
fieldid = db.Column(db.Integer, primary_key=True)
assettypeid = db.Column(
db.Integer,
db.ForeignKey('assettypes.assettypeid'),
nullable=False,
comment='Which asset category this field applies to'
)
# Machine-name key, unique per asset type. Used for stable references.
fieldkey = db.Column(db.String(50), nullable=False)
label = db.Column(db.String(150), nullable=False)
datatype = db.Column(db.String(20), nullable=False, server_default='text')
# JSON array of options, only meaningful when datatype='select'
options = db.Column(db.Text)
showondetail = db.Column(db.Boolean, nullable=False, server_default='1')
showonform = db.Column(db.Boolean, nullable=False, server_default='1')
sortorder = db.Column(db.Integer, nullable=False, server_default='0')
isactive = db.Column(db.Boolean, nullable=False, server_default='1')
# When true, this field's values are matched by global search.
searchable = db.Column(db.Boolean, nullable=False, server_default='0')
__table_args__ = (
db.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'),
)
def to_dict(self):
import json
parsed_options = []
if self.options:
try:
parsed_options = json.loads(self.options)
except (ValueError, TypeError):
parsed_options = []
return {
'fieldid': self.fieldid,
'assettypeid': self.assettypeid,
'fieldkey': self.fieldkey,
'label': self.label,
'datatype': self.datatype,
'options': parsed_options,
'showondetail': bool(self.showondetail),
'showonform': bool(self.showonform),
'sortorder': self.sortorder,
'isactive': bool(self.isactive),
'searchable': bool(self.searchable),
}
class CustomFieldValue(db.Model):
__tablename__ = 'customfieldvalues'
valueid = db.Column(db.Integer, primary_key=True)
fieldid = db.Column(
db.Integer,
db.ForeignKey('customfields.fieldid', ondelete='CASCADE'),
nullable=False
)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False
)
value = db.Column(db.Text)
field = db.relationship('CustomField')
__table_args__ = (
db.UniqueConstraint('fieldid', 'assetid', name='uq_customfieldvalue_field_asset'),
)
def to_dict(self):
return {
'valueid': self.valueid,
'fieldid': self.fieldid,
'assetid': self.assetid,
'value': self.value,
}

View File

@@ -0,0 +1,28 @@
"""Dashboard default model: visitor-IP -> business-unit mapping.
A shopfloor / lobby kiosk display resolves which business unit to show by the
display PC's IP address. Powers the visitor-location lookup the shopfloor and
TV dashboards call when no explicit business unit is given.
"""
from shopdb.extensions import db
from .base import BaseModel
class DashboardDefault(BaseModel):
"""Maps a display PC IP address to the business unit it should show."""
__tablename__ = 'dashboarddefaults'
dashboarddefaultid = db.Column(db.Integer, primary_key=True)
ipaddress = db.Column(db.String(50), unique=True, nullable=False)
businessunitid = db.Column(
db.Integer,
db.ForeignKey('businessunits.businessunitid'),
nullable=False
)
description = db.Column(db.String(255))
businessunit = db.relationship('BusinessUnit')
def __repr__(self):
return f"<DashboardDefault {self.ipaddress} -> {self.businessunitid}>"

View File

@@ -0,0 +1,91 @@
"""Location + LocationType models."""
from shopdb.extensions import db
from .base import BaseModel
def derive_locationcode(locationname):
"""Operation/short code for a location = leading token of its name.
Locations have no dedicated code column, so the operation code is the
leading whitespace-delimited token of the location name. Example:
'0615 Blisk Inspection' -> '0615'. Blank/None name -> None. Used by
printed labels that encode a tool's inspection operation rather than the
tool itself.
"""
if not locationname:
return None
parts = str(locationname).strip().split()
return parts[0] if parts else None
class LocationType(BaseModel):
"""Location classification (ADR-001 shared reference data).
Seeded values: section, cell, subcell, operation, meetingroom, lab,
office, storage, hallway, networkcloset, building. Sites can extend.
"""
__tablename__ = 'locationtypes'
locationtypeid = db.Column(db.Integer, primary_key=True)
locationtype = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
color = db.Column(db.String(20), comment='CSS color for UI')
def __repr__(self):
return f"<LocationType {self.locationtype}>"
class Location(BaseModel):
"""Physical location model."""
__tablename__ = 'locations'
locationid = db.Column(db.Integer, primary_key=True)
locationname = db.Column(db.String(100), unique=True, nullable=False)
building = db.Column(db.String(100))
floor = db.Column(db.String(50))
room = db.Column(db.String(50))
description = db.Column(db.Text)
# Classification + tree (ADR-001)
locationtypeid = db.Column(
db.Integer,
db.ForeignKey('locationtypes.locationtypeid'),
nullable=True
)
parentlocationid = db.Column(
db.Integer,
db.ForeignKey('locations.locationid'),
nullable=True,
comment='Parent location for the site location tree'
)
# Map configuration
mapimage = db.Column(db.String(500), comment='Path to floor map image')
mapwidth = db.Column(db.Integer)
mapheight = db.Column(db.Integer)
# Location-level position used as fallback when an asset has no own coords
# and no inheritsposition relationship resolves. ADR-001 position resolution
# chain priority 3.
mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location')
mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location')
# Relationships
locationtype = db.relationship('LocationType')
parent = db.relationship('Location', remote_side=[locationid])
@property
def locationcode(self):
"""Derived operation/short code (leading token of the name)."""
return derive_locationcode(self.locationname)
def to_dict(self):
data = super().to_dict()
data['locationtypename'] = self.locationtype.locationtype if self.locationtype else None
data['parentlocationname'] = self.parent.locationname if self.parent else None
data['locationcode'] = self.locationcode
return data
def __repr__(self):
return f"<Location {self.locationname}>"

View File

@@ -0,0 +1,43 @@
"""Model (vendor catalog model number) model."""
from shopdb.extensions import db
from .base import BaseModel
class Model(BaseModel):
"""Vendor catalog model information (machines, PCs, printers, network)."""
__tablename__ = 'models'
modelnumberid = db.Column(db.Integer, primary_key=True)
modelnumber = db.Column(db.String(100), nullable=False)
# Link to model type (what kind of thing this catalog model is for)
modeltypeid = db.Column(
db.Integer,
db.ForeignKey('modeltypes.modeltypeid'),
nullable=True
)
# Link to vendor/manufacturer
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
description = db.Column(db.Text)
imageurl = db.Column(db.String(500), comment='URL to product image')
documentationurl = db.Column(db.String(500), comment='URL to documentation')
notes = db.Column(db.Text)
# Relationships
modeltype = db.relationship('ModelType', backref='models')
vendor = db.relationship('Vendor', backref='models')
# Unique constraint on modelnumber + vendor
__table_args__ = (
db.UniqueConstraint('modelnumber', 'vendorid', name='uq_model_vendor'),
)
def __repr__(self):
return f"<Model {self.modelnumber}>"

View File

@@ -0,0 +1,33 @@
"""Model-type lookup (types the vendor MODELS catalog).
The Machine instance model and its PC/status lookups were retired (ADR-001);
assets are the platform contract. ModelType is kept because the shared `models`
table references it via models.modeltypeid: it types vendor models (Lathe,
Switch, Laser Printer), not asset instances. Renamed from MachineType to be
role-accurate and to free the "machinetype" name for the machines plugin.
"""
from shopdb.extensions import db
from .base import BaseModel
class ModelType(BaseModel):
"""
Model-type classification (what kind of thing a catalog model is for).
Categories: Equipment, PC, Network, Printer
"""
__tablename__ = 'modeltypes'
modeltypeid = db.Column(db.Integer, primary_key=True)
modeltype = db.Column(db.String(100), unique=True, nullable=False)
category = db.Column(
db.String(50),
nullable=False,
default='Equipment',
comment='Equipment, PC, Network, or Printer'
)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<ModelType {self.modeltype}>"

View File

@@ -0,0 +1,22 @@
"""Operating System model."""
from shopdb.extensions import db
from .base import BaseModel
class OperatingSystem(BaseModel):
"""Operating system model."""
__tablename__ = 'operatingsystems'
osid = db.Column(db.Integer, primary_key=True)
osname = db.Column(db.String(100), nullable=False)
osversion = db.Column(db.String(50))
architecture = db.Column(db.String(20), comment='x86, x64, ARM')
endoflife = db.Column(db.Date, comment='End of support date')
__table_args__ = (
db.UniqueConstraint('osname', 'osversion', name='uq_os_name_version'),
)
def __repr__(self):
return f"<OperatingSystem {self.osname} {self.osversion}>"

View File

@@ -0,0 +1,166 @@
"""Machine and Asset relationship models."""
from shopdb.extensions import db
from .base import BaseModel
class RelationshipType(BaseModel):
"""
Types of relationships between assets.
ADR-001 seeds three canonical types: partof, controls, connectedto.
Sites may add legacy/communication-flavored types (Serial Cable, Direct
Ethernet, USB, WiFi, Dualpath) for backward compatibility with pre-1.0
data, but new ADR-001 code paths only reason about the three canonical
types via free-text label for nuance.
"""
__tablename__ = 'relationshiptypes'
relationshiptypeid = db.Column(db.Integer, primary_key=True)
relationshiptype = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
color = db.Column(db.String(20), comment='CSS color for relationship badges')
# True: edge has a source->target meaning (controls, partof, Backup For).
# False: symmetric link (Dualpath, connectedto, USB...) shown on the card
# once per peer with no direction, both stored direction rows collapsed.
isdirectional = db.Column(
db.Boolean,
default=True,
nullable=False,
server_default='1',
comment='False = symmetric connection, shown direction-blind'
)
# Sibling propagation (ADR-001), WIRED. When a relationship of this type is
# created, the create path fans it out across every through-type in this
# type's propagation set (relationshiptypepropagations rows). For each
# SYMMETRIC through-type P, the source's edge to the target is mirrored to
# every asset linked to the target via P. This is M:N so one type can
# propagate through several rails at once. Seeded rows on this DB:
# controls -> partof (declared; directional rail, NOT consumed yet -
# parent/child fan-out direction is ambiguous)
# controls -> Dualpath (consumed; a dual-bay pair is one physical machine
# with one controller, so both bays carry controls)
# A dual-bay Dualpath pair thus gets the PC's controls link on both bays.
# propagatesthrough is the list of through-type RelationshipType rows.
propagatesthrough = db.relationship(
'RelationshipType',
secondary='relationshiptypepropagations',
primaryjoin='RelationshipType.relationshiptypeid'
' == RelationshipTypePropagation.relationshiptypeid',
secondaryjoin='RelationshipType.relationshiptypeid'
' == RelationshipTypePropagation.throughtypeid',
viewonly=True,
)
def __repr__(self):
return f"<RelationshipType {self.relationshiptype}>"
class RelationshipTypePropagation(db.Model):
"""
M:N propagation rails (ADR-001). One row means: a relationship of type
relationshiptypeid propagates through the connections of type throughtypeid.
Replaces the old single-valued relationshiptypes.propagatesthroughid so a
type (controls) can propagate through several through-types (partof AND
Dualpath) at once. Seed/CLI-managed; no management UI.
"""
__tablename__ = 'relationshiptypepropagations'
propagationid = db.Column(db.Integer, primary_key=True)
relationshiptypeid = db.Column(
db.Integer,
db.ForeignKey('relationshiptypes.relationshiptypeid'),
nullable=False
)
throughtypeid = db.Column(
db.Integer,
db.ForeignKey('relationshiptypes.relationshiptypeid'),
nullable=False
)
__table_args__ = (
db.UniqueConstraint(
'relationshiptypeid',
'throughtypeid',
name='uq_reltype_propagation'
),
)
def __repr__(self):
return f"<RelationshipTypePropagation {self.relationshiptypeid} through {self.throughtypeid}>"
class AssetRelationship(BaseModel):
"""
Relationships between assets.
Examples:
- Computer controls Machine
- Two machines are dualpath partners
- Network device connects to machine
"""
__tablename__ = 'assetrelationships'
relationshipid = db.Column(db.Integer, primary_key=True)
sourceassetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid'),
nullable=False
)
targetassetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid'),
nullable=False
)
relationshiptypeid = db.Column(
db.Integer,
db.ForeignKey('relationshiptypes.relationshiptypeid'),
nullable=False
)
# Free-text description carrying domain nuance ("DNC feed",
# "operator workstation", "ethernet PoE"). Avoids inflating type list.
label = db.Column(db.String(200), comment='Free-text relationship description (ADR-001)')
# When true, resolve_asset_position walks across this edge (priority 2
# in the resolution chain). Defaults to true for partof + controls when
# the relationship is created via the API; nullable for legacy rows.
inheritsposition = db.Column(
db.Boolean,
default=True,
nullable=False,
server_default='1',
comment='If true, resolved-position walk follows this edge (ADR-001)'
)
notes = db.Column(db.Text)
sourceasset = db.relationship(
'Asset',
foreign_keys=[sourceassetid],
backref='outgoing_relationships'
)
targetasset = db.relationship(
'Asset',
foreign_keys=[targetassetid],
backref='incoming_relationships'
)
relationshiptype = db.relationship('RelationshipType', backref='asset_relationships')
__table_args__ = (
db.UniqueConstraint(
'sourceassetid',
'targetassetid',
'relationshiptypeid',
name='uq_asset_relationship'
),
db.Index('idx_asset_rel_source', 'sourceassetid'),
db.Index('idx_asset_rel_target', 'targetassetid'),
)
def __repr__(self):
return f"<AssetRelationship {self.sourceassetid} -> {self.targetassetid}>"

View File

@@ -0,0 +1,102 @@
"""System settings model for key-value configuration storage."""
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.
Settings can be managed via the admin UI and are cached
for performance.
"""
__tablename__ = 'settings'
settingid = db.Column(db.Integer, primary_key=True, autoincrement=True)
key = db.Column(db.String(100), unique=True, nullable=False, index=True)
value = db.Column(db.Text, nullable=True)
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=_utcnow)
modifieddate = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow)
def to_dict(self):
return {
'settingid': self.settingid,
'key': self.key,
'value': self.get_typed_value(),
'valuetype': self.valuetype,
'category': self.category,
'description': self.description,
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None,
}
def get_typed_value(self):
"""Return value converted to its proper type."""
if self.value is None:
return None
if self.valuetype == 'boolean':
return self.value.lower() in ('true', '1', 'yes')
if self.valuetype == 'integer':
try:
return int(self.value)
except (ValueError, TypeError):
return 0
return self.value
@classmethod
def get(cls, key: str, default=None):
"""Get a setting value by key."""
setting = cls.query.filter_by(key=key).first()
if setting:
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.
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 setting:
setting.value = cls._stringify(value)
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

View File

@@ -0,0 +1,61 @@
"""Application support teams and their contacts.
A support team is who you contact about an application; each team carries an
optional ServiceNow group deep link (teamurl) and a list of named contacts
(the people you actually reach out to, legacy called them "app owners").
Applications point at one team via applications.supportteamid.
"""
from shopdb.extensions import db
from .base import BaseModel
class SupportTeam(BaseModel):
"""A support team an application belongs to."""
__tablename__ = 'supportteams'
supportteamid = db.Column(db.Integer, primary_key=True)
teamname = db.Column(db.String(100), unique=True, nullable=False)
teamurl = db.Column(db.Text) # ServiceNow group deep link, nullable
# Contacts cascade-delete with the team.
contacts = db.relationship(
'SupportTeamContact', back_populates='team',
cascade='all, delete-orphan', lazy='select')
applications = db.relationship(
'Application', back_populates='supportteam', lazy='dynamic')
def active_contacts(self):
"""Return active contacts in sortorder (then contactid) order."""
return sorted(
(c for c in self.contacts if c.isactive),
key=lambda c: (c.sortorder, c.contactid or 0))
def to_dict(self, with_contacts=True):
"""Serialize the team, nesting its active contacts by default."""
result = super().to_dict()
if with_contacts:
result['contacts'] = [c.to_dict() for c in self.active_contacts()]
return result
def __repr__(self):
return f"<SupportTeam {self.teamname}>"
class SupportTeamContact(BaseModel):
"""A person to contact for a support team."""
__tablename__ = 'supportteamcontacts'
contactid = db.Column(db.Integer, primary_key=True)
supportteamid = db.Column(
db.Integer,
db.ForeignKey('supportteams.supportteamid', ondelete='CASCADE'),
nullable=False)
name = db.Column(db.String(100), nullable=False)
sso = db.Column(db.String(50))
sortorder = db.Column(db.Integer, nullable=False, default=0)
team = db.relationship('SupportTeam', back_populates='contacts')
def __repr__(self):
return f"<SupportTeamContact {self.name}>"

241
shopdb/core/models/user.py Normal file
View File

@@ -0,0 +1,241 @@
"""User and authentication models."""
from datetime import datetime, timezone
from shopdb.extensions import db
from .base import BaseModel
# Association table for user roles (many-to-many)
userroles = db.Table(
'userroles',
db.Column('userid', db.Integer, db.ForeignKey('users.userid'), primary_key=True),
db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True)
)
# Association table for role permissions (many-to-many)
rolepermissions = db.Table(
'rolepermissions',
db.Column('roleid', db.Integer, db.ForeignKey('roles.roleid'), primary_key=True),
db.Column('permissionid', db.Integer, db.ForeignKey('permissions.permissionid'), primary_key=True)
)
class Permission(db.Model):
"""
Permission model for granular access control.
Permissions are predefined and assigned to roles.
"""
__tablename__ = 'permissions'
permissionid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.String(255))
category = db.Column(db.String(50), default='general') # For grouping in UI
# Core permission catalog. This is CORE ONLY - permissions for a plugin
# domain (machines, computers, printers, network, knowledgebase,
# notifications, usb, warranty, measuringtools) live in that plugin's
# BasePlugin.get_permissions() hook, not here (plugin-is-the-product). The
# merged core+plugins view is full_permission_catalog() below.
CORE_PERMISSIONS = [
# Assets
('assets.view', 'View assets', 'assets'),
('assets.create', 'Create assets', 'assets'),
('assets.edit', 'Edit assets', 'assets'),
('assets.delete', 'Delete assets', 'assets'),
# Applications
('applications.view', 'View applications', 'applications'),
('applications.create', 'Create applications', 'applications'),
('applications.edit', 'Edit applications', 'applications'),
('applications.delete', 'Delete applications', 'applications'),
# Reports
('reports.view', 'View reports', 'reports'),
('reports.export', 'Export reports', 'reports'),
# Settings
('settings.view', 'View settings', 'admin'),
('settings.edit', 'Edit settings', 'admin'),
# Users
('users.view', 'View users', 'admin'),
('users.create', 'Create users', 'admin'),
('users.edit', 'Edit users', 'admin'),
('users.delete', 'Delete users', 'admin'),
# Audit
('audit.view', 'View audit logs', 'admin'),
# API tokens
('apitokens.create', 'Create and manage API tokens', 'apitokens'),
# Collector service tokens. A token scoped to ONLY this permission is a
# collector service token: it authorizes the collector ingest API and
# nothing else (scoped tokens pass require_permission only for listed
# perms, and this perm gates no other route). See collector.py.
('collector.ingest', 'Submit collector payloads (fleet reporting)',
'collector'),
]
def __repr__(self):
return f"<Permission {self.name}>"
@classmethod
def seed_entries(cls, entries):
"""Idempotently create Permission rows from (name, desc, category)
tuples (or equivalent dicts). Returns how many were created."""
created = 0
for name, description, category in normalize_permission_entries(entries):
if not cls.query.filter_by(name=name).first():
perm = cls(name=name, description=description, category=category)
db.session.add(perm)
created += 1
return created
@classmethod
def seed(cls):
"""Seed the full permission catalog (core plus enabled plugins)."""
return cls.seed_entries(full_permission_catalog())
def normalize_permission_entries(entries):
"""Coerce catalog entries to (name, description, category) tuples.
Accepts tuples/lists in that order, or dicts with those keys, so a plugin
may return either shape from get_permissions().
"""
normalized = []
for entry in entries or []:
if isinstance(entry, dict):
normalized.append((
entry['name'],
entry.get('description', ''),
entry.get('category', 'general'),
))
else:
name, description, category = entry
normalized.append((name, description, category))
return normalized
def full_permission_catalog(include_disabled=False):
"""The full permission catalog: core permissions plus every LOADED plugin's
get_permissions().
By default only ENABLED plugins contribute, so a disabled plugin's
permissions are not offered for new scope grants or role edits (existing
Permission rows and role links persist - see the get_permissions docstring).
Pass include_disabled=True to include all installed plugins.
Follows the dashboard.py consumer pattern: a broken plugin is re-raised in
dev/test and isolated (logged) in prod. Returns (name, description,
category) tuples.
"""
from flask import current_app, has_app_context
catalog = list(Permission.CORE_PERMISSIONS)
if not has_app_context():
return catalog
pm = current_app.extensions.get('plugin_manager')
if not pm:
return catalog
for name, plugin in pm.get_all_plugins().items():
if not include_disabled and not pm.registry.is_enabled(name):
continue
try:
catalog.extend(
normalize_permission_entries(plugin.get_permissions()))
except Exception:
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception(
'Plugin %s get_permissions failed', name)
return catalog
class Role(BaseModel):
"""User role model."""
__tablename__ = 'roles'
roleid = db.Column(db.Integer, primary_key=True)
rolename = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
# Permissions relationship
permissions = db.relationship(
'Permission',
secondary=rolepermissions,
backref=db.backref('roles', lazy='dynamic')
)
def __repr__(self):
return f"<Role {self.rolename}>"
def haspermission(self, permission_name: str) -> bool:
"""Check if role has a specific permission."""
# Admin role has all permissions
if self.rolename == 'admin':
return True
return any(p.name == permission_name for p in self.permissions)
def getpermissionnames(self) -> list:
"""Get list of permission names."""
if self.rolename == 'admin':
return [p[0] for p in full_permission_catalog()]
return [p.name for p in self.permissions]
class User(BaseModel):
"""User model for authentication."""
__tablename__ = 'users'
userid = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
email = db.Column(db.String(255), unique=True, nullable=False)
passwordhash = db.Column(db.String(255), nullable=False)
# Profile
firstname = db.Column(db.String(100))
lastname = db.Column(db.String(100))
# Status
lastlogindate = db.Column(db.DateTime)
failedlogins = db.Column(db.Integer, default=0)
lockeduntil = db.Column(db.DateTime)
# Forced password change: set true when an admin creates the account, so the
# user is steered through a password change before landing in the app.
mustchangepassword = db.Column(db.Boolean, default=False, nullable=False)
# Relationships
roles = db.relationship(
'Role',
secondary=userroles,
backref=db.backref('users', lazy='dynamic')
)
def __repr__(self):
return f"<User {self.username}>"
@property
def islocked(self):
"""Check if account is locked."""
if self.lockeduntil:
return datetime.now(timezone.utc).replace(tzinfo=None) < self.lockeduntil
return False
def hasrole(self, rolename: str) -> bool:
"""Check if user has a specific role."""
return any(r.rolename == rolename for r in self.roles)
def haspermission(self, permission_name: str) -> bool:
"""Check if user has a specific permission through any role."""
# Admin role has all permissions
if self.hasrole('admin'):
return True
return any(r.haspermission(permission_name) for r in self.roles)
def getpermissions(self) -> list:
"""Get list of all permission names from all roles."""
if self.hasrole('admin'):
return [p[0] for p in full_permission_catalog()]
perms = set()
for role in self.roles:
perms.update(role.getpermissionnames())
return list(perms)

View File

@@ -0,0 +1,20 @@
"""Vendor model."""
from shopdb.extensions import db
from .base import BaseModel
class Vendor(BaseModel):
"""Vendor/Manufacturer model."""
__tablename__ = 'vendors'
vendorid = db.Column(db.Integer, primary_key=True)
vendor = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
website = db.Column(db.String(255))
supportphone = db.Column(db.String(50))
supportemail = db.Column(db.String(100))
notes = db.Column(db.Text)
def __repr__(self):
return f"<Vendor {self.vendor}>"