Major refactor: deduplicate routes, sync, JS, and fix bugs
- Extract 8 common route patterns into factory functions in routes/shared.py (favourite, upload, replace cover, save defaults, clone, save JSON, get missing, clear covers) — removes ~1,100 lines across 9 route files - Extract generic _sync_category() in sync.py — 7 sync functions become one-liner wrappers, removing ~350 lines - Extract shared detail page JS into static/js/detail-common.js — all 9 detail templates now call initDetailPage() with minimal config - Extract layout inline JS into static/js/layout-utils.js (~185 lines) - Extract library toolbar JS into static/js/library-toolbar.js - Fix finalize missing-image bug: raise RuntimeError instead of logging warning so job is marked failed - Fix missing scheduler default in _default_checkpoint_data() - Fix N+1 query in Character.get_available_outfits() with batch IN query - Convert all print() to logger across services and routes - Add missing tags display to styles, scenes, detailers, checkpoints detail - Update delete buttons to use trash.png icon with solid red background - Update CLAUDE.md to reflect new architecture Net reduction: ~1,600 lines Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
422
routes/shared.py
Normal file
422
routes/shared.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
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')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category configuration registry
|
||||
# ---------------------------------------------------------------------------
|
||||
# Each entry maps a category name to its metadata.
|
||||
# 'url_prefix': the URL segment (e.g. 'outfit' → /outfit/<slug>/...)
|
||||
# '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 /<prefix>/<slug>/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}/<path:slug>/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 /<prefix>/<slug>/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}/<path:slug>/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 /<prefix>/<slug>/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}/<path:slug>/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 and os.path.exists(
|
||||
os.path.join(current_app.config['UPLOAD_FOLDER'], preview_path)):
|
||||
entity.image_path = preview_path
|
||||
db.session.commit()
|
||||
flash('Cover image updated!')
|
||||
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 /<prefix>/<slug>/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}/<path:slug>/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 /<prefix>/<slug>/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}/<path:slug>/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 /<prefix>/<slug>/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}/<path:slug>/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_<category> 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_<category>_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)
|
||||
Reference in New Issue
Block a user