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:
Aodhan Collins
2026-03-21 23:06:58 +00:00
parent ed9a7b4b11
commit 55ff58aba6
42 changed files with 1493 additions and 3105 deletions

View File

@@ -16,24 +16,13 @@ from services.sync import sync_actions
from services.file_io import get_available_loras
from services.llm import load_prompt, call_llm
from utils import allowed_file, _LORA_DEFAULTS
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/get_missing_actions')
def get_missing_actions():
missing = Action.query.filter((Action.image_path == None) | (Action.image_path == '')).order_by(Action.name).all()
return {'missing': [{'slug': a.slug, 'name': a.name} for a in missing]}
@app.route('/clear_all_action_covers', methods=['POST'])
def clear_all_action_covers():
actions = Action.query.all()
for action in actions:
action.image_path = None
db.session.commit()
return {'success': True}
register_common_routes(app, 'actions')
@app.route('/actions')
def actions_index():
@@ -151,40 +140,11 @@ def register_routes(app):
return redirect(url_for('action_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('actions/edit.html', action=action, loras=loras)
@app.route('/action/<path:slug>/upload', methods=['POST'])
def upload_action_image(slug):
action = Action.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):
# Create action subfolder
action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}")
os.makedirs(action_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(action_folder, filename)
file.save(file_path)
# Store relative path in DB
action.image_path = f"actions/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('action_detail', slug=slug))
@app.route('/action/<path:slug>/generate', methods=['POST'])
def generate_action_image(slug):
action_obj = Action.query.filter_by(slug=slug).first_or_404()
@@ -352,7 +312,7 @@ def register_routes(app):
# Append to main prompt
if extra_parts:
prompts["main"] += ", " + ", ".join(extra_parts)
print(f"Added extra character: {extra_char.name}")
logger.debug("Added extra character: %s", extra_char.name)
_append_background(prompts, character)
@@ -377,33 +337,12 @@ def register_routes(app):
return redirect(url_for('action_detail', slug=slug))
except Exception as e:
print(f"Generation error: {e}")
logger.exception("Generation error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
return redirect(url_for('action_detail', slug=slug))
@app.route('/action/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_action_cover_from_preview(slug):
action = Action.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(app.config['UPLOAD_FOLDER'], preview_path)):
action.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('action_detail', slug=slug))
@app.route('/action/<path:slug>/save_defaults', methods=['POST'])
def save_action_defaults(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
action.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this action!')
return redirect(url_for('action_detail', slug=slug))
@app.route('/actions/bulk_create', methods=['POST'])
def bulk_create_actions_from_loras():
_s = Settings.query.first()
@@ -556,7 +495,7 @@ def register_routes(app):
action_data['action_id'] = safe_slug
action_data['action_name'] = name
except Exception as e:
print(f"LLM error: {e}")
logger.exception("LLM error: %s", e)
flash(f"Failed to generate action profile: {e}")
return render_template('actions/create.html', form_data=form_data)
else:
@@ -587,74 +526,9 @@ def register_routes(app):
flash('Action created successfully!')
return redirect(url_for('action_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create action: {e}")
return render_template('actions/create.html', form_data=form_data)
return render_template('actions/create.html', form_data=form_data)
@app.route('/action/<path:slug>/clone', methods=['POST'])
def clone_action(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
# Find the next available number for the clone
base_id = action.action_id
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(app.config['ACTIONS_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
new_data = action.data.copy()
new_data['action_id'] = new_id
new_data['action_name'] = f"{action.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)
new_action = Action(
action_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['action_name'], data=new_data
)
db.session.add(new_action)
db.session.commit()
flash(f'Action cloned as "{new_id}"!')
return redirect(url_for('action_detail', slug=new_slug))
@app.route('/action/<path:slug>/save_json', methods=['POST'])
def save_action_json(slug):
action = Action.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
action.data = new_data
flag_modified(action, 'data')
db.session.commit()
if action.filename:
file_path = os.path.join(app.config['ACTIONS_DIR'], action.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/action/<path:slug>/favourite', methods=['POST'])
def toggle_action_favourite(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
action.is_favourite = not action.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': action.is_favourite}
return redirect(url_for('action_detail', slug=slug))

View File

@@ -5,8 +5,6 @@ import re
from flask import flash, jsonify, redirect, render_template, request, session, url_for
from sqlalchemy.orm.attributes import flag_modified
from werkzeug.utils import secure_filename
from models import Action, Character, Detailer, Look, Outfit, Scene, Style, db
from services.file_io import get_available_loras
from services.job_queue import _enqueue_job, _make_finalize
@@ -14,12 +12,13 @@ from services.llm import call_character_mcp_tool, call_llm, load_prompt
from services.prompts import build_prompt
from services.sync import sync_characters
from services.workflow import _get_default_checkpoint, _prepare_workflow
from utils import allowed_file
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'characters')
@app.route('/')
def index():
@@ -165,7 +164,7 @@ def register_routes(app):
new_data[f'{target_type}_name'] = new_name
except Exception as e:
print(f"LLM transfer error: {e}")
logger.exception("LLM transfer error: %s", e)
flash(f'Failed to generate {target_type} with AI: {e}')
return redirect(url_for('transfer_character', slug=slug))
else:
@@ -212,7 +211,7 @@ def register_routes(app):
return redirect(url_for('detailer_detail', slug=safe_slug))
except Exception as e:
print(f"Transfer save error: {e}")
logger.exception("Transfer save error: %s", e)
flash(f'Failed to save transferred {target_type}: {e}')
return redirect(url_for('transfer_character', slug=slug))
@@ -356,7 +355,7 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
logger.info(f"Generated outfit: {outfit_name} for character {name}")
except Exception as e:
print(f"Outfit generation error: {e}")
logger.exception("Outfit generation error: %s", e)
# Fall back to default
default_outfit_id = 'default'
@@ -404,7 +403,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
char_data.pop('wardrobe', None)
except Exception as e:
print(f"LLM error: {e}")
logger.exception("LLM error: %s", e)
error_msg = f"Failed to generate character profile: {e}"
if is_ajax:
return jsonify({'error': error_msg}), 500
@@ -473,7 +472,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
return redirect(url_for('detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
error_msg = f"Failed to create character: {e}"
if is_ajax:
return jsonify({'error': error_msg}), 500
@@ -565,7 +564,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
return redirect(url_for('detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('edit.html', character=character, loras=loras, char_looks=char_looks)
@@ -757,60 +756,6 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
return redirect(url_for('edit_character', slug=slug))
@app.route('/character/<path:slug>/upload', methods=['POST'])
def upload_image(slug):
character = Character.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):
# Create character subfolder
char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{slug}")
os.makedirs(char_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(char_folder, filename)
file.save(file_path)
# Store relative path in DB
character.image_path = f"characters/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('detail', slug=slug))
@app.route('/character/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_cover_from_preview(slug):
character = Character.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(app.config['UPLOAD_FOLDER'], preview_path)):
character.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('detail', slug=slug))
@app.route('/get_missing_characters')
def get_missing_characters():
missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).order_by(Character.name).all()
return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]}
@app.route('/clear_all_covers', methods=['POST'])
def clear_all_covers():
characters = Character.query.all()
for char in characters:
char.image_path = None
db.session.commit()
return {'success': True}
@app.route('/generate_missing', methods=['POST'])
def generate_missing():
missing = Character.query.filter(
@@ -834,7 +779,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
_enqueue_job(f"{character.name} cover", workflow, _make_finalize('characters', _slug, Character))
enqueued += 1
except Exception as e:
print(f"Error queuing cover generation for {character.name}: {e}")
logger.exception("Error queuing cover generation for %s: %s", character.name, e)
flash(f"Queued {enqueued} cover image generation job{'s' if enqueued != 1 else ''}.")
return redirect(url_for('index'))
@@ -882,26 +827,9 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
return redirect(url_for('detail', slug=slug))
except Exception as e:
print(f"Generation error: {e}")
logger.exception("Generation error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
return redirect(url_for('detail', slug=slug))
@app.route('/character/<path:slug>/save_defaults', methods=['POST'])
def save_defaults(slug):
character = Character.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
character.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this character!')
return redirect(url_for('detail', slug=slug))
@app.route('/character/<path:slug>/favourite', methods=['POST'])
def toggle_character_favourite(slug):
character = Character.query.filter_by(slug=slug).first_or_404()
character.is_favourite = not character.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': character.is_favourite}
return redirect(url_for('detail', slug=slug))

View File

@@ -15,11 +15,13 @@ from services.sync import sync_checkpoints, _default_checkpoint_data
from services.file_io import get_available_checkpoints
from services.llm import load_prompt, call_llm
from utils import allowed_file
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'checkpoints')
def _build_checkpoint_workflow(ckpt_obj, character=None, fixed_seed=None, extra_positive=None, extra_negative=None):
"""Build and return a prepared ComfyUI workflow dict for a checkpoint generation."""
@@ -95,26 +97,6 @@ def register_routes(app):
existing_previews=existing_previews,
extra_positive=extra_positive, extra_negative=extra_negative)
@app.route('/checkpoint/<path:slug>/upload', methods=['POST'])
def upload_checkpoint_image(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(url_for('checkpoint_detail', slug=slug))
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(url_for('checkpoint_detail', slug=slug))
if file and allowed_file(file.filename):
folder = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{slug}")
os.makedirs(folder, exist_ok=True)
filename = secure_filename(file.filename)
file.save(os.path.join(folder, filename))
ckpt.image_path = f"checkpoints/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/generate', methods=['POST'])
def generate_checkpoint_image(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
@@ -145,52 +127,12 @@ def register_routes(app):
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('checkpoint_detail', slug=slug))
except Exception as e:
print(f"Generation error: {e}")
logger.exception("Generation error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
return redirect(url_for('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_checkpoint_cover_from_preview(slug):
ckpt = Checkpoint.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(app.config['UPLOAD_FOLDER'], preview_path)):
ckpt.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/save_json', methods=['POST'])
def save_checkpoint_json(slug):
ckpt = Checkpoint.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
ckpt.data = new_data
flag_modified(ckpt, 'data')
db.session.commit()
checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints')
file_path = os.path.join(checkpoints_dir, f'{ckpt.slug}.json')
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/get_missing_checkpoints')
def get_missing_checkpoints():
missing = Checkpoint.query.filter((Checkpoint.image_path == None) | (Checkpoint.image_path == '')).order_by(Checkpoint.name).all()
return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]}
@app.route('/clear_all_checkpoint_covers', methods=['POST'])
def clear_all_checkpoint_covers():
for ckpt in Checkpoint.query.all():
ckpt.image_path = None
db.session.commit()
return {'success': True}
@app.route('/checkpoints/bulk_create', methods=['POST'])
def bulk_create_checkpoints():
checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints')
@@ -304,11 +246,3 @@ def register_routes(app):
flash(f'Queued {len(job_ids)} checkpoint tasks, {written_directly} written directly ({skipped} skipped).')
return redirect(url_for('checkpoints_index'))
@app.route('/checkpoint/<path:slug>/favourite', methods=['POST'])
def toggle_checkpoint_favourite(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
ckpt.is_favourite = not ckpt.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': ckpt.is_favourite}
return redirect(url_for('checkpoint_detail', slug=slug))

View File

@@ -3,23 +3,24 @@ import os
import re
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from flask import render_template, request, redirect, url_for, flash, session
from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Detailer, Action, Outfit, Style, Scene, Checkpoint, Settings, Look
from models import db, Character, Detailer, Action, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
from services.sync import sync_detailers
from services.file_io import get_available_loras
from services.llm import load_prompt, call_llm
from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS
from utils import _WARDROBE_KEYS
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'detailers')
def _queue_detailer_generation(detailer_obj, character=None, selected_fields=None, client_id=None, action=None, extra_positive=None, extra_negative=None, fixed_seed=None):
if character:
@@ -193,40 +194,11 @@ def register_routes(app):
return redirect(url_for('detailer_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('detailers/edit.html', detailer=detailer, loras=loras)
@app.route('/detailer/<path:slug>/upload', methods=['POST'])
def upload_detailer_image(slug):
detailer = Detailer.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):
# Create detailer subfolder
detailer_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{slug}")
os.makedirs(detailer_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(detailer_folder, filename)
file.save(file_path)
# Store relative path in DB
detailer.image_path = f"detailers/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/generate', methods=['POST'])
def generate_detailer_image(slug):
detailer_obj = Detailer.query.filter_by(slug=slug).first_or_404()
@@ -277,49 +249,12 @@ def register_routes(app):
return redirect(url_for('detailer_detail', slug=slug))
except Exception as e:
print(f"Generation error: {e}")
logger.exception("Generation error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/save_defaults', methods=['POST'])
def save_detailer_defaults(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
detailer.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this detailer!')
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_detailer_cover_from_preview(slug):
detailer = Detailer.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(app.config['UPLOAD_FOLDER'], preview_path)):
detailer.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/save_json', methods=['POST'])
def save_detailer_json(slug):
detailer = Detailer.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
detailer.data = new_data
flag_modified(detailer, 'data')
db.session.commit()
if detailer.filename:
file_path = os.path.join(app.config['DETAILERS_DIR'], detailer.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/detailers/bulk_create', methods=['POST'])
def bulk_create_detailers_from_loras():
_s = Settings.query.first()
@@ -463,17 +398,9 @@ def register_routes(app):
flash('Detailer created successfully!')
return redirect(url_for('detailer_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create detailer: {e}")
return render_template('detailers/create.html', form_data=form_data)
return render_template('detailers/create.html', form_data=form_data)
@app.route('/detailer/<path:slug>/favourite', methods=['POST'])
def toggle_detailer_favourite(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
detailer.is_favourite = not detailer.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': detailer.is_favourite}
return redirect(url_for('detailer_detail', slug=slug))

View File

@@ -3,18 +3,17 @@ import os
import re
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from flask import render_template, request, redirect, url_for, flash, session
from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Look, Action, Checkpoint, Settings, Outfit
from models import db, Character, Look, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background, _dedup_tags
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _dedup_tags
from services.sync import sync_looks
from services.file_io import get_available_loras, _count_look_assignments
from services.llm import load_prompt, call_llm
from utils import allowed_file
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
@@ -54,6 +53,7 @@ def _fix_look_lora_data(lora_data):
def register_routes(app):
register_common_routes(app, 'looks')
@app.route('/looks')
def looks_index():
@@ -173,23 +173,6 @@ def register_routes(app):
return render_template('looks/edit.html', look=look, characters=characters, loras=loras)
@app.route('/look/<path:slug>/upload', methods=['POST'])
def upload_look_image(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file selected')
return redirect(url_for('look_detail', slug=slug))
file = request.files['image']
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
look_folder = os.path.join(app.config['UPLOAD_FOLDER'], f'looks/{slug}')
os.makedirs(look_folder, exist_ok=True)
file_path = os.path.join(look_folder, filename)
file.save(file_path)
look.image_path = f'looks/{slug}/{filename}'
db.session.commit()
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/generate', methods=['POST'])
def generate_look_image(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
@@ -275,32 +258,12 @@ def register_routes(app):
return redirect(url_for('look_detail', slug=slug))
except Exception as e:
print(f"Generation error: {e}")
logger.exception("Generation error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_look_cover_from_preview(slug):
look = Look.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(app.config['UPLOAD_FOLDER'], preview_path)):
look.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/save_defaults', methods=['POST'])
def save_look_defaults(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
look.default_fields = request.form.getlist('include_field')
db.session.commit()
flash('Default prompt selection saved!')
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/generate_character', methods=['POST'])
def generate_character_from_look(slug):
"""Generate a character JSON using a look as the base."""
@@ -426,23 +389,6 @@ Character ID: {character_slug}"""
flash(f'Character "{character_name}" created from look!', 'success')
return redirect(url_for('detail', slug=character_slug))
@app.route('/look/<path:slug>/save_json', methods=['POST'])
def save_look_json(slug):
look = Look.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
look.data = new_data
look.character_id = new_data.get('character_id', look.character_id)
flag_modified(look, 'data')
db.session.commit()
if look.filename:
file_path = os.path.join(app.config['LOOKS_DIR'], look.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/look/create', methods=['GET', 'POST'])
def create_look():
characters = Character.query.order_by(Character.name).all()
@@ -499,25 +445,12 @@ Character ID: {character_slug}"""
flash(f'Look "{name}" created!')
return redirect(url_for('look_detail', slug=slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create look: {e}")
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
@app.route('/get_missing_looks')
def get_missing_looks():
missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).order_by(Look.name).all()
return {'missing': [{'slug': l.slug, 'name': l.name} for l in missing]}
@app.route('/clear_all_look_covers', methods=['POST'])
def clear_all_look_covers():
looks = Look.query.all()
for look in looks:
look.image_path = None
db.session.commit()
return {'success': True}
@app.route('/looks/bulk_create', methods=['POST'])
def bulk_create_looks_from_loras():
_s = Settings.query.first()
@@ -615,11 +548,3 @@ Character ID: {character_slug}"""
flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).')
return redirect(url_for('looks_index'))
@app.route('/look/<path:slug>/favourite', methods=['POST'])
def toggle_look_favourite(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
look.is_favourite = not look.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': look.is_favourite}
return redirect(url_for('look_detail', slug=slug))

View File

@@ -15,24 +15,13 @@ from services.sync import sync_outfits
from services.file_io import get_available_loras, _count_outfit_lora_assignments
from utils import allowed_file, _LORA_DEFAULTS
from services.llm import load_prompt, call_llm
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/get_missing_outfits')
def get_missing_outfits():
missing = Outfit.query.filter((Outfit.image_path == None) | (Outfit.image_path == '')).order_by(Outfit.name).all()
return {'missing': [{'slug': o.slug, 'name': o.name} for o in missing]}
@app.route('/clear_all_outfit_covers', methods=['POST'])
def clear_all_outfit_covers():
outfits = Outfit.query.all()
for outfit in outfits:
outfit.image_path = None
db.session.commit()
return {'success': True}
register_common_routes(app, 'outfits')
@app.route('/outfits')
def outfits_index():
@@ -268,40 +257,11 @@ def register_routes(app):
return redirect(url_for('outfit_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('outfits/edit.html', outfit=outfit, loras=loras)
@app.route('/outfit/<path:slug>/upload', methods=['POST'])
def upload_outfit_image(slug):
outfit = Outfit.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):
# Create outfit subfolder
outfit_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{slug}")
os.makedirs(outfit_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(outfit_folder, filename)
file.save(file_path)
# Store relative path in DB
outfit.image_path = f"outfits/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit/<path:slug>/generate', methods=['POST'])
def generate_outfit_image(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
@@ -406,24 +366,12 @@ def register_routes(app):
return redirect(url_for('outfit_detail', slug=slug))
except Exception as e:
print(f"Generation error: {e}")
logger.exception("Generation error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_outfit_cover_from_preview(slug):
outfit = Outfit.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(app.config['UPLOAD_FOLDER'], preview_path)):
outfit.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit/create', methods=['GET', 'POST'])
def create_outfit():
form_data = {}
@@ -496,7 +444,7 @@ def register_routes(app):
outfit_data['tags'] = []
except Exception as e:
print(f"LLM error: {e}")
logger.exception("LLM error: %s", e)
flash(f"Failed to generate outfit profile: {e}")
return render_template('outfits/create.html', form_data=form_data)
else:
@@ -542,92 +490,10 @@ def register_routes(app):
return redirect(url_for('outfit_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create outfit: {e}")
return render_template('outfits/create.html', form_data=form_data)
return render_template('outfits/create.html', form_data=form_data)
@app.route('/outfit/<path:slug>/save_defaults', methods=['POST'])
def save_outfit_defaults(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
outfit.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this outfit!')
return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit/<path:slug>/clone', methods=['POST'])
def clone_outfit(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
# Find the next available number for the clone
base_id = outfit.outfit_id
# Extract base name without number suffix
import re
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
# Find next available number
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(app.config['CLOTHING_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
# Create new outfit data (copy of original)
new_data = outfit.data.copy()
new_data['outfit_id'] = new_id
new_data['outfit_name'] = f"{outfit.name} (Copy)"
# Save the new JSON file
with open(new_path, 'w') as f:
json.dump(new_data, f, indent=2)
# Create new outfit in database
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_outfit = Outfit(
outfit_id=new_id,
slug=new_slug,
filename=new_filename,
name=new_data['outfit_name'],
data=new_data
)
db.session.add(new_outfit)
db.session.commit()
flash(f'Outfit cloned as "{new_id}"!')
return redirect(url_for('outfit_detail', slug=new_slug))
@app.route('/outfit/<path:slug>/save_json', methods=['POST'])
def save_outfit_json(slug):
outfit = Outfit.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
outfit.data = new_data
flag_modified(outfit, 'data')
db.session.commit()
if outfit.filename:
file_path = os.path.join(app.config['CLOTHING_DIR'], outfit.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/outfit/<path:slug>/favourite', methods=['POST'])
def toggle_outfit_favourite(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
outfit.is_favourite = not outfit.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': outfit.is_favourite}
return redirect(url_for('outfit_detail', slug=slug))

View File

@@ -2,23 +2,19 @@ import json
import os
import re
import logging
import random
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from models import db, Character, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look, Settings
from models import db, Character, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look
from sqlalchemy.orm.attributes import flag_modified
from services.prompts import build_prompt, _dedup_tags, _resolve_character, _ensure_character_fields, _append_background
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize
from services.sync import sync_presets, _resolve_preset_entity, _resolve_preset_fields, _PRESET_ENTITY_MAP
from services.sync import sync_presets
from services.generation import generate_from_preset
from services.llm import load_prompt, call_llm
from utils import allowed_file
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'presets')
@app.route('/presets')
def presets_index():
@@ -79,37 +75,6 @@ def register_routes(app):
flash(f"Error during generation: {str(e)}")
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_preset_cover_from_preview(slug):
preset = Preset.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)):
preset.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/upload', methods=['POST'])
def upload_preset_image(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file uploaded.')
return redirect(url_for('preset_detail', slug=slug))
file = request.files['image']
if file.filename == '':
flash('No file selected.')
return redirect(url_for('preset_detail', slug=slug))
filename = secure_filename(file.filename)
folder = os.path.join(current_app.config['UPLOAD_FOLDER'], f'presets/{slug}')
os.makedirs(folder, exist_ok=True)
file.save(os.path.join(folder, filename))
preset.image_path = f'presets/{slug}/{filename}'
db.session.commit()
flash('Image uploaded!')
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/edit', methods=['GET', 'POST'])
def edit_preset(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
@@ -196,51 +161,6 @@ def register_routes(app):
styles=styles, scenes=scenes, detailers=detailers,
looks=looks, checkpoints=checkpoints)
@app.route('/preset/<path:slug>/save_json', methods=['POST'])
def save_preset_json(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
preset.data = new_data
preset.name = new_data.get('preset_name', preset.name)
flag_modified(preset, "data")
db.session.commit()
if preset.filename:
file_path = os.path.join(current_app.config['PRESETS_DIR'], preset.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}, 400
@app.route('/preset/<path:slug>/clone', methods=['POST'])
def clone_preset(slug):
original = Preset.query.filter_by(slug=slug).first_or_404()
new_data = dict(original.data)
base_id = f"{original.preset_id}_copy"
new_id = base_id
counter = 1
while Preset.query.filter_by(preset_id=new_id).first():
new_id = f"{base_id}_{counter}"
counter += 1
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_data['preset_id'] = new_id
new_data['preset_name'] = f"{original.name} (Copy)"
new_filename = f"{new_id}.json"
os.makedirs(current_app.config['PRESETS_DIR'], exist_ok=True)
with open(os.path.join(current_app.config['PRESETS_DIR'], new_filename), 'w') as f:
json.dump(new_data, f, indent=2)
new_preset = Preset(preset_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['preset_name'], data=new_data)
db.session.add(new_preset)
db.session.commit()
flash(f"Cloned as '{new_data['preset_name']}'")
return redirect(url_for('preset_detail', slug=new_slug))
@app.route('/presets/rescan', methods=['POST'])
def rescan_presets():
sync_presets()
@@ -322,7 +242,3 @@ def register_routes(app):
return render_template('presets/create.html', form_data=form_data)
@app.route('/get_missing_presets')
def get_missing_presets():
missing = Preset.query.filter((Preset.image_path == None) | (Preset.image_path == '')).order_by(Preset.filename).all()
return {'missing': [{'slug': p.slug, 'name': p.name} for p in missing]}

View File

@@ -3,36 +3,24 @@ import os
import re
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from flask import render_template, request, redirect, url_for, flash, session
from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Scene, Outfit, Action, Style, Detailer, Checkpoint, Settings, Look
from models import db, Character, Scene, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields
from services.sync import sync_scenes
from services.file_io import get_available_loras
from services.llm import load_prompt, call_llm
from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS
from routes.shared import register_common_routes
from utils import _WARDROBE_KEYS
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/get_missing_scenes')
def get_missing_scenes():
missing = Scene.query.filter((Scene.image_path == None) | (Scene.image_path == '')).order_by(Scene.name).all()
return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]}
@app.route('/clear_all_scene_covers', methods=['POST'])
def clear_all_scene_covers():
scenes = Scene.query.all()
for scene in scenes:
scene.image_path = None
db.session.commit()
return {'success': True}
register_common_routes(app, 'scenes')
@app.route('/scenes')
def scenes_index():
@@ -147,40 +135,11 @@ def register_routes(app):
return redirect(url_for('scene_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('scenes/edit.html', scene=scene, loras=loras)
@app.route('/scene/<path:slug>/upload', methods=['POST'])
def upload_scene_image(slug):
scene = Scene.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):
# Create scene subfolder
scene_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{slug}")
os.makedirs(scene_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(scene_folder, filename)
file.save(file_path)
# Store relative path in DB
scene.image_path = f"scenes/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('scene_detail', slug=slug))
def _queue_scene_generation(scene_obj, character=None, selected_fields=None, client_id=None, fixed_seed=None, extra_positive=None, extra_negative=None):
if character:
combined_data = character.data.copy()
@@ -306,33 +265,12 @@ def register_routes(app):
return redirect(url_for('scene_detail', slug=slug))
except Exception as e:
print(f"Generation error: {e}")
logger.exception("Generation error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
return redirect(url_for('scene_detail', slug=slug))
@app.route('/scene/<path:slug>/save_defaults', methods=['POST'])
def save_scene_defaults(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
scene.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this scene!')
return redirect(url_for('scene_detail', slug=slug))
@app.route('/scene/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_scene_cover_from_preview(slug):
scene = Scene.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(app.config['UPLOAD_FOLDER'], preview_path)):
scene.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('scene_detail', slug=slug))
@app.route('/scenes/bulk_create', methods=['POST'])
def bulk_create_scenes_from_loras():
_s = Settings.query.first()
@@ -483,74 +421,9 @@ def register_routes(app):
flash('Scene created successfully!')
return redirect(url_for('scene_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create scene: {e}")
return render_template('scenes/create.html', form_data=form_data)
return render_template('scenes/create.html', form_data=form_data)
@app.route('/scene/<path:slug>/clone', methods=['POST'])
def clone_scene(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
base_id = scene.scene_id
import re
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(app.config['SCENES_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
new_data = scene.data.copy()
new_data['scene_id'] = new_id
new_data['scene_name'] = f"{scene.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)
new_scene = Scene(
scene_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['scene_name'], data=new_data
)
db.session.add(new_scene)
db.session.commit()
flash(f'Scene cloned as "{new_id}"!')
return redirect(url_for('scene_detail', slug=new_slug))
@app.route('/scene/<path:slug>/save_json', methods=['POST'])
def save_scene_json(slug):
scene = Scene.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
scene.data = new_data
flag_modified(scene, 'data')
db.session.commit()
if scene.filename:
file_path = os.path.join(app.config['SCENES_DIR'], scene.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/scene/<path:slug>/favourite', methods=['POST'])
def toggle_scene_favourite(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
scene.is_favourite = not scene.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': scene.is_favourite}
return redirect(url_for('scene_detail', slug=slug))

422
routes/shared.py Normal file
View 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)

View File

@@ -263,7 +263,7 @@ def register_routes(app):
else:
character = None
print(f"[Strengths] char_slug={char_slug!r} character={character.slug if character else 'none'}")
logger.debug("Strengths: char_slug=%r -> character=%s", char_slug, character.slug if character else 'none')
action_obj = None
extra_positive = ''
@@ -274,7 +274,7 @@ def register_routes(app):
action_obj = Action.query.filter_by(slug=action_slug).first()
extra_positive = session.get(f'extra_pos_detailer_{slug}', '')
extra_negative = session.get(f'extra_neg_detailer_{slug}', '')
print(f"[Strengths] detailer session char={char_slug}, action={action_slug}, extra_pos={bool(extra_positive)}, extra_neg={bool(extra_negative)}")
logger.debug("Strengths: detailer session -- char=%s, action=%s, extra_pos=%s, extra_neg=%s", char_slug, action_slug, bool(extra_positive), bool(extra_negative))
prompts = _build_strengths_prompts(category, entity, character,
action=action_obj, extra_positive=extra_positive)
@@ -321,7 +321,7 @@ def register_routes(app):
return {'status': 'queued', 'job_id': job['id']}
except Exception as e:
print(f"[Strengths] generate error: {e}")
logger.exception("Strengths generate error: %s", e)
return {'error': str(e)}, 500
@app.route('/strengths/<category>/<path:slug>/list')

View File

@@ -4,23 +4,24 @@ import re
import random
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from flask import render_template, request, redirect, url_for, flash, session
from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Style, Detailer, Settings
from models import db, Character, Style, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
from services.sync import sync_styles
from services.file_io import get_available_loras
from services.llm import load_prompt, call_llm
from utils import allowed_file, _WARDROBE_KEYS
from routes.shared import register_common_routes
from utils import _WARDROBE_KEYS
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'styles')
def _build_style_workflow(style_obj, character=None, selected_fields=None, fixed_seed=None, extra_positive=None, extra_negative=None):
"""Build and return a prepared ComfyUI workflow dict for a style generation."""
@@ -188,40 +189,11 @@ def register_routes(app):
return redirect(url_for('style_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('styles/edit.html', style=style, loras=loras)
@app.route('/style/<path:slug>/upload', methods=['POST'])
def upload_style_image(slug):
style = Style.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):
# Create style subfolder
style_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{slug}")
os.makedirs(style_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(style_folder, filename)
file.save(file_path)
# Store relative path in DB
style.image_path = f"styles/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('style_detail', slug=slug))
@app.route('/style/<path:slug>/generate', methods=['POST'])
def generate_style_image(slug):
style_obj = Style.query.filter_by(slug=slug).first_or_404()
@@ -269,59 +241,12 @@ def register_routes(app):
return redirect(url_for('style_detail', slug=slug))
except Exception as e:
print(f"Generation error: {e}")
logger.exception("Generation error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error during generation: {str(e)}")
return redirect(url_for('style_detail', slug=slug))
@app.route('/style/<path:slug>/save_defaults', methods=['POST'])
def save_style_defaults(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
style.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this style!')
return redirect(url_for('style_detail', slug=slug))
@app.route('/style/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_style_cover_from_preview(slug):
style = Style.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(app.config['UPLOAD_FOLDER'], preview_path)):
style.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('style_detail', slug=slug))
@app.route('/get_missing_styles')
def get_missing_styles():
missing = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).order_by(Style.name).all()
return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]}
@app.route('/get_missing_detailers')
def get_missing_detailers():
missing = Detailer.query.filter((Detailer.image_path == None) | (Detailer.image_path == '')).order_by(Detailer.name).all()
return {'missing': [{'slug': d.slug, 'name': d.name} for d in missing]}
@app.route('/clear_all_detailer_covers', methods=['POST'])
def clear_all_detailer_covers():
detailers = Detailer.query.all()
for detailer in detailers:
detailer.image_path = None
db.session.commit()
return {'success': True}
@app.route('/clear_all_style_covers', methods=['POST'])
def clear_all_style_covers():
styles = Style.query.all()
for style in styles:
style.image_path = None
db.session.commit()
return {'success': True}
@app.route('/styles/generate_missing', methods=['POST'])
def generate_missing_styles():
missing = Style.query.filter(
@@ -347,7 +272,7 @@ def register_routes(app):
_make_finalize('styles', style_obj.slug, Style))
enqueued += 1
except Exception as e:
print(f"Error queuing cover generation for style {style_obj.name}: {e}")
logger.exception("Error queuing cover generation for style %s: %s", style_obj.name, e)
flash(f"Queued {enqueued} style cover image generation job{'s' if enqueued != 1 else ''}.")
return redirect(url_for('styles_index'))
@@ -511,73 +436,9 @@ def register_routes(app):
flash('Style created successfully!')
return redirect(url_for('style_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create style: {e}")
return render_template('styles/create.html', form_data=form_data)
return render_template('styles/create.html', form_data=form_data)
@app.route('/style/<path:slug>/clone', methods=['POST'])
def clone_style(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
base_id = style.style_id
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(app.config['STYLES_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
new_data = style.data.copy()
new_data['style_id'] = new_id
new_data['style_name'] = f"{style.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)
new_style = Style(
style_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['style_name'], data=new_data
)
db.session.add(new_style)
db.session.commit()
flash(f'Style cloned as "{new_id}"!')
return redirect(url_for('style_detail', slug=new_slug))
@app.route('/style/<path:slug>/save_json', methods=['POST'])
def save_style_json(slug):
style = Style.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
style.data = new_data
flag_modified(style, 'data')
db.session.commit()
if style.filename:
file_path = os.path.join(app.config['STYLES_DIR'], style.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/style/<path:slug>/favourite', methods=['POST'])
def toggle_style_favourite(slug):
style_obj = Style.query.filter_by(slug=slug).first_or_404()
style_obj.is_favourite = not style_obj.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': style_obj.is_favourite}
return redirect(url_for('style_detail', slug=slug))