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:
70
shopdb/core/models/__init__.py
Normal file
70
shopdb/core/models/__init__.py
Normal 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',
|
||||
]
|
||||
128
shopdb/core/models/apitoken.py
Normal file
128
shopdb/core/models/apitoken.py
Normal 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
|
||||
95
shopdb/core/models/application.py
Normal file
95
shopdb/core/models/application.py
Normal 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
285
shopdb/core/models/asset.py
Normal 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
|
||||
160
shopdb/core/models/auditlog.py
Normal file
160
shopdb/core/models/auditlog.py
Normal 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)
|
||||
71
shopdb/core/models/base.py
Normal file
71
shopdb/core/models/base.py
Normal 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)
|
||||
31
shopdb/core/models/businessunit.py
Normal file
31
shopdb/core/models/businessunit.py
Normal 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}>"
|
||||
93
shopdb/core/models/communication.py
Normal file
93
shopdb/core/models/communication.py
Normal 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'}>"
|
||||
94
shopdb/core/models/customfield.py
Normal file
94
shopdb/core/models/customfield.py
Normal 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,
|
||||
}
|
||||
28
shopdb/core/models/dashboarddefault.py
Normal file
28
shopdb/core/models/dashboarddefault.py
Normal 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}>"
|
||||
91
shopdb/core/models/location.py
Normal file
91
shopdb/core/models/location.py
Normal 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}>"
|
||||
43
shopdb/core/models/model.py
Normal file
43
shopdb/core/models/model.py
Normal 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}>"
|
||||
33
shopdb/core/models/modeltype.py
Normal file
33
shopdb/core/models/modeltype.py
Normal 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}>"
|
||||
22
shopdb/core/models/operatingsystem.py
Normal file
22
shopdb/core/models/operatingsystem.py
Normal 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}>"
|
||||
166
shopdb/core/models/relationship.py
Normal file
166
shopdb/core/models/relationship.py
Normal 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}>"
|
||||
102
shopdb/core/models/setting.py
Normal file
102
shopdb/core/models/setting.py
Normal 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
|
||||
61
shopdb/core/models/supportteam.py
Normal file
61
shopdb/core/models/supportteam.py
Normal 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
241
shopdb/core/models/user.py
Normal 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)
|
||||
20
shopdb/core/models/vendor.py
Normal file
20
shopdb/core/models/vendor.py
Normal 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}>"
|
||||
Reference in New Issue
Block a user