""" Shared route factory functions for common patterns across all resource categories. Each factory registers a route on the Flask app using the closure pattern, preserving endpoint names for url_for() compatibility. """ import json import logging import os import re from flask import current_app, flash, redirect, request, url_for from sqlalchemy.orm.attributes import flag_modified from werkzeug.utils import secure_filename from models import db from utils import allowed_file logger = logging.getLogger('gaze') def apply_library_filters(query, model_class): """Apply standard favourite/NSFW filters and sorting to a library query. Returns (items, favourite_filter, nsfw_filter) tuple. """ fav = request.args.get('favourite') nsfw = request.args.get('nsfw', 'all') if fav == 'on': query = query.filter_by(is_favourite=True) if nsfw == 'sfw': query = query.filter_by(is_nsfw=False) elif nsfw == 'nsfw': query = query.filter_by(is_nsfw=True) items = query.order_by(model_class.is_favourite.desc(), model_class.name).all() return items, fav or '', nsfw # --------------------------------------------------------------------------- # Category configuration registry # --------------------------------------------------------------------------- # Each entry maps a category name to its metadata. # 'url_prefix': the URL segment (e.g. 'outfit' → /outfit//...) # 'detail_endpoint': the Flask endpoint name for the detail page # 'config_dir': the app.config key for the JSON data directory # 'category_folder': subfolder under static/uploads/ # 'id_field': JSON key for entity ID # 'name_field': JSON key for display name CATEGORY_CONFIG = { 'characters': { 'model': None, # Set at import time to avoid circular imports 'url_prefix': 'character', 'detail_endpoint': 'detail', 'config_dir': 'CHARACTERS_DIR', 'category_folder': 'characters', 'id_field': 'character_id', 'name_field': 'character_name', # Characters use unprefixed endpoint names 'endpoint_prefix': '', }, 'outfits': { 'model': None, 'url_prefix': 'outfit', 'detail_endpoint': 'outfit_detail', 'config_dir': 'CLOTHING_DIR', 'category_folder': 'outfits', 'id_field': 'outfit_id', 'name_field': 'outfit_name', 'endpoint_prefix': 'outfit_', }, 'actions': { 'model': None, 'url_prefix': 'action', 'detail_endpoint': 'action_detail', 'config_dir': 'ACTIONS_DIR', 'category_folder': 'actions', 'id_field': 'action_id', 'name_field': 'action_name', 'endpoint_prefix': 'action_', }, 'styles': { 'model': None, 'url_prefix': 'style', 'detail_endpoint': 'style_detail', 'config_dir': 'STYLES_DIR', 'category_folder': 'styles', 'id_field': 'style_id', 'name_field': 'style_name', 'endpoint_prefix': 'style_', }, 'scenes': { 'model': None, 'url_prefix': 'scene', 'detail_endpoint': 'scene_detail', 'config_dir': 'SCENES_DIR', 'category_folder': 'scenes', 'id_field': 'scene_id', 'name_field': 'scene_name', 'endpoint_prefix': 'scene_', }, 'detailers': { 'model': None, 'url_prefix': 'detailer', 'detail_endpoint': 'detailer_detail', 'config_dir': 'DETAILERS_DIR', 'category_folder': 'detailers', 'id_field': 'detailer_id', 'name_field': 'detailer_name', 'endpoint_prefix': 'detailer_', }, 'looks': { 'model': None, 'url_prefix': 'look', 'detail_endpoint': 'look_detail', 'config_dir': 'LOOKS_DIR', 'category_folder': 'looks', 'id_field': 'look_id', 'name_field': 'look_name', 'endpoint_prefix': 'look_', }, 'checkpoints': { 'model': None, 'url_prefix': 'checkpoint', 'detail_endpoint': 'checkpoint_detail', 'config_dir': 'CHECKPOINTS_DIR', 'category_folder': 'checkpoints', 'id_field': 'checkpoint_path', 'name_field': 'checkpoint_name', 'endpoint_prefix': 'checkpoint_', }, 'presets': { 'model': None, 'url_prefix': 'preset', 'detail_endpoint': 'preset_detail', 'config_dir': 'PRESETS_DIR', 'category_folder': 'presets', 'id_field': 'preset_id', 'name_field': 'preset_name', 'endpoint_prefix': 'preset_', }, } def _init_models(): """Lazily populate model references to avoid circular imports.""" from models import (Action, Character, Checkpoint, Detailer, Look, Outfit, Preset, Scene, Style) CATEGORY_CONFIG['characters']['model'] = Character CATEGORY_CONFIG['outfits']['model'] = Outfit CATEGORY_CONFIG['actions']['model'] = Action CATEGORY_CONFIG['styles']['model'] = Style CATEGORY_CONFIG['scenes']['model'] = Scene CATEGORY_CONFIG['detailers']['model'] = Detailer CATEGORY_CONFIG['looks']['model'] = Look CATEGORY_CONFIG['checkpoints']['model'] = Checkpoint CATEGORY_CONFIG['presets']['model'] = Preset def _get_config(category): """Get config for a category, initializing models if needed.""" cfg = CATEGORY_CONFIG[category] if cfg['model'] is None: _init_models() return cfg # --------------------------------------------------------------------------- # Factory functions # --------------------------------------------------------------------------- def _register_favourite_route(app, cfg): """Register POST ///favourite toggle route.""" Model = cfg['model'] prefix = cfg['url_prefix'] detail_ep = cfg['detail_endpoint'] ep_prefix = cfg['endpoint_prefix'] # Characters use 'toggle_character_favourite', others use 'toggle_{prefix}_favourite' if ep_prefix == '': endpoint_name = 'toggle_character_favourite' else: endpoint_name = f'toggle_{prefix}_favourite' @app.route(f'/{prefix}//favourite', methods=['POST'], endpoint=endpoint_name) def favourite_toggle(slug): entity = Model.query.filter_by(slug=slug).first_or_404() entity.is_favourite = not entity.is_favourite db.session.commit() if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'success': True, 'is_favourite': entity.is_favourite} return redirect(url_for(detail_ep, slug=slug)) def _register_upload_route(app, cfg): """Register POST ///upload image route.""" Model = cfg['model'] prefix = cfg['url_prefix'] detail_ep = cfg['detail_endpoint'] ep_prefix = cfg['endpoint_prefix'] folder = cfg['category_folder'] if ep_prefix == '': endpoint_name = 'upload_image' else: endpoint_name = f'upload_{prefix}_image' @app.route(f'/{prefix}//upload', methods=['POST'], endpoint=endpoint_name) def upload_image(slug): entity = Model.query.filter_by(slug=slug).first_or_404() if 'image' not in request.files: flash('No file part') return redirect(request.url) file = request.files['image'] if file.filename == '': flash('No selected file') return redirect(request.url) if file and allowed_file(file.filename): entity_folder = os.path.join( current_app.config['UPLOAD_FOLDER'], f"{folder}/{slug}") os.makedirs(entity_folder, exist_ok=True) filename = secure_filename(file.filename) file_path = os.path.join(entity_folder, filename) file.save(file_path) entity.image_path = f"{folder}/{slug}/{filename}" db.session.commit() flash('Image uploaded successfully!') return redirect(url_for(detail_ep, slug=slug)) def _register_replace_cover_route(app, cfg): """Register POST ///replace_cover_from_preview route.""" Model = cfg['model'] prefix = cfg['url_prefix'] detail_ep = cfg['detail_endpoint'] ep_prefix = cfg['endpoint_prefix'] if ep_prefix == '': endpoint_name = 'replace_cover_from_preview' else: endpoint_name = f'replace_{prefix}_cover_from_preview' @app.route(f'/{prefix}//replace_cover_from_preview', methods=['POST'], endpoint=endpoint_name) def replace_cover(slug): entity = Model.query.filter_by(slug=slug).first_or_404() preview_path = request.form.get('preview_path') if preview_path: full_path = os.path.realpath( os.path.join(current_app.config['UPLOAD_FOLDER'], preview_path)) upload_root = os.path.realpath(current_app.config['UPLOAD_FOLDER']) if full_path.startswith(upload_root + os.sep) and os.path.exists(full_path): entity.image_path = preview_path db.session.commit() flash('Cover image updated!') else: flash('Invalid preview path.', 'error') else: flash('No valid preview image selected.', 'error') return redirect(url_for(detail_ep, slug=slug)) def _register_save_defaults_route(app, cfg): """Register POST ///save_defaults route.""" Model = cfg['model'] prefix = cfg['url_prefix'] detail_ep = cfg['detail_endpoint'] ep_prefix = cfg['endpoint_prefix'] category = cfg['category_folder'] if ep_prefix == '': endpoint_name = 'save_defaults' else: endpoint_name = f'save_{prefix}_defaults' # Display name for the flash message display = category.rstrip('s') @app.route(f'/{prefix}//save_defaults', methods=['POST'], endpoint=endpoint_name) def save_defaults(slug): entity = Model.query.filter_by(slug=slug).first_or_404() selected_fields = request.form.getlist('include_field') entity.default_fields = selected_fields db.session.commit() flash(f'Default prompt selection saved for this {display}!') return redirect(url_for(detail_ep, slug=slug)) def _register_clone_route(app, cfg): """Register POST ///clone route.""" Model = cfg['model'] prefix = cfg['url_prefix'] detail_ep = cfg['detail_endpoint'] config_dir = cfg['config_dir'] id_field = cfg['id_field'] name_field = cfg['name_field'] endpoint_name = f'clone_{prefix}' @app.route(f'/{prefix}//clone', methods=['POST'], endpoint=endpoint_name) def clone_entity(slug): entity = Model.query.filter_by(slug=slug).first_or_404() base_id = getattr(entity, id_field, None) or entity.data.get(id_field) match = re.match(r'^(.+?)_(\d+)$', base_id) if match: base_name = match.group(1) current_num = int(match.group(2)) else: base_name = base_id current_num = 1 next_num = current_num + 1 while True: new_id = f"{base_name}_{next_num:02d}" new_filename = f"{new_id}.json" new_path = os.path.join( current_app.config[config_dir], new_filename) if not os.path.exists(new_path): break next_num += 1 new_data = dict(entity.data) new_data[id_field] = new_id new_data[name_field] = f"{entity.name} (Copy)" with open(new_path, 'w') as f: json.dump(new_data, f, indent=2) new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) kwargs = { id_field: new_id, 'slug': new_slug, 'filename': new_filename, 'name': new_data[name_field], 'data': new_data, } new_entity = Model(**kwargs) db.session.add(new_entity) db.session.commit() flash(f'Cloned as "{new_id}"!') return redirect(url_for(detail_ep, slug=new_slug)) def _register_save_json_route(app, cfg): """Register POST ///save_json route.""" Model = cfg['model'] prefix = cfg['url_prefix'] config_dir = cfg['config_dir'] ep_prefix = cfg['endpoint_prefix'] if ep_prefix == '': endpoint_name = 'save_character_json' else: endpoint_name = f'save_{prefix}_json' @app.route(f'/{prefix}//save_json', methods=['POST'], endpoint=endpoint_name) def save_json(slug): entity = Model.query.filter_by(slug=slug).first_or_404() try: new_data = json.loads(request.form.get('json_data', '')) except (ValueError, TypeError) as e: return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 entity.data = new_data flag_modified(entity, 'data') db.session.commit() if entity.filename: file_path = os.path.join( current_app.config[config_dir], entity.filename) with open(file_path, 'w') as f: json.dump(new_data, f, indent=2) return {'success': True} def _register_get_missing_route(app, cfg): """Register GET /get_missing_ route.""" Model = cfg['model'] category = cfg['category_folder'] endpoint_name = f'get_missing_{category}' @app.route(f'/get_missing_{category}', endpoint=endpoint_name) def get_missing(): missing = Model.query.filter( (Model.image_path == None) | (Model.image_path == '') ).order_by(Model.name).all() return {'missing': [{'slug': e.slug, 'name': e.name} for e in missing]} def _register_clear_covers_route(app, cfg): """Register POST /clear_all__covers route.""" Model = cfg['model'] category = cfg['category_folder'] ep_prefix = cfg['endpoint_prefix'] # Characters use 'clear_all_covers', others use 'clear_all_{category}_covers' if ep_prefix == '': endpoint_name = 'clear_all_covers' url = '/clear_all_covers' else: endpoint_name = f'clear_all_{category.rstrip("s")}_covers' url = f'/clear_all_{category.rstrip("s")}_covers' @app.route(url, methods=['POST'], endpoint=endpoint_name) def clear_covers(): entities = Model.query.all() for entity in entities: entity.image_path = None db.session.commit() return {'success': True} # --------------------------------------------------------------------------- # Main registration function # --------------------------------------------------------------------------- def register_common_routes(app, category): """Register all common routes for a category. Call this from each route module's register_routes(app) function. """ cfg = _get_config(category) _register_favourite_route(app, cfg) _register_upload_route(app, cfg) _register_replace_cover_route(app, cfg) _register_save_defaults_route(app, cfg) _register_clone_route(app, cfg) _register_save_json_route(app, cfg) _register_get_missing_route(app, cfg) _register_clear_covers_route(app, cfg)