"""Models (vendor model catalog) API endpoints - Full CRUD.""" import glob import os from flask import Blueprint, request, current_app, send_from_directory from flask_jwt_extended import jwt_required from werkzeug.utils import secure_filename 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 from shopdb.utils.authz import require_permission, require_role from shopdb.utils.import_mode import apply_import_timestamps models_bp = Blueprint('models', __name__) # Uploaded model photos live in the instance dir and are served publicly # (asset detail pages read the model image without auth). Same image set the # map/branding uploads accept. MODEL_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} # URL prefix an uploaded image resolves to. imageurl values with this prefix # are our own files under the instance dir; anything else (external URLs or the # shipped /images/models/* assets) is left on disk untouched. MODEL_IMAGE_URL_PREFIX = '/api/models/image/' def _modelimage_dir(): return os.path.join(current_app.instance_path, 'modelimages') @models_bp.route('', methods=['GET']) @jwt_required(optional=True) def list_models(): """List all vendor catalog 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 modeltype_id := request.args.get('modeltype', type=int): query = query.filter(Model.modeltypeid == modeltype_id) # Exact-match natural-key lookup for idempotent import. Natural key is # modelnumber + vendor; pair this with ?vendor= to disambiguate. if exactmodelnumber := request.args.get('modelnumber'): query = query.filter(Model.modelnumber == exactmodelnumber) 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['modeltype'] = m.modeltype.modeltype if m.modeltype else None data.append(d) return paginated_response(data, page, per_page, total) @models_bp.route('/', methods=['GET']) @jwt_required(optional=True) def get_model(model_id: int): """Get a single model.""" m = db.session.get(Model, 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['modeltype'] = m.modeltype.to_dict() if m.modeltype else None return success_response(data) @models_bp.route('', methods=['POST']) @jwt_required() @require_role('admin') 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'), modeltypeid=data.get('modeltypeid'), description=data.get('description'), imageurl=data.get('imageurl'), documentationurl=data.get('documentationurl'), notes=data.get('notes') ) db.session.add(m) apply_import_timestamps(m, data) db.session.commit() return success_response(m.to_dict(), message='Model created', http_code=201) @models_bp.route('/', methods=['PUT']) @jwt_required() @require_role('admin') def update_model(model_id: int): """Update a model.""" m = db.session.get(Model, 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', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']: if key in data: setattr(m, key, data[key]) apply_import_timestamps(m, data) db.session.commit() return success_response(m.to_dict(), message='Model updated') @models_bp.route('/', methods=['DELETE']) @jwt_required() @require_role('admin') def delete_model(model_id: int): """Delete (deactivate) a model.""" m = db.session.get(Model, 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') @models_bp.route('//image', methods=['POST']) @jwt_required() @require_role('admin') def upload_model_image(model_id: int): """Upload (or replace) the photo for a model. multipart/form-data: file=. Saves to the instance modelimages dir as model- (one image per model) and points model.imageurl at the served URL. Re-upload replaces the old file even when the extension changes. """ m = db.session.get(Model, model_id) if not m: return error_response( ErrorCodes.NOT_FOUND, f'Model with ID {model_id} not found', http_code=404 ) upload = request.files.get('file') if not upload or not upload.filename: return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') ext = os.path.splitext(upload.filename)[1].lower() if ext not in MODEL_IMAGE_EXTENSIONS: return error_response(ErrorCodes.VALIDATION_ERROR, f'Unsupported image type {ext}') imagedir = _modelimage_dir() os.makedirs(imagedir, exist_ok=True) # Wipe any prior model-.* file so a new extension does not orphan the old # one (one image per model). for old in glob.glob(os.path.join(imagedir, secure_filename(f'model-{model_id}') + '.*')): os.remove(old) filename = secure_filename(f'model-{model_id}{ext}') upload.save(os.path.join(imagedir, filename)) m.imageurl = f'{MODEL_IMAGE_URL_PREFIX}{filename}' db.session.commit() return success_response(m.to_dict(), message='Model image uploaded') @models_bp.route('/image/', methods=['GET']) def serve_model_image(filename): """Serve an uploaded model image (public - asset detail pages read it).""" return send_from_directory(_modelimage_dir(), filename) @models_bp.route('//image', methods=['DELETE']) @jwt_required() @require_role('admin') def delete_model_image(model_id: int): """Clear a model image and delete the uploaded file if we own it. External URLs and the shipped /images/models/* assets are never touched on disk - only the imageurl field is cleared. """ m = db.session.get(Model, model_id) if not m: return error_response( ErrorCodes.NOT_FOUND, f'Model with ID {model_id} not found', http_code=404 ) url = m.imageurl or '' if url.startswith(MODEL_IMAGE_URL_PREFIX): # secure_filename strips any traversal; the file lives in our dir only. filename = secure_filename(url[len(MODEL_IMAGE_URL_PREFIX):]) path = os.path.join(_modelimage_dir(), filename) if os.path.exists(path): os.remove(path) m.imageurl = None db.session.commit() return success_response(m.to_dict(), message='Model image removed')