Initial commit: Shop Database Flask Application
Flask backend with Vue 3 frontend for shop floor machine management. Includes database schema export for MySQL shopdb_flask database. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
shopdb/core/__init__.py
Normal file
1
shopdb/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core module - always loaded."""
|
||||
33
shopdb/core/api/__init__.py
Normal file
33
shopdb/core/api/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Core API blueprints."""
|
||||
|
||||
from .auth import auth_bp
|
||||
from .machines import machines_bp
|
||||
from .machinetypes import machinetypes_bp
|
||||
from .pctypes import pctypes_bp
|
||||
from .statuses import statuses_bp
|
||||
from .vendors import vendors_bp
|
||||
from .models import models_bp
|
||||
from .businessunits import businessunits_bp
|
||||
from .locations import locations_bp
|
||||
from .operatingsystems import operatingsystems_bp
|
||||
from .dashboard import dashboard_bp
|
||||
from .applications import applications_bp
|
||||
from .knowledgebase import knowledgebase_bp
|
||||
from .search import search_bp
|
||||
|
||||
__all__ = [
|
||||
'auth_bp',
|
||||
'machines_bp',
|
||||
'machinetypes_bp',
|
||||
'pctypes_bp',
|
||||
'statuses_bp',
|
||||
'vendors_bp',
|
||||
'models_bp',
|
||||
'businessunits_bp',
|
||||
'locations_bp',
|
||||
'operatingsystems_bp',
|
||||
'dashboard_bp',
|
||||
'applications_bp',
|
||||
'knowledgebase_bp',
|
||||
'search_bp',
|
||||
]
|
||||
429
shopdb/core/api/applications.py
Normal file
429
shopdb/core/api/applications.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""Applications API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import (
|
||||
Application, AppVersion, AppOwner, SupportTeam, InstalledApp, Machine
|
||||
)
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
applications_bp = Blueprint('applications', __name__)
|
||||
|
||||
|
||||
@applications_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_applications():
|
||||
"""List all applications."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = Application.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(Application.isactive == True)
|
||||
|
||||
# Filter out hidden unless specifically requested
|
||||
if request.args.get('showhidden', 'false').lower() != 'true':
|
||||
query = query.filter(Application.ishidden == False)
|
||||
|
||||
# Filter by installable
|
||||
if request.args.get('installable') is not None:
|
||||
installable = request.args.get('installable').lower() == 'true'
|
||||
query = query.filter(Application.isinstallable == installable)
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Application.appname.ilike(f'%{search}%'),
|
||||
Application.appdescription.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(Application.appname)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = []
|
||||
for app in items:
|
||||
app_dict = app.to_dict()
|
||||
if app.supportteam:
|
||||
app_dict['supportteam'] = {
|
||||
'supportteamid': app.supportteam.supportteamid,
|
||||
'teamname': app.supportteam.teamname,
|
||||
'teamurl': app.supportteam.teamurl,
|
||||
'owner': {
|
||||
'appownerid': app.supportteam.owner.appownerid,
|
||||
'appowner': app.supportteam.owner.appowner,
|
||||
'sso': app.supportteam.owner.sso
|
||||
} if app.supportteam.owner else None
|
||||
}
|
||||
else:
|
||||
app_dict['supportteam'] = None
|
||||
app_dict['installedcount'] = app.installed_on.filter_by(isactive=True).count()
|
||||
data.append(app_dict)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@applications_bp.route('/<int:app_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_application(app_id: int):
|
||||
"""Get a single application with details."""
|
||||
app = Application.query.get(app_id)
|
||||
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
data = app.to_dict()
|
||||
if app.supportteam:
|
||||
data['supportteam'] = {
|
||||
'supportteamid': app.supportteam.supportteamid,
|
||||
'teamname': app.supportteam.teamname,
|
||||
'teamurl': app.supportteam.teamurl,
|
||||
'owner': {
|
||||
'appownerid': app.supportteam.owner.appownerid,
|
||||
'appowner': app.supportteam.owner.appowner,
|
||||
'sso': app.supportteam.owner.sso
|
||||
} if app.supportteam.owner else None
|
||||
}
|
||||
else:
|
||||
data['supportteam'] = None
|
||||
data['versions'] = [v.to_dict() for v in app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()]
|
||||
data['installedcount'] = app.installed_on.filter_by(isactive=True).count()
|
||||
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@applications_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_application():
|
||||
"""Create a new application."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('appname'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'appname is required')
|
||||
|
||||
if Application.query.filter_by(appname=data['appname']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Application '{data['appname']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
app = Application(
|
||||
appname=data['appname'],
|
||||
appdescription=data.get('appdescription'),
|
||||
supportteamid=data.get('supportteamid'),
|
||||
isinstallable=data.get('isinstallable', False),
|
||||
applicationnotes=data.get('applicationnotes'),
|
||||
installpath=data.get('installpath'),
|
||||
applicationlink=data.get('applicationlink'),
|
||||
documentationpath=data.get('documentationpath'),
|
||||
ishidden=data.get('ishidden', False),
|
||||
isprinter=data.get('isprinter', False),
|
||||
islicenced=data.get('islicenced', False),
|
||||
image=data.get('image')
|
||||
)
|
||||
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(app.to_dict(), message='Application created', http_code=201)
|
||||
|
||||
|
||||
@applications_bp.route('/<int:app_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_application(app_id: int):
|
||||
"""Update an application."""
|
||||
app = Application.query.get(app_id)
|
||||
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
if 'appname' in data and data['appname'] != app.appname:
|
||||
if Application.query.filter_by(appname=data['appname']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Application '{data['appname']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
fields = [
|
||||
'appname', 'appdescription', 'supportteamid', 'isinstallable',
|
||||
'applicationnotes', 'installpath', 'applicationlink', 'documentationpath',
|
||||
'ishidden', 'isprinter', 'islicenced', 'image', 'isactive'
|
||||
]
|
||||
for key in fields:
|
||||
if key in data:
|
||||
setattr(app, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(app.to_dict(), message='Application updated')
|
||||
|
||||
|
||||
@applications_bp.route('/<int:app_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_application(app_id: int):
|
||||
"""Delete (deactivate) an application."""
|
||||
app = Application.query.get(app_id)
|
||||
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
app.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Application deleted')
|
||||
|
||||
|
||||
# ---- Versions ----
|
||||
|
||||
@applications_bp.route('/<int:app_id>/versions', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_versions(app_id: int):
|
||||
"""List all versions for an application."""
|
||||
app = Application.query.get(app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
versions = app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()
|
||||
return success_response([v.to_dict() for v in versions])
|
||||
|
||||
|
||||
@applications_bp.route('/<int:app_id>/versions', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_version(app_id: int):
|
||||
"""Create a new version for an application."""
|
||||
app = Application.query.get(app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
data = request.get_json()
|
||||
if not data or not data.get('version'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'version is required')
|
||||
|
||||
if AppVersion.query.filter_by(appid=app_id, version=data['version']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Version '{data['version']}' already exists for this application",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
version = AppVersion(
|
||||
appid=app_id,
|
||||
version=data['version'],
|
||||
releasedate=data.get('releasedate'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
|
||||
db.session.add(version)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(version.to_dict(), message='Version created', http_code=201)
|
||||
|
||||
|
||||
# ---- Machines with this app installed ----
|
||||
|
||||
@applications_bp.route('/<int:app_id>/installed', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_installed_machines(app_id: int):
|
||||
"""List all machines that have this application installed."""
|
||||
app = Application.query.get(app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
installed = app.installed_on.filter_by(isactive=True).all()
|
||||
data = []
|
||||
for i in installed:
|
||||
item = i.to_dict()
|
||||
if i.machine:
|
||||
item['machine'] = {
|
||||
'machineid': i.machine.machineid,
|
||||
'machinenumber': i.machine.machinenumber,
|
||||
'alias': i.machine.alias,
|
||||
'hostname': i.machine.hostname
|
||||
}
|
||||
data.append(item)
|
||||
|
||||
return success_response(data)
|
||||
|
||||
|
||||
# ---- Installed Apps (per machine) ----
|
||||
|
||||
@applications_bp.route('/machines/<int:machine_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_machine_applications(machine_id: int):
|
||||
"""List all applications installed on a machine."""
|
||||
machine = Machine.query.get(machine_id)
|
||||
if not machine:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Machine not found', http_code=404)
|
||||
|
||||
installed = machine.installedapps.filter_by(isactive=True).all()
|
||||
return success_response([i.to_dict() for i in installed])
|
||||
|
||||
|
||||
@applications_bp.route('/machines/<int:machine_id>', methods=['POST'])
|
||||
@jwt_required()
|
||||
def install_application(machine_id: int):
|
||||
"""Install an application on a machine."""
|
||||
machine = Machine.query.get(machine_id)
|
||||
if not machine:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Machine not found', http_code=404)
|
||||
|
||||
data = request.get_json()
|
||||
if not data or not data.get('appid'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required')
|
||||
|
||||
app = Application.query.get(data['appid'])
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
# Check if already installed
|
||||
existing = InstalledApp.query.filter_by(
|
||||
machineid=machine_id,
|
||||
appid=data['appid']
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
if existing.isactive:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'Application already installed on this machine',
|
||||
http_code=409
|
||||
)
|
||||
# Reactivate
|
||||
existing.isactive = True
|
||||
existing.appversionid = data.get('appversionid')
|
||||
existing.installeddate = db.func.now()
|
||||
db.session.commit()
|
||||
return success_response(existing.to_dict(), message='Application reinstalled')
|
||||
|
||||
installed = InstalledApp(
|
||||
machineid=machine_id,
|
||||
appid=data['appid'],
|
||||
appversionid=data.get('appversionid')
|
||||
)
|
||||
|
||||
db.session.add(installed)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(installed.to_dict(), message='Application installed', http_code=201)
|
||||
|
||||
|
||||
@applications_bp.route('/machines/<int:machine_id>/<int:app_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def uninstall_application(machine_id: int, app_id: int):
|
||||
"""Uninstall an application from a machine."""
|
||||
installed = InstalledApp.query.filter_by(
|
||||
machineid=machine_id,
|
||||
appid=app_id,
|
||||
isactive=True
|
||||
).first()
|
||||
|
||||
if not installed:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this machine', http_code=404)
|
||||
|
||||
installed.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Application uninstalled')
|
||||
|
||||
|
||||
@applications_bp.route('/machines/<int:machine_id>/<int:app_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_installed_app(machine_id: int, app_id: int):
|
||||
"""Update installed application (e.g., change version)."""
|
||||
installed = InstalledApp.query.filter_by(
|
||||
machineid=machine_id,
|
||||
appid=app_id,
|
||||
isactive=True
|
||||
).first()
|
||||
|
||||
if not installed:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not installed on this machine', http_code=404)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
if 'appversionid' in data:
|
||||
installed.appversionid = data['appversionid']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return success_response(installed.to_dict(), message='Installation updated')
|
||||
|
||||
|
||||
# ---- Support Teams ----
|
||||
|
||||
@applications_bp.route('/supportteams', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_support_teams():
|
||||
"""List all support teams."""
|
||||
teams = SupportTeam.query.filter_by(isactive=True).order_by(SupportTeam.teamname).all()
|
||||
data = []
|
||||
for team in teams:
|
||||
team_dict = team.to_dict()
|
||||
team_dict['owner'] = team.owner.appowner if team.owner else None
|
||||
data.append(team_dict)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@applications_bp.route('/supportteams', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_support_team():
|
||||
"""Create a new support team."""
|
||||
data = request.get_json()
|
||||
if not data or not data.get('teamname'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'teamname is required')
|
||||
|
||||
team = SupportTeam(
|
||||
teamname=data['teamname'],
|
||||
teamurl=data.get('teamurl'),
|
||||
appownerid=data.get('appownerid')
|
||||
)
|
||||
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(team.to_dict(), message='Support team created', http_code=201)
|
||||
|
||||
|
||||
# ---- App Owners ----
|
||||
|
||||
@applications_bp.route('/appowners', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_app_owners():
|
||||
"""List all application owners."""
|
||||
owners = AppOwner.query.filter_by(isactive=True).order_by(AppOwner.appowner).all()
|
||||
return success_response([o.to_dict() for o in owners])
|
||||
|
||||
|
||||
@applications_bp.route('/appowners', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_app_owner():
|
||||
"""Create a new application owner."""
|
||||
data = request.get_json()
|
||||
if not data or not data.get('appowner'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'appowner is required')
|
||||
|
||||
owner = AppOwner(
|
||||
appowner=data['appowner'],
|
||||
sso=data.get('sso'),
|
||||
email=data.get('email')
|
||||
)
|
||||
|
||||
db.session.add(owner)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(owner.to_dict(), message='App owner created', http_code=201)
|
||||
147
shopdb/core/api/auth.py
Normal file
147
shopdb/core/api/auth.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Authentication API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
jwt_required,
|
||||
get_jwt_identity,
|
||||
current_user
|
||||
)
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import User
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""
|
||||
Authenticate user and return JWT tokens.
|
||||
|
||||
Request:
|
||||
{
|
||||
"username": "string",
|
||||
"password": "string"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"data": {
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"user": {...}
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('username') or not data.get('password'):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Username and password required'
|
||||
)
|
||||
|
||||
user = User.query.filter_by(
|
||||
username=data['username'],
|
||||
isactive=True
|
||||
).first()
|
||||
|
||||
if not user or not check_password_hash(user.passwordhash, data['password']):
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Invalid username or password',
|
||||
http_code=401
|
||||
)
|
||||
|
||||
if user.islocked:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'Account is locked',
|
||||
http_code=403
|
||||
)
|
||||
|
||||
# Create tokens (identity must be a string in Flask-JWT-Extended 4.x)
|
||||
access_token = create_access_token(
|
||||
identity=str(user.userid),
|
||||
additional_claims={
|
||||
'username': user.username,
|
||||
'roles': [r.rolename for r in user.roles]
|
||||
}
|
||||
)
|
||||
refresh_token = create_refresh_token(identity=str(user.userid))
|
||||
|
||||
# Update last login
|
||||
user.lastlogindate = db.func.now()
|
||||
user.failedlogins = 0
|
||||
db.session.commit()
|
||||
|
||||
return success_response({
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 3600,
|
||||
'user': {
|
||||
'userid': user.userid,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'firstname': user.firstname,
|
||||
'lastname': user.lastname,
|
||||
'roles': [r.rolename for r in user.roles]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/refresh', methods=['POST'])
|
||||
@jwt_required(refresh=True)
|
||||
def refresh():
|
||||
"""Refresh access token using refresh token."""
|
||||
user_id = get_jwt_identity()
|
||||
user = User.query.get(int(user_id))
|
||||
|
||||
if not user or not user.isactive:
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'User not found or inactive',
|
||||
http_code=401
|
||||
)
|
||||
|
||||
access_token = create_access_token(
|
||||
identity=str(user.userid),
|
||||
additional_claims={
|
||||
'username': user.username,
|
||||
'roles': [r.rolename for r in user.roles]
|
||||
}
|
||||
)
|
||||
|
||||
return success_response({
|
||||
'access_token': access_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 3600
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/me', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_current_user():
|
||||
"""Get current authenticated user info."""
|
||||
return success_response({
|
||||
'userid': current_user.userid,
|
||||
'username': current_user.username,
|
||||
'email': current_user.email,
|
||||
'firstname': current_user.firstname,
|
||||
'lastname': current_user.lastname,
|
||||
'roles': [r.rolename for r in current_user.roles],
|
||||
'permissions': current_user.getpermissions()
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/logout', methods=['POST'])
|
||||
@jwt_required()
|
||||
def logout():
|
||||
"""Logout user (for frontend token cleanup)."""
|
||||
# In a full implementation, you'd blacklist the token
|
||||
return success_response(message='Successfully logged out')
|
||||
144
shopdb/core/api/businessunits.py
Normal file
144
shopdb/core/api/businessunits.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Business Units API endpoints - Full CRUD."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import BusinessUnit
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
businessunits_bp = Blueprint('businessunits', __name__)
|
||||
|
||||
|
||||
@businessunits_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_businessunits():
|
||||
"""List all business units."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = BusinessUnit.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(BusinessUnit.isactive == True)
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
BusinessUnit.businessunit.ilike(f'%{search}%'),
|
||||
BusinessUnit.code.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(BusinessUnit.businessunit)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [bu.to_dict() for bu in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@businessunits_bp.route('/<int:bu_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_businessunit(bu_id: int):
|
||||
"""Get a single business unit."""
|
||||
bu = BusinessUnit.query.get(bu_id)
|
||||
|
||||
if not bu:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Business unit with ID {bu_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = bu.to_dict()
|
||||
data['parent'] = bu.parent.to_dict() if bu.parent else None
|
||||
data['children'] = [c.to_dict() for c in bu.children]
|
||||
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@businessunits_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_businessunit():
|
||||
"""Create a new business unit."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('businessunit'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunit is required')
|
||||
|
||||
if BusinessUnit.query.filter_by(businessunit=data['businessunit']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Business unit '{data['businessunit']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
bu = BusinessUnit(
|
||||
businessunit=data['businessunit'],
|
||||
code=data.get('code'),
|
||||
description=data.get('description'),
|
||||
parentid=data.get('parentid')
|
||||
)
|
||||
|
||||
db.session.add(bu)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(bu.to_dict(), message='Business unit created', http_code=201)
|
||||
|
||||
|
||||
@businessunits_bp.route('/<int:bu_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_businessunit(bu_id: int):
|
||||
"""Update a business unit."""
|
||||
bu = BusinessUnit.query.get(bu_id)
|
||||
|
||||
if not bu:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Business unit with ID {bu_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
if 'businessunit' in data and data['businessunit'] != bu.businessunit:
|
||||
if BusinessUnit.query.filter_by(businessunit=data['businessunit']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Business unit '{data['businessunit']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
for key in ['businessunit', 'code', 'description', 'parentid', 'isactive']:
|
||||
if key in data:
|
||||
setattr(bu, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(bu.to_dict(), message='Business unit updated')
|
||||
|
||||
|
||||
@businessunits_bp.route('/<int:bu_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_businessunit(bu_id: int):
|
||||
"""Delete (deactivate) a business unit."""
|
||||
bu = BusinessUnit.query.get(bu_id)
|
||||
|
||||
if not bu:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Business unit with ID {bu_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
bu.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Business unit deleted')
|
||||
117
shopdb/core/api/dashboard.py
Normal file
117
shopdb/core/api/dashboard.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Dashboard API endpoints."""
|
||||
|
||||
from flask import Blueprint
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Machine, MachineType, MachineStatus
|
||||
from shopdb.utils.responses import success_response
|
||||
|
||||
dashboard_bp = Blueprint('dashboard', __name__)
|
||||
|
||||
|
||||
@dashboard_bp.route('/summary', methods=['GET'])
|
||||
@dashboard_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_dashboard():
|
||||
"""Get dashboard summary data."""
|
||||
# Count machines by category
|
||||
equipment_count = db.session.query(Machine).join(MachineType).filter(
|
||||
Machine.isactive == True,
|
||||
MachineType.category == 'Equipment'
|
||||
).count()
|
||||
|
||||
pc_count = db.session.query(Machine).join(MachineType).filter(
|
||||
Machine.isactive == True,
|
||||
MachineType.category == 'PC'
|
||||
).count()
|
||||
|
||||
network_count = db.session.query(Machine).join(MachineType).filter(
|
||||
Machine.isactive == True,
|
||||
MachineType.category == 'Network'
|
||||
).count()
|
||||
|
||||
# Count by status
|
||||
status_counts = db.session.query(
|
||||
MachineStatus.status,
|
||||
db.func.count(Machine.machineid)
|
||||
).outerjoin(
|
||||
Machine,
|
||||
db.and_(Machine.statusid == MachineStatus.statusid, Machine.isactive == True)
|
||||
).group_by(MachineStatus.status).all()
|
||||
|
||||
# Recent machines
|
||||
recent_machines = Machine.query.filter_by(isactive=True).order_by(
|
||||
Machine.createddate.desc()
|
||||
).limit(10).all()
|
||||
|
||||
# Build status dict
|
||||
status_dict = {status: count for status, count in status_counts}
|
||||
|
||||
return success_response({
|
||||
# Fields expected by frontend
|
||||
'totalmachines': equipment_count + pc_count + network_count,
|
||||
'totalequipment': equipment_count,
|
||||
'totalpc': pc_count,
|
||||
'totalnetwork': network_count,
|
||||
'activemachines': status_dict.get('In Use', 0),
|
||||
'inrepair': status_dict.get('In Repair', 0),
|
||||
# Also include structured data
|
||||
'counts': {
|
||||
'equipment': equipment_count,
|
||||
'pcs': pc_count,
|
||||
'network_devices': network_count,
|
||||
'total': equipment_count + pc_count + network_count
|
||||
},
|
||||
'by_status': status_dict,
|
||||
'recent': [
|
||||
{
|
||||
'machineid': m.machineid,
|
||||
'machinenumber': m.machinenumber,
|
||||
'machinetype': m.machinetype.machinetype if m.machinetype else None,
|
||||
'createddate': m.createddate.isoformat() + 'Z' if m.createddate else None
|
||||
}
|
||||
for m in recent_machines
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@dashboard_bp.route('/stats', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_stats():
|
||||
"""Get detailed statistics."""
|
||||
# Machine type breakdown
|
||||
type_counts = db.session.query(
|
||||
MachineType.machinetype,
|
||||
MachineType.category,
|
||||
db.func.count(Machine.machineid)
|
||||
).outerjoin(
|
||||
Machine,
|
||||
db.and_(Machine.machinetypeid == MachineType.machinetypeid, Machine.isactive == True)
|
||||
).filter(MachineType.isactive == True).group_by(
|
||||
MachineType.machinetypeid
|
||||
).all()
|
||||
|
||||
return success_response({
|
||||
'by_type': [
|
||||
{'type': t, 'category': c, 'count': count}
|
||||
for t, c, count in type_counts
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@dashboard_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Health check endpoint (no auth required)."""
|
||||
try:
|
||||
# Test database connection
|
||||
db.session.execute(db.text('SELECT 1'))
|
||||
db_status = 'healthy'
|
||||
except Exception as e:
|
||||
db_status = f'unhealthy: {str(e)}'
|
||||
|
||||
return success_response({
|
||||
'status': 'ok' if db_status == 'healthy' else 'degraded',
|
||||
'database': db_status,
|
||||
'version': '1.0.0'
|
||||
})
|
||||
207
shopdb/core/api/knowledgebase.py
Normal file
207
shopdb/core/api/knowledgebase.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Knowledge Base API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import KnowledgeBase, Application
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
knowledgebase_bp = Blueprint('knowledgebase', __name__)
|
||||
|
||||
|
||||
@knowledgebase_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_articles():
|
||||
"""List all knowledge base articles."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = KnowledgeBase.query.filter_by(isactive=True)
|
||||
|
||||
# Search
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
KnowledgeBase.shortdescription.ilike(f'%{search}%'),
|
||||
KnowledgeBase.keywords.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by topic/application
|
||||
if appid := request.args.get('appid'):
|
||||
query = query.filter(KnowledgeBase.appid == int(appid))
|
||||
|
||||
# Sort options
|
||||
sort = request.args.get('sort', 'clicks')
|
||||
order = request.args.get('order', 'desc')
|
||||
|
||||
if sort == 'clicks':
|
||||
query = query.order_by(
|
||||
KnowledgeBase.clicks.desc() if order == 'desc' else KnowledgeBase.clicks.asc(),
|
||||
KnowledgeBase.lastupdated.desc()
|
||||
)
|
||||
elif sort == 'topic':
|
||||
query = query.join(Application).order_by(
|
||||
Application.appname.desc() if order == 'desc' else Application.appname.asc()
|
||||
)
|
||||
elif sort == 'description':
|
||||
query = query.order_by(
|
||||
KnowledgeBase.shortdescription.desc() if order == 'desc' else KnowledgeBase.shortdescription.asc()
|
||||
)
|
||||
elif sort == 'lastupdated':
|
||||
query = query.order_by(
|
||||
KnowledgeBase.lastupdated.desc() if order == 'desc' else KnowledgeBase.lastupdated.asc()
|
||||
)
|
||||
else:
|
||||
query = query.order_by(KnowledgeBase.clicks.desc())
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = []
|
||||
for article in items:
|
||||
article_dict = article.to_dict()
|
||||
if article.application:
|
||||
article_dict['application'] = {
|
||||
'appid': article.application.appid,
|
||||
'appname': article.application.appname
|
||||
}
|
||||
else:
|
||||
article_dict['application'] = None
|
||||
data.append(article_dict)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/stats', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_stats():
|
||||
"""Get knowledge base statistics."""
|
||||
total_clicks = db.session.query(
|
||||
db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0)
|
||||
).filter(KnowledgeBase.isactive == True).scalar()
|
||||
|
||||
total_articles = KnowledgeBase.query.filter_by(isactive=True).count()
|
||||
|
||||
return success_response({
|
||||
'totalclicks': int(total_clicks),
|
||||
'totalarticles': total_articles
|
||||
})
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_article(link_id: int):
|
||||
"""Get a single knowledge base article."""
|
||||
article = KnowledgeBase.query.get(link_id)
|
||||
|
||||
if not article or not article.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
|
||||
|
||||
data = article.to_dict()
|
||||
if article.application:
|
||||
data['application'] = {
|
||||
'appid': article.application.appid,
|
||||
'appname': article.application.appname
|
||||
}
|
||||
else:
|
||||
data['application'] = None
|
||||
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>/click', methods=['POST'])
|
||||
@jwt_required(optional=True)
|
||||
def track_click(link_id: int):
|
||||
"""Increment click counter and return the URL to redirect to."""
|
||||
article = KnowledgeBase.query.get(link_id)
|
||||
|
||||
if not article or not article.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
|
||||
|
||||
article.increment_clicks()
|
||||
db.session.commit()
|
||||
|
||||
return success_response({
|
||||
'linkurl': article.linkurl,
|
||||
'clicks': article.clicks
|
||||
})
|
||||
|
||||
|
||||
@knowledgebase_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_article():
|
||||
"""Create a new knowledge base article."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('shortdescription'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'shortdescription is required')
|
||||
|
||||
if not data.get('linkurl'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'linkurl is required')
|
||||
|
||||
# Validate application if provided
|
||||
if data.get('appid'):
|
||||
app = Application.query.get(data['appid'])
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
article = KnowledgeBase(
|
||||
shortdescription=data['shortdescription'],
|
||||
linkurl=data['linkurl'],
|
||||
appid=data.get('appid'),
|
||||
keywords=data.get('keywords'),
|
||||
clicks=0
|
||||
)
|
||||
|
||||
db.session.add(article)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(article.to_dict(), message='Article created', http_code=201)
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_article(link_id: int):
|
||||
"""Update a knowledge base article."""
|
||||
article = KnowledgeBase.query.get(link_id)
|
||||
|
||||
if not article:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Validate application if being changed
|
||||
if 'appid' in data and data['appid']:
|
||||
app = Application.query.get(data['appid'])
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
fields = ['shortdescription', 'linkurl', 'appid', 'keywords', 'isactive']
|
||||
for key in fields:
|
||||
if key in data:
|
||||
setattr(article, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(article.to_dict(), message='Article updated')
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_article(link_id: int):
|
||||
"""Delete (deactivate) a knowledge base article."""
|
||||
article = KnowledgeBase.query.get(link_id)
|
||||
|
||||
if not article:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
|
||||
|
||||
article.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Article deleted')
|
||||
144
shopdb/core/api/locations.py
Normal file
144
shopdb/core/api/locations.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Locations API endpoints - Full CRUD."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Location
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
locations_bp = Blueprint('locations', __name__)
|
||||
|
||||
|
||||
@locations_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def list_locations():
|
||||
"""List all locations."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = Location.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(Location.isactive == True)
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Location.locationname.ilike(f'%{search}%'),
|
||||
Location.building.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(Location.locationname)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [loc.to_dict() for loc in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@locations_bp.route('/<int:location_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_location(location_id: int):
|
||||
"""Get a single location."""
|
||||
loc = Location.query.get(location_id)
|
||||
|
||||
if not loc:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Location with ID {location_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(loc.to_dict())
|
||||
|
||||
|
||||
@locations_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_location():
|
||||
"""Create a new location."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('locationname'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'locationname is required')
|
||||
|
||||
if Location.query.filter_by(locationname=data['locationname']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Location '{data['locationname']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
loc = Location(
|
||||
locationname=data['locationname'],
|
||||
building=data.get('building'),
|
||||
floor=data.get('floor'),
|
||||
room=data.get('room'),
|
||||
description=data.get('description'),
|
||||
mapimage=data.get('mapimage'),
|
||||
mapwidth=data.get('mapwidth'),
|
||||
mapheight=data.get('mapheight')
|
||||
)
|
||||
|
||||
db.session.add(loc)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(loc.to_dict(), message='Location created', http_code=201)
|
||||
|
||||
|
||||
@locations_bp.route('/<int:location_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_location(location_id: int):
|
||||
"""Update a location."""
|
||||
loc = Location.query.get(location_id)
|
||||
|
||||
if not loc:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Location with ID {location_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
if 'locationname' in data and data['locationname'] != loc.locationname:
|
||||
if Location.query.filter_by(locationname=data['locationname']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Location '{data['locationname']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
for key in ['locationname', 'building', 'floor', 'room', 'description', 'mapimage', 'mapwidth', 'mapheight', 'isactive']:
|
||||
if key in data:
|
||||
setattr(loc, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(loc.to_dict(), message='Location updated')
|
||||
|
||||
|
||||
@locations_bp.route('/<int:location_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_location(location_id: int):
|
||||
"""Delete (deactivate) a location."""
|
||||
loc = Location.query.get(location_id)
|
||||
|
||||
if not loc:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Location with ID {location_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
loc.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Location deleted')
|
||||
567
shopdb/core/api/machines.py
Normal file
567
shopdb/core/api/machines.py
Normal file
@@ -0,0 +1,567 @@
|
||||
"""Machines API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required, current_user
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Machine, MachineType
|
||||
from shopdb.core.models.relationship import MachineRelationship, RelationshipType
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
machines_bp = Blueprint('machines', __name__)
|
||||
|
||||
|
||||
@machines_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_machines():
|
||||
"""
|
||||
List all machines with filtering and pagination.
|
||||
|
||||
Query params:
|
||||
page: int (default 1)
|
||||
per_page: int (default 20, max 100)
|
||||
machinetype: int (filter by type ID)
|
||||
pctype: int (filter by PC type ID)
|
||||
businessunit: int (filter by business unit ID)
|
||||
status: int (filter by status ID)
|
||||
category: str (Equipment, PC, Network)
|
||||
search: str (search in machinenumber, alias, hostname)
|
||||
active: bool (default true)
|
||||
sort: str (field name, prefix with - for desc)
|
||||
"""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
# Build query
|
||||
query = Machine.query
|
||||
|
||||
# Apply filters
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(Machine.isactive == True)
|
||||
|
||||
if machinetype_id := request.args.get('machinetype', type=int):
|
||||
query = query.filter(Machine.machinetypeid == machinetype_id)
|
||||
|
||||
if pctype_id := request.args.get('pctype', type=int):
|
||||
query = query.filter(Machine.pctypeid == pctype_id)
|
||||
|
||||
if businessunit_id := request.args.get('businessunit', type=int):
|
||||
query = query.filter(Machine.businessunitid == businessunit_id)
|
||||
|
||||
if status_id := request.args.get('status', type=int):
|
||||
query = query.filter(Machine.statusid == status_id)
|
||||
|
||||
if category := request.args.get('category'):
|
||||
query = query.join(MachineType).filter(MachineType.category == category)
|
||||
|
||||
if search := request.args.get('search'):
|
||||
search_term = f'%{search}%'
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Machine.machinenumber.ilike(search_term),
|
||||
Machine.alias.ilike(search_term),
|
||||
Machine.hostname.ilike(search_term),
|
||||
Machine.serialnumber.ilike(search_term)
|
||||
)
|
||||
)
|
||||
|
||||
# Filter for machines with map positions
|
||||
if request.args.get('hasmap', '').lower() == 'true':
|
||||
query = query.filter(
|
||||
Machine.mapleft.isnot(None),
|
||||
Machine.maptop.isnot(None)
|
||||
)
|
||||
|
||||
# Apply sorting
|
||||
sort_field = request.args.get('sort', 'machinenumber')
|
||||
desc = sort_field.startswith('-')
|
||||
if desc:
|
||||
sort_field = sort_field[1:]
|
||||
|
||||
if hasattr(Machine, sort_field):
|
||||
order = getattr(Machine, sort_field)
|
||||
query = query.order_by(order.desc() if desc else order)
|
||||
|
||||
# For map view, allow fetching all machines without pagination limit
|
||||
include_map_extras = request.args.get('hasmap', '').lower() == 'true'
|
||||
fetch_all = request.args.get('all', '').lower() == 'true'
|
||||
|
||||
if include_map_extras and fetch_all:
|
||||
# Get all map machines without pagination
|
||||
items = query.all()
|
||||
total = len(items)
|
||||
else:
|
||||
# Normal pagination
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
|
||||
# Convert to dicts with relationships
|
||||
data = []
|
||||
for m in items:
|
||||
d = m.to_dict()
|
||||
# Get machinetype from model (single source of truth)
|
||||
mt = m.derived_machinetype
|
||||
d['machinetype'] = mt.machinetype if mt else None
|
||||
d['machinetypeid'] = mt.machinetypeid if mt else None
|
||||
d['category'] = mt.category if mt else None
|
||||
d['status'] = m.status.status if m.status else None
|
||||
d['statusid'] = m.statusid
|
||||
d['businessunit'] = m.businessunit.businessunit if m.businessunit else None
|
||||
d['businessunitid'] = m.businessunitid
|
||||
d['vendor'] = m.vendor.vendor if m.vendor else None
|
||||
d['model'] = m.model.modelnumber if m.model else None
|
||||
d['pctype'] = m.pctype.pctype if m.pctype else None
|
||||
d['serialnumber'] = m.serialnumber
|
||||
d['isvnc'] = m.isvnc
|
||||
d['iswinrm'] = m.iswinrm
|
||||
|
||||
# Include extra fields for map view
|
||||
if include_map_extras:
|
||||
# Get primary IP address from communications
|
||||
primary_comm = next(
|
||||
(c for c in m.communications if c.isprimary and c.ipaddress),
|
||||
None
|
||||
)
|
||||
if not primary_comm:
|
||||
# Fall back to first communication with IP
|
||||
primary_comm = next(
|
||||
(c for c in m.communications if c.ipaddress),
|
||||
None
|
||||
)
|
||||
d['ipaddress'] = primary_comm.ipaddress if primary_comm else None
|
||||
|
||||
# Get connected PC (parent machine that is a PC)
|
||||
connected_pc = None
|
||||
for rel in m.parent_relationships:
|
||||
if rel.parent_machine and rel.parent_machine.is_pc:
|
||||
connected_pc = rel.parent_machine.machinenumber
|
||||
break
|
||||
d['connected_pc'] = connected_pc
|
||||
|
||||
data.append(d)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_machine(machine_id: int):
|
||||
"""Get a single machine by ID."""
|
||||
machine = Machine.query.get(machine_id)
|
||||
|
||||
if not machine:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine with ID {machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = machine.to_dict()
|
||||
# Add related data - machinetype comes from model (single source of truth)
|
||||
mt = machine.derived_machinetype
|
||||
data['machinetype'] = mt.to_dict() if mt else None
|
||||
data['pctype'] = machine.pctype.to_dict() if machine.pctype else None
|
||||
data['status'] = machine.status.to_dict() if machine.status else None
|
||||
data['businessunit'] = machine.businessunit.to_dict() if machine.businessunit else None
|
||||
data['vendor'] = machine.vendor.to_dict() if machine.vendor else None
|
||||
data['model'] = machine.model.to_dict() if machine.model else None
|
||||
data['location'] = machine.location.to_dict() if machine.location else None
|
||||
data['operatingsystem'] = machine.operatingsystem.to_dict() if machine.operatingsystem else None
|
||||
|
||||
# Add communications
|
||||
data['communications'] = [c.to_dict() for c in machine.communications.all()]
|
||||
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@machines_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_machine():
|
||||
"""Create a new machine."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
if not data.get('machinenumber'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'machinenumber is required')
|
||||
|
||||
if not data.get('modelnumberid'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumberid is required (determines machine type)')
|
||||
|
||||
# Check for duplicate machinenumber
|
||||
if Machine.query.filter_by(machinenumber=data['machinenumber']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Machine number '{data['machinenumber']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
# Create machine
|
||||
allowed_fields = [
|
||||
'machinenumber', 'alias', 'hostname', 'serialnumber',
|
||||
'machinetypeid', 'pctypeid', 'businessunitid', 'modelnumberid',
|
||||
'vendorid', 'statusid', 'locationid', 'osid',
|
||||
'mapleft', 'maptop', 'islocationonly',
|
||||
'loggedinuser', 'isvnc', 'iswinrm', 'isshopfloor',
|
||||
'requiresmanualconfig', 'notes'
|
||||
]
|
||||
|
||||
machine_data = {k: v for k, v in data.items() if k in allowed_fields}
|
||||
machine = Machine(**machine_data)
|
||||
machine.createdby = current_user.username
|
||||
|
||||
db.session.add(machine)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(
|
||||
machine.to_dict(),
|
||||
message='Machine created successfully',
|
||||
http_code=201
|
||||
)
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_machine(machine_id: int):
|
||||
"""Update an existing machine."""
|
||||
machine = Machine.query.get(machine_id)
|
||||
|
||||
if not machine:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine with ID {machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Check for duplicate machinenumber if changed
|
||||
if 'machinenumber' in data and data['machinenumber'] != machine.machinenumber:
|
||||
existing = Machine.query.filter_by(machinenumber=data['machinenumber']).first()
|
||||
if existing:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Machine number '{data['machinenumber']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
# Update allowed fields
|
||||
allowed_fields = [
|
||||
'machinenumber', 'alias', 'hostname', 'serialnumber',
|
||||
'machinetypeid', 'pctypeid', 'businessunitid', 'modelnumberid',
|
||||
'vendorid', 'statusid', 'locationid', 'osid',
|
||||
'mapleft', 'maptop', 'islocationonly',
|
||||
'loggedinuser', 'isvnc', 'iswinrm', 'isshopfloor',
|
||||
'requiresmanualconfig', 'notes', 'isactive'
|
||||
]
|
||||
|
||||
for key, value in data.items():
|
||||
if key in allowed_fields:
|
||||
setattr(machine, key, value)
|
||||
|
||||
machine.modifiedby = current_user.username
|
||||
db.session.commit()
|
||||
|
||||
return success_response(machine.to_dict(), message='Machine updated successfully')
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_machine(machine_id: int):
|
||||
"""Soft delete a machine."""
|
||||
machine = Machine.query.get(machine_id)
|
||||
|
||||
if not machine:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine with ID {machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
machine.soft_delete(deleted_by=current_user.username)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Machine deleted successfully')
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>/communications', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_machine_communications(machine_id: int):
|
||||
"""Get all communications for a machine."""
|
||||
machine = Machine.query.get(machine_id)
|
||||
|
||||
if not machine:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine with ID {machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
comms = [c.to_dict() for c in machine.communications.all()]
|
||||
return success_response(comms)
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>/communication', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_machine_communication(machine_id: int):
|
||||
"""Update machine communication (IP address)."""
|
||||
from shopdb.core.models.communication import Communication, CommunicationType
|
||||
|
||||
machine = Machine.query.get(machine_id)
|
||||
|
||||
if not machine:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine with ID {machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Get or create IP communication type
|
||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
if not ip_comtype:
|
||||
ip_comtype = CommunicationType(comtype='IP', description='IP Network')
|
||||
db.session.add(ip_comtype)
|
||||
db.session.flush()
|
||||
|
||||
# Find existing primary communication or create new one
|
||||
comms = list(machine.communications.all())
|
||||
comm = next((c for c in comms if c.isprimary), None)
|
||||
if not comm:
|
||||
comm = next((c for c in comms if c.comtypeid == ip_comtype.comtypeid), None)
|
||||
if not comm:
|
||||
comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid)
|
||||
db.session.add(comm)
|
||||
|
||||
# Update fields
|
||||
if 'ipaddress' in data:
|
||||
comm.ipaddress = data['ipaddress']
|
||||
if 'isprimary' in data:
|
||||
comm.isprimary = data['isprimary']
|
||||
if 'macaddress' in data:
|
||||
comm.macaddress = data['macaddress']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return success_response({
|
||||
'communicationid': comm.communicationid,
|
||||
'ipaddress': comm.ipaddress,
|
||||
'isprimary': comm.isprimary,
|
||||
}, message='Communication updated')
|
||||
|
||||
|
||||
# ==================== Machine Relationships ====================
|
||||
|
||||
@machines_bp.route('/<int:machine_id>/relationships', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_machine_relationships(machine_id: int):
|
||||
"""Get all relationships for a machine (both parent and child)."""
|
||||
machine = Machine.query.get(machine_id)
|
||||
|
||||
if not machine:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine with ID {machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
relationships = []
|
||||
my_category = machine.machinetype.category if machine.machinetype else None
|
||||
seen_ids = set()
|
||||
|
||||
# Get all relationships involving this machine
|
||||
all_rels = list(machine.child_relationships) + list(machine.parent_relationships)
|
||||
|
||||
for rel in all_rels:
|
||||
if rel.relationshipid in seen_ids:
|
||||
continue
|
||||
seen_ids.add(rel.relationshipid)
|
||||
|
||||
# Determine the related machine (the one that isn't us)
|
||||
if rel.parentmachineid == machine.machineid:
|
||||
related = rel.child_machine
|
||||
else:
|
||||
related = rel.parent_machine
|
||||
|
||||
related_category = related.machinetype.category if related and related.machinetype else None
|
||||
rel_type = rel.relationship_type.relationshiptype if rel.relationship_type else None
|
||||
|
||||
# Determine direction based on relationship type and categories
|
||||
if rel_type == 'Controls':
|
||||
# PC controls Equipment - determine from categories
|
||||
if my_category == 'PC':
|
||||
direction = 'controls'
|
||||
else:
|
||||
direction = 'controlled_by'
|
||||
elif rel_type == 'Dualpath':
|
||||
direction = 'dualpath_partner'
|
||||
else:
|
||||
# For other types, use parent/child
|
||||
if rel.parentmachineid == machine.machineid:
|
||||
direction = 'controls'
|
||||
else:
|
||||
direction = 'controlled_by'
|
||||
|
||||
relationships.append({
|
||||
'relationshipid': rel.relationshipid,
|
||||
'direction': direction,
|
||||
'relatedmachineid': related.machineid if related else None,
|
||||
'relatedmachinenumber': related.machinenumber if related else None,
|
||||
'relatedmachinealias': related.alias if related else None,
|
||||
'relatedcategory': related_category,
|
||||
'relationshiptype': rel_type,
|
||||
'relationshiptypeid': rel.relationshiptypeid,
|
||||
'notes': rel.notes
|
||||
})
|
||||
|
||||
return success_response(relationships)
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>/relationships', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_machine_relationship(machine_id: int):
|
||||
"""Create a relationship for a machine."""
|
||||
machine = Machine.query.get(machine_id)
|
||||
|
||||
if not machine:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine with ID {machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
related_machine_id = data.get('relatedmachineid')
|
||||
relationship_type_id = data.get('relationshiptypeid')
|
||||
direction = data.get('direction', 'controlled_by') # 'controls' or 'controlled_by'
|
||||
|
||||
if not related_machine_id:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'relatedmachineid is required')
|
||||
|
||||
if not relationship_type_id:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptypeid is required')
|
||||
|
||||
related_machine = Machine.query.get(related_machine_id)
|
||||
if not related_machine:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Related machine with ID {related_machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
# Determine parent/child based on direction
|
||||
if direction == 'controls':
|
||||
parent_id = machine_id
|
||||
child_id = related_machine_id
|
||||
else: # controlled_by
|
||||
parent_id = related_machine_id
|
||||
child_id = machine_id
|
||||
|
||||
# Check if relationship already exists
|
||||
existing = MachineRelationship.query.filter_by(
|
||||
parentmachineid=parent_id,
|
||||
childmachineid=child_id,
|
||||
relationshiptypeid=relationship_type_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'This relationship already exists',
|
||||
http_code=409
|
||||
)
|
||||
|
||||
relationship = MachineRelationship(
|
||||
parentmachineid=parent_id,
|
||||
childmachineid=child_id,
|
||||
relationshiptypeid=relationship_type_id,
|
||||
notes=data.get('notes')
|
||||
)
|
||||
|
||||
db.session.add(relationship)
|
||||
db.session.commit()
|
||||
|
||||
return success_response({
|
||||
'relationshipid': relationship.relationshipid,
|
||||
'parentmachineid': relationship.parentmachineid,
|
||||
'childmachineid': relationship.childmachineid,
|
||||
'relationshiptypeid': relationship.relationshiptypeid
|
||||
}, message='Relationship created successfully', http_code=201)
|
||||
|
||||
|
||||
@machines_bp.route('/relationships/<int:relationship_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_machine_relationship(relationship_id: int):
|
||||
"""Delete a machine relationship."""
|
||||
relationship = MachineRelationship.query.get(relationship_id)
|
||||
|
||||
if not relationship:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Relationship with ID {relationship_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
db.session.delete(relationship)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Relationship deleted successfully')
|
||||
|
||||
|
||||
@machines_bp.route('/relationshiptypes', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_relationship_types():
|
||||
"""List all relationship types."""
|
||||
types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
|
||||
return success_response([{
|
||||
'relationshiptypeid': t.relationshiptypeid,
|
||||
'relationshiptype': t.relationshiptype,
|
||||
'description': t.description
|
||||
} for t in types])
|
||||
|
||||
|
||||
@machines_bp.route('/relationshiptypes', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_relationship_type():
|
||||
"""Create a new relationship type."""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
if not data.get('relationshiptype'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required')
|
||||
|
||||
existing = RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first()
|
||||
if existing:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Relationship type '{data['relationshiptype']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
rel_type = RelationshipType(
|
||||
relationshiptype=data['relationshiptype'],
|
||||
description=data.get('description')
|
||||
)
|
||||
|
||||
db.session.add(rel_type)
|
||||
db.session.commit()
|
||||
|
||||
return success_response({
|
||||
'relationshiptypeid': rel_type.relationshiptypeid,
|
||||
'relationshiptype': rel_type.relationshiptype,
|
||||
'description': rel_type.description
|
||||
}, message='Relationship type created successfully', http_code=201)
|
||||
148
shopdb/core/api/machinetypes.py
Normal file
148
shopdb/core/api/machinetypes.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Machine Types API endpoints - Full CRUD."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required, current_user
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import MachineType
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
machinetypes_bp = Blueprint('machinetypes', __name__)
|
||||
|
||||
|
||||
@machinetypes_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_machinetypes():
|
||||
"""List all machine types with optional filtering."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = MachineType.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(MachineType.isactive == True)
|
||||
|
||||
if category := request.args.get('category'):
|
||||
query = query.filter(MachineType.category == category)
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(MachineType.machinetype.ilike(f'%{search}%'))
|
||||
|
||||
query = query.order_by(MachineType.machinetype)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [mt.to_dict() for mt in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@machinetypes_bp.route('/<int:type_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_machinetype(type_id: int):
|
||||
"""Get a single machine type."""
|
||||
mt = MachineType.query.get(type_id)
|
||||
|
||||
if not mt:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine type with ID {type_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(mt.to_dict())
|
||||
|
||||
|
||||
@machinetypes_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_machinetype():
|
||||
"""Create a new machine type."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('machinetype'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required')
|
||||
|
||||
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Machine type '{data['machinetype']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
mt = MachineType(
|
||||
machinetype=data['machinetype'],
|
||||
category=data.get('category', 'Equipment'),
|
||||
description=data.get('description'),
|
||||
icon=data.get('icon')
|
||||
)
|
||||
|
||||
db.session.add(mt)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(mt.to_dict(), message='Machine type created', http_code=201)
|
||||
|
||||
|
||||
@machinetypes_bp.route('/<int:type_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_machinetype(type_id: int):
|
||||
"""Update a machine type."""
|
||||
mt = MachineType.query.get(type_id)
|
||||
|
||||
if not mt:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine type with ID {type_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Check duplicate name
|
||||
if 'machinetype' in data and data['machinetype'] != mt.machinetype:
|
||||
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Machine type '{data['machinetype']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
for key in ['machinetype', 'category', 'description', 'icon', 'isactive']:
|
||||
if key in data:
|
||||
setattr(mt, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(mt.to_dict(), message='Machine type updated')
|
||||
|
||||
|
||||
@machinetypes_bp.route('/<int:type_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_machinetype(type_id: int):
|
||||
"""Delete (deactivate) a machine type."""
|
||||
mt = MachineType.query.get(type_id)
|
||||
|
||||
if not mt:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine type with ID {type_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
# Check if any machines use this type
|
||||
from shopdb.core.models import Machine
|
||||
if Machine.query.filter_by(machinetypeid=type_id, isactive=True).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'Cannot delete machine type: machines are using it',
|
||||
http_code=409
|
||||
)
|
||||
|
||||
mt.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Machine type deleted')
|
||||
151
shopdb/core/api/models.py
Normal file
151
shopdb/core/api/models.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Models (equipment models) API endpoints - Full CRUD."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Model
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
models_bp = Blueprint('models', __name__)
|
||||
|
||||
|
||||
@models_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_models():
|
||||
"""List all equipment models."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = Model.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(Model.isactive == True)
|
||||
|
||||
if vendor_id := request.args.get('vendor', type=int):
|
||||
query = query.filter(Model.vendorid == vendor_id)
|
||||
|
||||
if machinetype_id := request.args.get('machinetype', type=int):
|
||||
query = query.filter(Model.machinetypeid == machinetype_id)
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
|
||||
|
||||
query = query.order_by(Model.modelnumber)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
|
||||
data = []
|
||||
for m in items:
|
||||
d = m.to_dict()
|
||||
d['vendor'] = m.vendor.vendor if m.vendor else None
|
||||
d['machinetype'] = m.machinetype.machinetype if m.machinetype else None
|
||||
data.append(d)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@models_bp.route('/<int:model_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_model(model_id: int):
|
||||
"""Get a single model."""
|
||||
m = Model.query.get(model_id)
|
||||
|
||||
if not m:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Model with ID {model_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = m.to_dict()
|
||||
data['vendor'] = m.vendor.to_dict() if m.vendor else None
|
||||
data['machinetype'] = m.machinetype.to_dict() if m.machinetype else None
|
||||
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@models_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_model():
|
||||
"""Create a new model."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('modelnumber'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumber is required')
|
||||
|
||||
# Check duplicate
|
||||
existing = Model.query.filter_by(
|
||||
modelnumber=data['modelnumber'],
|
||||
vendorid=data.get('vendorid')
|
||||
).first()
|
||||
if existing:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Model '{data['modelnumber']}' already exists for this vendor",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
m = Model(
|
||||
modelnumber=data['modelnumber'],
|
||||
vendorid=data.get('vendorid'),
|
||||
machinetypeid=data.get('machinetypeid'),
|
||||
description=data.get('description'),
|
||||
imageurl=data.get('imageurl'),
|
||||
documentationurl=data.get('documentationurl'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
|
||||
db.session.add(m)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(m.to_dict(), message='Model created', http_code=201)
|
||||
|
||||
|
||||
@models_bp.route('/<int:model_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_model(model_id: int):
|
||||
"""Update a model."""
|
||||
m = Model.query.get(model_id)
|
||||
|
||||
if not m:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Model with ID {model_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
for key in ['modelnumber', 'vendorid', 'machinetypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
|
||||
if key in data:
|
||||
setattr(m, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(m.to_dict(), message='Model updated')
|
||||
|
||||
|
||||
@models_bp.route('/<int:model_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_model(model_id: int):
|
||||
"""Delete (deactivate) a model."""
|
||||
m = Model.query.get(model_id)
|
||||
|
||||
if not m:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Model with ID {model_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
m.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Model deleted')
|
||||
131
shopdb/core/api/operatingsystems.py
Normal file
131
shopdb/core/api/operatingsystems.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Operating Systems API endpoints - Full CRUD."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import OperatingSystem
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
operatingsystems_bp = Blueprint('operatingsystems', __name__)
|
||||
|
||||
|
||||
@operatingsystems_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def list_operatingsystems():
|
||||
"""List all operating systems."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = OperatingSystem.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(OperatingSystem.isactive == True)
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(OperatingSystem.osname.ilike(f'%{search}%'))
|
||||
|
||||
query = query.order_by(OperatingSystem.osname)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [os.to_dict() for os in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@operatingsystems_bp.route('/<int:os_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_operatingsystem(os_id: int):
|
||||
"""Get a single operating system."""
|
||||
os = OperatingSystem.query.get(os_id)
|
||||
|
||||
if not os:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Operating system with ID {os_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(os.to_dict())
|
||||
|
||||
|
||||
@operatingsystems_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_operatingsystem():
|
||||
"""Create a new operating system."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('osname'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'osname is required')
|
||||
|
||||
existing = OperatingSystem.query.filter_by(
|
||||
osname=data['osname'],
|
||||
osversion=data.get('osversion')
|
||||
).first()
|
||||
if existing:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Operating system '{data['osname']} {data.get('osversion', '')}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
os = OperatingSystem(
|
||||
osname=data['osname'],
|
||||
osversion=data.get('osversion'),
|
||||
architecture=data.get('architecture'),
|
||||
endoflife=data.get('endoflife')
|
||||
)
|
||||
|
||||
db.session.add(os)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(os.to_dict(), message='Operating system created', http_code=201)
|
||||
|
||||
|
||||
@operatingsystems_bp.route('/<int:os_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_operatingsystem(os_id: int):
|
||||
"""Update an operating system."""
|
||||
os = OperatingSystem.query.get(os_id)
|
||||
|
||||
if not os:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Operating system with ID {os_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
for key in ['osname', 'osversion', 'architecture', 'endoflife', 'isactive']:
|
||||
if key in data:
|
||||
setattr(os, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(os.to_dict(), message='Operating system updated')
|
||||
|
||||
|
||||
@operatingsystems_bp.route('/<int:os_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_operatingsystem(os_id: int):
|
||||
"""Delete (deactivate) an operating system."""
|
||||
os = OperatingSystem.query.get(os_id)
|
||||
|
||||
if not os:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Operating system with ID {os_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
os.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Operating system deleted')
|
||||
141
shopdb/core/api/pctypes.py
Normal file
141
shopdb/core/api/pctypes.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""PC Types API endpoints - Full CRUD."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import PCType
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
pctypes_bp = Blueprint('pctypes', __name__)
|
||||
|
||||
|
||||
@pctypes_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def list_pctypes():
|
||||
"""List all PC types."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = PCType.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(PCType.isactive == True)
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(PCType.pctype.ilike(f'%{search}%'))
|
||||
|
||||
query = query.order_by(PCType.pctype)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [pt.to_dict() for pt in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@pctypes_bp.route('/<int:type_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_pctype(type_id: int):
|
||||
"""Get a single PC type."""
|
||||
pt = PCType.query.get(type_id)
|
||||
|
||||
if not pt:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'PC type with ID {type_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(pt.to_dict())
|
||||
|
||||
|
||||
@pctypes_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_pctype():
|
||||
"""Create a new PC type."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('pctype'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'pctype is required')
|
||||
|
||||
if PCType.query.filter_by(pctype=data['pctype']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"PC type '{data['pctype']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
pt = PCType(
|
||||
pctype=data['pctype'],
|
||||
description=data.get('description')
|
||||
)
|
||||
|
||||
db.session.add(pt)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(pt.to_dict(), message='PC type created', http_code=201)
|
||||
|
||||
|
||||
@pctypes_bp.route('/<int:type_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_pctype(type_id: int):
|
||||
"""Update a PC type."""
|
||||
pt = PCType.query.get(type_id)
|
||||
|
||||
if not pt:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'PC type with ID {type_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
if 'pctype' in data and data['pctype'] != pt.pctype:
|
||||
if PCType.query.filter_by(pctype=data['pctype']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"PC type '{data['pctype']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
for key in ['pctype', 'description', 'isactive']:
|
||||
if key in data:
|
||||
setattr(pt, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(pt.to_dict(), message='PC type updated')
|
||||
|
||||
|
||||
@pctypes_bp.route('/<int:type_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_pctype(type_id: int):
|
||||
"""Delete (deactivate) a PC type."""
|
||||
pt = PCType.query.get(type_id)
|
||||
|
||||
if not pt:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'PC type with ID {type_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
from shopdb.core.models import Machine
|
||||
if Machine.query.filter_by(pctypeid=type_id, isactive=True).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'Cannot delete PC type: machines are using it',
|
||||
http_code=409
|
||||
)
|
||||
|
||||
pt.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='PC type deleted')
|
||||
203
shopdb/core/api/search.py
Normal file
203
shopdb/core/api/search.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Global search API endpoint."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import (
|
||||
Machine, Application, KnowledgeBase
|
||||
)
|
||||
from shopdb.utils.responses import success_response
|
||||
|
||||
search_bp = Blueprint('search', __name__)
|
||||
|
||||
|
||||
@search_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def global_search():
|
||||
"""
|
||||
Global search across multiple entity types.
|
||||
|
||||
Returns combined results from:
|
||||
- Machines (equipment and PCs)
|
||||
- Applications
|
||||
- Knowledge Base articles
|
||||
- Printers (if available)
|
||||
|
||||
Results are sorted by relevance score.
|
||||
"""
|
||||
query = request.args.get('q', '').strip()
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return success_response({
|
||||
'results': [],
|
||||
'query': query,
|
||||
'message': 'Search query must be at least 2 characters'
|
||||
})
|
||||
|
||||
if len(query) > 200:
|
||||
return success_response({
|
||||
'results': [],
|
||||
'query': query[:200],
|
||||
'message': 'Search query too long'
|
||||
})
|
||||
|
||||
results = []
|
||||
search_term = f'%{query}%'
|
||||
|
||||
# Search Machines (Equipment and PCs)
|
||||
machines = Machine.query.filter(
|
||||
Machine.isactive == True,
|
||||
db.or_(
|
||||
Machine.machinenumber.ilike(search_term),
|
||||
Machine.alias.ilike(search_term),
|
||||
Machine.hostname.ilike(search_term),
|
||||
Machine.serialnumber.ilike(search_term),
|
||||
Machine.notes.ilike(search_term)
|
||||
)
|
||||
).limit(10).all()
|
||||
|
||||
for m in machines:
|
||||
# Determine type: PC, Printer, or Equipment
|
||||
is_pc = m.pctypeid is not None
|
||||
is_printer = m.is_printer
|
||||
|
||||
# Calculate relevance - exact matches score higher
|
||||
relevance = 15
|
||||
if m.machinenumber and query.lower() == m.machinenumber.lower():
|
||||
relevance = 100
|
||||
elif m.hostname and query.lower() == m.hostname.lower():
|
||||
relevance = 100
|
||||
elif m.alias and query.lower() in m.alias.lower():
|
||||
relevance = 50
|
||||
|
||||
display_name = m.hostname if is_pc and m.hostname else m.machinenumber
|
||||
if m.alias and not is_pc:
|
||||
display_name = f"{m.machinenumber} ({m.alias})"
|
||||
|
||||
# Determine result type and URL
|
||||
if is_printer:
|
||||
result_type = 'printer'
|
||||
url = f"/printers/{m.machineid}"
|
||||
elif is_pc:
|
||||
result_type = 'pc'
|
||||
url = f"/pcs/{m.machineid}"
|
||||
else:
|
||||
result_type = 'machine'
|
||||
url = f"/machines/{m.machineid}"
|
||||
|
||||
# Get location - prefer machine's own location, fall back to parent machine's location
|
||||
location_name = None
|
||||
if m.location:
|
||||
location_name = m.location.locationname
|
||||
elif m.parent_relationships:
|
||||
# Check parent machines for location
|
||||
for rel in m.parent_relationships:
|
||||
if rel.parent_machine and rel.parent_machine.location:
|
||||
location_name = rel.parent_machine.location.locationname
|
||||
break
|
||||
|
||||
# Get machinetype from model (single source of truth)
|
||||
mt = m.derived_machinetype
|
||||
results.append({
|
||||
'type': result_type,
|
||||
'id': m.machineid,
|
||||
'title': display_name,
|
||||
'subtitle': mt.machinetype if mt else None,
|
||||
'location': location_name,
|
||||
'url': url,
|
||||
'relevance': relevance
|
||||
})
|
||||
|
||||
# Search Applications
|
||||
apps = Application.query.filter(
|
||||
Application.isactive == True,
|
||||
db.or_(
|
||||
Application.appname.ilike(search_term),
|
||||
Application.appdescription.ilike(search_term)
|
||||
)
|
||||
).limit(10).all()
|
||||
|
||||
for app in apps:
|
||||
relevance = 20
|
||||
if query.lower() == app.appname.lower():
|
||||
relevance = 100
|
||||
elif query.lower() in app.appname.lower():
|
||||
relevance = 50
|
||||
|
||||
results.append({
|
||||
'type': 'application',
|
||||
'id': app.appid,
|
||||
'title': app.appname,
|
||||
'subtitle': app.appdescription[:100] if app.appdescription else None,
|
||||
'url': f"/applications/{app.appid}",
|
||||
'relevance': relevance
|
||||
})
|
||||
|
||||
# Search Knowledge Base
|
||||
kb_articles = KnowledgeBase.query.filter(
|
||||
KnowledgeBase.isactive == True,
|
||||
db.or_(
|
||||
KnowledgeBase.shortdescription.ilike(search_term),
|
||||
KnowledgeBase.keywords.ilike(search_term)
|
||||
)
|
||||
).limit(20).all()
|
||||
|
||||
for kb in kb_articles:
|
||||
# Weight by clicks and keyword match
|
||||
relevance = 10 + (kb.clicks or 0) * 0.1
|
||||
if kb.keywords and query.lower() in kb.keywords.lower():
|
||||
relevance += 15
|
||||
|
||||
results.append({
|
||||
'type': 'knowledgebase',
|
||||
'id': kb.linkid,
|
||||
'title': kb.shortdescription,
|
||||
'subtitle': kb.application.appname if kb.application else None,
|
||||
'url': f"/knowledgebase/{kb.linkid}",
|
||||
'linkurl': kb.linkurl,
|
||||
'relevance': relevance
|
||||
})
|
||||
|
||||
# Search Printers (check if printers model exists)
|
||||
try:
|
||||
from shopdb.plugins.printers.models import Printer
|
||||
printers = Printer.query.filter(
|
||||
Printer.isactive == True,
|
||||
db.or_(
|
||||
Printer.printercsfname.ilike(search_term),
|
||||
Printer.printerwindowsname.ilike(search_term),
|
||||
Printer.serialnumber.ilike(search_term),
|
||||
Printer.fqdn.ilike(search_term)
|
||||
)
|
||||
).limit(10).all()
|
||||
|
||||
for p in printers:
|
||||
relevance = 15
|
||||
if p.printercsfname and query.lower() == p.printercsfname.lower():
|
||||
relevance = 100
|
||||
|
||||
display_name = p.printercsfname or p.printerwindowsname or f"Printer #{p.printerid}"
|
||||
|
||||
results.append({
|
||||
'type': 'printer',
|
||||
'id': p.printerid,
|
||||
'title': display_name,
|
||||
'subtitle': p.printerwindowsname if p.printercsfname else None,
|
||||
'url': f"/printers/{p.printerid}",
|
||||
'relevance': relevance
|
||||
})
|
||||
except ImportError:
|
||||
pass # Printers plugin not installed
|
||||
|
||||
# Sort by relevance (highest first)
|
||||
results.sort(key=lambda x: x['relevance'], reverse=True)
|
||||
|
||||
# Limit total results
|
||||
results = results[:30]
|
||||
|
||||
return success_response({
|
||||
'results': results,
|
||||
'query': query,
|
||||
'total': len(results)
|
||||
})
|
||||
139
shopdb/core/api/statuses.py
Normal file
139
shopdb/core/api/statuses.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Machine Statuses API endpoints - Full CRUD."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import MachineStatus
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
statuses_bp = Blueprint('statuses', __name__)
|
||||
|
||||
|
||||
@statuses_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_statuses():
|
||||
"""List all machine statuses."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = MachineStatus.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(MachineStatus.isactive == True)
|
||||
|
||||
query = query.order_by(MachineStatus.status)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [s.to_dict() for s in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@statuses_bp.route('/<int:status_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_status(status_id: int):
|
||||
"""Get a single status."""
|
||||
s = MachineStatus.query.get(status_id)
|
||||
|
||||
if not s:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Status with ID {status_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(s.to_dict())
|
||||
|
||||
|
||||
@statuses_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_status():
|
||||
"""Create a new status."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('status'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required')
|
||||
|
||||
if MachineStatus.query.filter_by(status=data['status']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Status '{data['status']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
s = MachineStatus(
|
||||
status=data['status'],
|
||||
description=data.get('description'),
|
||||
color=data.get('color')
|
||||
)
|
||||
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(s.to_dict(), message='Status created', http_code=201)
|
||||
|
||||
|
||||
@statuses_bp.route('/<int:status_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_status(status_id: int):
|
||||
"""Update a status."""
|
||||
s = MachineStatus.query.get(status_id)
|
||||
|
||||
if not s:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Status with ID {status_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
if 'status' in data and data['status'] != s.status:
|
||||
if MachineStatus.query.filter_by(status=data['status']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Status '{data['status']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
for key in ['status', 'description', 'color', 'isactive']:
|
||||
if key in data:
|
||||
setattr(s, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(s.to_dict(), message='Status updated')
|
||||
|
||||
|
||||
@statuses_bp.route('/<int:status_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_status(status_id: int):
|
||||
"""Delete (deactivate) a status."""
|
||||
s = MachineStatus.query.get(status_id)
|
||||
|
||||
if not s:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Status with ID {status_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
from shopdb.core.models import Machine
|
||||
if Machine.query.filter_by(statusid=status_id, isactive=True).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'Cannot delete status: machines are using it',
|
||||
http_code=409
|
||||
)
|
||||
|
||||
s.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Status deleted')
|
||||
137
shopdb/core/api/vendors.py
Normal file
137
shopdb/core/api/vendors.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Vendors API endpoints - Full CRUD."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Vendor
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes
|
||||
)
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
vendors_bp = Blueprint('vendors', __name__)
|
||||
|
||||
|
||||
@vendors_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def list_vendors():
|
||||
"""List all vendors."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = Vendor.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(Vendor.isactive == True)
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(Vendor.vendor.ilike(f'%{search}%'))
|
||||
|
||||
query = query.order_by(Vendor.vendor)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [v.to_dict() for v in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@vendors_bp.route('/<int:vendor_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_vendor(vendor_id: int):
|
||||
"""Get a single vendor."""
|
||||
v = Vendor.query.get(vendor_id)
|
||||
|
||||
if not v:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Vendor with ID {vendor_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(v.to_dict())
|
||||
|
||||
|
||||
@vendors_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_vendor():
|
||||
"""Create a new vendor."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('vendor'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required')
|
||||
|
||||
if Vendor.query.filter_by(vendor=data['vendor']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Vendor '{data['vendor']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
v = Vendor(
|
||||
vendor=data['vendor'],
|
||||
description=data.get('description'),
|
||||
website=data.get('website'),
|
||||
supportphone=data.get('supportphone'),
|
||||
supportemail=data.get('supportemail'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
|
||||
db.session.add(v)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(v.to_dict(), message='Vendor created', http_code=201)
|
||||
|
||||
|
||||
@vendors_bp.route('/<int:vendor_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_vendor(vendor_id: int):
|
||||
"""Update a vendor."""
|
||||
v = Vendor.query.get(vendor_id)
|
||||
|
||||
if not v:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Vendor with ID {vendor_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
if 'vendor' in data and data['vendor'] != v.vendor:
|
||||
if Vendor.query.filter_by(vendor=data['vendor']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Vendor '{data['vendor']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
for key in ['vendor', 'description', 'website', 'supportphone', 'supportemail', 'notes', 'isactive']:
|
||||
if key in data:
|
||||
setattr(v, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(v.to_dict(), message='Vendor updated')
|
||||
|
||||
|
||||
@vendors_bp.route('/<int:vendor_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_vendor(vendor_id: int):
|
||||
"""Delete (deactivate) a vendor."""
|
||||
v = Vendor.query.get(vendor_id)
|
||||
|
||||
if not v:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Vendor with ID {vendor_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
v.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Vendor deleted')
|
||||
49
shopdb/core/models/__init__.py
Normal file
49
shopdb/core/models/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Core SQLAlchemy models."""
|
||||
|
||||
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
||||
from .machine import Machine, MachineType, MachineStatus, PCType
|
||||
from .vendor import Vendor
|
||||
from .model import Model
|
||||
from .businessunit import BusinessUnit
|
||||
from .location import Location
|
||||
from .operatingsystem import OperatingSystem
|
||||
from .relationship import MachineRelationship, RelationshipType
|
||||
from .communication import Communication, CommunicationType
|
||||
from .user import User, Role
|
||||
from .application import Application, AppVersion, AppOwner, SupportTeam, InstalledApp
|
||||
from .knowledgebase import KnowledgeBase
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
'BaseModel',
|
||||
'SoftDeleteMixin',
|
||||
'AuditMixin',
|
||||
# Machine
|
||||
'Machine',
|
||||
'MachineType',
|
||||
'MachineStatus',
|
||||
'PCType',
|
||||
# Reference
|
||||
'Vendor',
|
||||
'Model',
|
||||
'BusinessUnit',
|
||||
'Location',
|
||||
'OperatingSystem',
|
||||
# Relationships
|
||||
'MachineRelationship',
|
||||
'RelationshipType',
|
||||
# Communication
|
||||
'Communication',
|
||||
'CommunicationType',
|
||||
# Auth
|
||||
'User',
|
||||
'Role',
|
||||
# Applications
|
||||
'Application',
|
||||
'AppVersion',
|
||||
'AppOwner',
|
||||
'SupportTeam',
|
||||
'InstalledApp',
|
||||
# Knowledge Base
|
||||
'KnowledgeBase',
|
||||
]
|
||||
130
shopdb/core/models/application.py
Normal file
130
shopdb/core/models/application.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Application tracking models."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class AppOwner(BaseModel):
|
||||
"""Application owner/contact."""
|
||||
__tablename__ = 'appowners'
|
||||
|
||||
appownerid = db.Column(db.Integer, primary_key=True)
|
||||
appowner = db.Column(db.String(100), nullable=False)
|
||||
sso = db.Column(db.String(50))
|
||||
email = db.Column(db.String(100))
|
||||
|
||||
# Relationships
|
||||
supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AppOwner {self.appowner}>"
|
||||
|
||||
|
||||
class SupportTeam(BaseModel):
|
||||
"""Application support team."""
|
||||
__tablename__ = 'supportteams'
|
||||
|
||||
supportteamid = db.Column(db.Integer, primary_key=True)
|
||||
teamname = db.Column(db.String(100), nullable=False)
|
||||
teamurl = db.Column(db.String(255))
|
||||
appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid'))
|
||||
|
||||
# Relationships
|
||||
owner = db.relationship('AppOwner', back_populates='supportteams')
|
||||
applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SupportTeam {self.teamname}>"
|
||||
|
||||
|
||||
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)
|
||||
image = db.Column(db.String(255))
|
||||
|
||||
# Relationships
|
||||
supportteam = db.relationship('SupportTeam', back_populates='applications')
|
||||
versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic')
|
||||
installed_on = db.relationship('InstalledApp', back_populates='application', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Application {self.appname}>"
|
||||
|
||||
|
||||
class AppVersion(BaseModel):
|
||||
"""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=db.func.now())
|
||||
|
||||
# Relationships
|
||||
application = db.relationship('Application', back_populates='versions')
|
||||
installations = db.relationship('InstalledApp', back_populates='appversion', lazy='dynamic')
|
||||
|
||||
# Unique constraint on app + version
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('appid', 'version', name='uq_app_version'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AppVersion {self.application.appname if self.application else self.appid} v{self.version}>"
|
||||
|
||||
|
||||
class InstalledApp(db.Model):
|
||||
"""Junction table for applications installed on machines (PCs)."""
|
||||
__tablename__ = 'installedapps'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'), nullable=False)
|
||||
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False)
|
||||
appversionid = db.Column(db.Integer, db.ForeignKey('appversions.appversionid'))
|
||||
isactive = db.Column(db.Boolean, default=True, nullable=False)
|
||||
installeddate = db.Column(db.DateTime, default=db.func.now())
|
||||
|
||||
# Relationships
|
||||
machine = db.relationship('Machine', back_populates='installedapps')
|
||||
application = db.relationship('Application', back_populates='installed_on')
|
||||
appversion = db.relationship('AppVersion', back_populates='installations')
|
||||
|
||||
# Unique constraint - one app per machine (can have different versions over time)
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('machineid', 'appid', name='uq_machine_app'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'machineid': self.machineid,
|
||||
'appid': self.appid,
|
||||
'appversionid': self.appversionid,
|
||||
'isactive': self.isactive,
|
||||
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None,
|
||||
'application': {
|
||||
'appid': self.application.appid,
|
||||
'appname': self.application.appname,
|
||||
'appdescription': self.application.appdescription,
|
||||
} if self.application else None,
|
||||
'version': self.appversion.version if self.appversion else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<InstalledApp machine={self.machineid} app={self.appid}>"
|
||||
66
shopdb/core/models/base.py
Normal file
66
shopdb/core/models/base.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Base model class with common fields."""
|
||||
|
||||
from datetime import datetime
|
||||
from shopdb.extensions import db
|
||||
|
||||
|
||||
class BaseModel(db.Model):
|
||||
"""
|
||||
Abstract base model with common fields.
|
||||
All models should inherit from this.
|
||||
"""
|
||||
__abstract__ = True
|
||||
|
||||
createddate = db.Column(
|
||||
db.DateTime,
|
||||
default=datetime.utcnow,
|
||||
nullable=False
|
||||
)
|
||||
modifieddate = db.Column(
|
||||
db.DateTime,
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.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.utcnow()
|
||||
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}>"
|
||||
90
shopdb/core/models/communication.py
Normal file
90
shopdb/core/models/communication.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""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 a machine.
|
||||
Stores network config, serial settings, etc.
|
||||
"""
|
||||
__tablename__ = 'communications'
|
||||
|
||||
communicationid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
machineid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('machines.machineid'),
|
||||
nullable=False
|
||||
)
|
||||
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_machine', 'machineid'),
|
||||
db.Index('idx_comm_ip', 'ipaddress'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Communication {self.machineid}:{self.comtype.comtype if self.comtype else 'Unknown'}>"
|
||||
27
shopdb/core/models/knowledgebase.py
Normal file
27
shopdb/core/models/knowledgebase.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Knowledge Base models."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class KnowledgeBase(BaseModel):
|
||||
"""Knowledge Base article linking to external resources."""
|
||||
__tablename__ = 'knowledgebase'
|
||||
|
||||
linkid = db.Column(db.Integer, primary_key=True)
|
||||
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'))
|
||||
shortdescription = db.Column(db.String(500), nullable=False)
|
||||
linkurl = db.Column(db.String(2000))
|
||||
keywords = db.Column(db.String(500))
|
||||
clicks = db.Column(db.Integer, default=0)
|
||||
lastupdated = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())
|
||||
|
||||
# Relationships
|
||||
application = db.relationship('Application', backref=db.backref('knowledgebase_articles', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<KnowledgeBase {self.linkid}: {self.shortdescription[:50] if self.shortdescription else 'No desc'}>"
|
||||
|
||||
def increment_clicks(self):
|
||||
"""Increment click counter."""
|
||||
self.clicks = (self.clicks or 0) + 1
|
||||
24
shopdb/core/models/location.py
Normal file
24
shopdb/core/models/location.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Location model."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Map configuration
|
||||
mapimage = db.Column(db.String(500), comment='Path to floor map image')
|
||||
mapwidth = db.Column(db.Integer)
|
||||
mapheight = db.Column(db.Integer)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Location {self.locationname}>"
|
||||
252
shopdb/core/models/machine.py
Normal file
252
shopdb/core/models/machine.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Unified Machine model - combines equipment and PCs."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
||||
|
||||
|
||||
class MachineType(BaseModel):
|
||||
"""
|
||||
Machine type classification.
|
||||
Categories: Equipment, PC, Network, Printer
|
||||
"""
|
||||
__tablename__ = 'machinetypes'
|
||||
|
||||
machinetypeid = db.Column(db.Integer, primary_key=True)
|
||||
machinetype = 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"<MachineType {self.machinetype}>"
|
||||
|
||||
|
||||
class MachineStatus(BaseModel):
|
||||
"""Machine status options."""
|
||||
__tablename__ = 'machinestatuses'
|
||||
|
||||
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"<MachineStatus {self.status}>"
|
||||
|
||||
|
||||
class PCType(BaseModel):
|
||||
"""
|
||||
PC type classification for more specific PC categorization.
|
||||
Examples: Shopfloor PC, Engineer Workstation, CMM PC, etc.
|
||||
"""
|
||||
__tablename__ = 'pctypes'
|
||||
|
||||
pctypeid = db.Column(db.Integer, primary_key=True)
|
||||
pctype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PCType {self.pctype}>"
|
||||
|
||||
|
||||
class Machine(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
"""
|
||||
Unified machine model for all asset types.
|
||||
|
||||
Machine types can be:
|
||||
- CNC machines, CMMs, EDMs, etc. (manufacturing equipment)
|
||||
- PCs (shopfloor PCs, engineer workstations, etc.)
|
||||
- Network devices (servers, switches, etc.) - if network_devices plugin not used
|
||||
|
||||
The machinetype.category field distinguishes between types.
|
||||
"""
|
||||
__tablename__ = 'machines'
|
||||
|
||||
machineid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Identification
|
||||
machinenumber = db.Column(
|
||||
db.String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'
|
||||
)
|
||||
alias = db.Column(
|
||||
db.String(100),
|
||||
comment='Friendly name'
|
||||
)
|
||||
hostname = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Network hostname (for PCs)'
|
||||
)
|
||||
serialnumber = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Hardware serial number'
|
||||
)
|
||||
|
||||
# Classification
|
||||
machinetypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('machinetypes.machinetypeid'),
|
||||
nullable=False
|
||||
)
|
||||
pctypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('pctypes.pctypeid'),
|
||||
nullable=True,
|
||||
comment='Set for PCs, NULL for equipment'
|
||||
)
|
||||
businessunitid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('businessunits.businessunitid'),
|
||||
nullable=True
|
||||
)
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True
|
||||
)
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Status
|
||||
statusid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('machinestatuses.statusid'),
|
||||
default=1,
|
||||
comment='In Use, Spare, Retired, etc.'
|
||||
)
|
||||
|
||||
# Location and mapping
|
||||
locationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('locations.locationid'),
|
||||
nullable=True
|
||||
)
|
||||
mapleft = db.Column(db.Integer, comment='X coordinate on floor map')
|
||||
maptop = db.Column(db.Integer, comment='Y coordinate on floor map')
|
||||
islocationonly = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Virtual location marker (not actual machine)'
|
||||
)
|
||||
|
||||
# PC-specific fields (nullable for non-PC machines)
|
||||
osid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('operatingsystems.osid'),
|
||||
nullable=True
|
||||
)
|
||||
loggedinuser = db.Column(db.String(100), nullable=True)
|
||||
lastreporteddate = db.Column(db.DateTime, nullable=True)
|
||||
lastboottime = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Features/flags
|
||||
isvnc = db.Column(db.Boolean, default=False, comment='VNC remote access enabled')
|
||||
iswinrm = db.Column(db.Boolean, default=False, comment='WinRM enabled')
|
||||
isshopfloor = db.Column(db.Boolean, default=False, comment='Shopfloor PC')
|
||||
requiresmanualconfig = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Multi-PC machine needs manual configuration'
|
||||
)
|
||||
|
||||
# Notes
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
machinetype = db.relationship('MachineType', backref='machines')
|
||||
pctype = db.relationship('PCType', backref='machines')
|
||||
businessunit = db.relationship('BusinessUnit', backref='machines')
|
||||
model = db.relationship('Model', backref='machines')
|
||||
vendor = db.relationship('Vendor', backref='machines')
|
||||
status = db.relationship('MachineStatus', backref='machines')
|
||||
location = db.relationship('Location', backref='machines')
|
||||
operatingsystem = db.relationship('OperatingSystem', backref='machines')
|
||||
|
||||
# Communications (one-to-many)
|
||||
communications = db.relationship(
|
||||
'Communication',
|
||||
backref='machine',
|
||||
cascade='all, delete-orphan',
|
||||
lazy='dynamic'
|
||||
)
|
||||
|
||||
# Installed applications (for PCs)
|
||||
installedapps = db.relationship(
|
||||
'InstalledApp',
|
||||
back_populates='machine',
|
||||
cascade='all, delete-orphan',
|
||||
lazy='dynamic'
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
db.Index('idx_machine_type_bu', 'machinetypeid', 'businessunitid'),
|
||||
db.Index('idx_machine_location', 'locationid'),
|
||||
db.Index('idx_machine_active', 'isactive'),
|
||||
db.Index('idx_machine_hostname', 'hostname'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Machine {self.machinenumber}>"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get display name (alias if set, otherwise machinenumber)."""
|
||||
return self.alias or self.machinenumber
|
||||
|
||||
@property
|
||||
def derived_machinetype(self):
|
||||
"""Get machinetype from model (single source of truth)."""
|
||||
if self.model and self.model.machinetype:
|
||||
return self.model.machinetype
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_pc(self):
|
||||
"""Check if this machine is a PC type."""
|
||||
mt = self.derived_machinetype
|
||||
return mt.category == 'PC' if mt else False
|
||||
|
||||
@property
|
||||
def is_equipment(self):
|
||||
"""Check if this machine is equipment."""
|
||||
mt = self.derived_machinetype
|
||||
return mt.category == 'Equipment' if mt else False
|
||||
|
||||
@property
|
||||
def is_network_device(self):
|
||||
"""Check if this machine is a network device."""
|
||||
mt = self.derived_machinetype
|
||||
return mt.category == 'Network' if mt else False
|
||||
|
||||
@property
|
||||
def is_printer(self):
|
||||
"""Check if this machine is a printer."""
|
||||
mt = self.derived_machinetype
|
||||
return mt.category == 'Printer' if mt else False
|
||||
|
||||
@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
|
||||
43
shopdb/core/models/model.py
Normal file
43
shopdb/core/models/model.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Model (equipment model number) model."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
"""Equipment/device model information."""
|
||||
__tablename__ = 'models'
|
||||
|
||||
modelnumberid = db.Column(db.Integer, primary_key=True)
|
||||
modelnumber = db.Column(db.String(100), nullable=False)
|
||||
|
||||
# Link to machine type (what kind of equipment this model is for)
|
||||
machinetypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('machinetypes.machinetypeid'),
|
||||
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
|
||||
machinetype = db.relationship('MachineType', 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}>"
|
||||
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}>"
|
||||
77
shopdb/core/models/relationship.py
Normal file
77
shopdb/core/models/relationship.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Machine relationship models."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class RelationshipType(BaseModel):
|
||||
"""Types of relationships between machines."""
|
||||
__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)
|
||||
|
||||
# Example types:
|
||||
# - "Controls" (PC controls Equipment)
|
||||
# - "Dualpath" (Redundant path partner)
|
||||
# - "Backup" (Backup machine)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RelationshipType {self.relationshiptype}>"
|
||||
|
||||
|
||||
class MachineRelationship(BaseModel):
|
||||
"""
|
||||
Relationships between machines.
|
||||
|
||||
Examples:
|
||||
- PC controls CNC machine
|
||||
- Two CNCs are dualpath partners
|
||||
"""
|
||||
__tablename__ = 'machinerelationships'
|
||||
|
||||
relationshipid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
parentmachineid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('machines.machineid'),
|
||||
nullable=False
|
||||
)
|
||||
childmachineid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('machines.machineid'),
|
||||
nullable=False
|
||||
)
|
||||
relationshiptypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('relationshiptypes.relationshiptypeid'),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
# Relationships
|
||||
parent_machine = db.relationship(
|
||||
'Machine',
|
||||
foreign_keys=[parentmachineid],
|
||||
backref='child_relationships'
|
||||
)
|
||||
child_machine = db.relationship(
|
||||
'Machine',
|
||||
foreign_keys=[childmachineid],
|
||||
backref='parent_relationships'
|
||||
)
|
||||
relationship_type = db.relationship('RelationshipType', backref='relationships')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint(
|
||||
'parentmachineid',
|
||||
'childmachineid',
|
||||
'relationshiptypeid',
|
||||
name='uq_machine_relationship'
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MachineRelationship {self.parentmachineid} -> {self.childmachineid}>"
|
||||
73
shopdb/core/models/user.py
Normal file
73
shopdb/core/models/user.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""User and authentication models."""
|
||||
|
||||
from datetime import datetime
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role {self.rolename}>"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# 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.utcnow() < 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 getpermissions(self) -> list:
|
||||
"""Get list of permission names from roles."""
|
||||
# Simple role-based permissions
|
||||
perms = []
|
||||
for role in self.roles:
|
||||
perms.append(role.rolename)
|
||||
return 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}>"
|
||||
1
shopdb/core/schemas/__init__.py
Normal file
1
shopdb/core/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core Marshmallow schemas."""
|
||||
1
shopdb/core/services/__init__.py
Normal file
1
shopdb/core/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core services."""
|
||||
Reference in New Issue
Block a user