Files
Code_of_Conquest/public_web/app/views/character_views.py
Phillip Tarrant 61a42d3a77 feat(api,web): tier-based session limits and daily turn usage display
Backend Changes:
- Add tier-based max_sessions config (free: 1, basic: 2, premium: 3, elite: 5)
- Add DELETE /api/v1/sessions/{id} endpoint for hard session deletion
- Cascade delete chat messages when session is deleted
- Add GET /api/v1/usage endpoint for daily turn limit info
- Replace hardcoded TIER_LIMITS with config-based ai_calls_per_day
- Handle unlimited (-1) tier in rate limiter service

Frontend Changes:
- Add inline session delete buttons with HTMX on character list
- Add usage_display.html component showing remaining daily turns
- Display usage indicator on character list and game play pages
- Page refresh after session deletion to update UI state

Documentation:
- Update API_REFERENCE.md with new endpoints and tier limits
- Update API_TESTING.md with session endpoint examples
- Update SESSION_MANAGEMENT.md with tier-based limits

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 10:00:45 -06:00

755 lines
24 KiB
Python

"""
Character Views Blueprint
This module provides web UI routes for character management:
- Character creation flow (4 steps)
- Character list view
- Character detail view
- Skill tree view
All views require authentication and render HTML templates with HTMX.
"""
import time
from flask import Blueprint, render_template, request, session, redirect, url_for, flash
from app.utils.auth import require_auth_web, get_current_user
from app.utils.logging import get_logger
from app.utils.api_client import (
get_api_client,
APIError,
APINotFoundError,
APITimeoutError
)
# Initialize logger
logger = get_logger(__file__)
# Create blueprint
character_bp = Blueprint('character_views', __name__, url_prefix='/characters')
# Cache settings
_CACHE_TTL = 300 # 5 minutes in seconds
_origins_cache = {'data': None, 'timestamp': 0}
_classes_cache = {'data': None, 'timestamp': 0}
# Wizard session timeout (1 hour)
_WIZARD_TIMEOUT = 3600
def _get_cached_origins(api_client):
"""
Get origins list with caching.
Returns cached data if available and fresh, otherwise fetches from API.
Args:
api_client: API client instance.
Returns:
List of origin dictionaries.
"""
global _origins_cache
current_time = time.time()
if _origins_cache['data'] and (current_time - _origins_cache['timestamp']) < _CACHE_TTL:
logger.debug("Using cached origins")
return _origins_cache['data']
# Fetch from API
response = api_client.get("/api/v1/origins")
origins = response.get('result', {}).get('origins', [])
# Update cache
_origins_cache = {'data': origins, 'timestamp': current_time}
logger.debug("Cached origins", count=len(origins))
return origins
def _get_cached_classes(api_client):
"""
Get classes list with caching.
Returns cached data if available and fresh, otherwise fetches from API.
Args:
api_client: API client instance.
Returns:
List of class dictionaries.
"""
global _classes_cache
current_time = time.time()
if _classes_cache['data'] and (current_time - _classes_cache['timestamp']) < _CACHE_TTL:
logger.debug("Using cached classes")
return _classes_cache['data']
# Fetch from API
response = api_client.get("/api/v1/classes")
classes = response.get('result', {}).get('classes', [])
# Update cache
_classes_cache = {'data': classes, 'timestamp': current_time}
logger.debug("Cached classes", count=len(classes))
return classes
def _cleanup_stale_wizard_session():
"""
Clean up stale character creation wizard session data.
Called at the start of character creation to remove abandoned wizard data.
"""
if 'character_creation' in session:
creation_data = session['character_creation']
started_at = creation_data.get('started_at', 0)
current_time = time.time()
if (current_time - started_at) > _WIZARD_TIMEOUT:
logger.info("Cleaning up stale wizard session", age_seconds=int(current_time - started_at))
session.pop('character_creation', None)
# ===== CHARACTER CREATION FLOW =====
@character_bp.route('/create/origin', methods=['GET', 'POST'])
@require_auth_web
def create_origin():
"""
Step 1: Origin Selection
GET: Display all available origins for user to choose from
POST: Save selected origin to session and redirect to class selection
"""
user = get_current_user()
api_client = get_api_client()
# Clean up any stale wizard session from previous attempts
_cleanup_stale_wizard_session()
logger.info("Character creation started - origin selection", user_id=user.get('id'))
if request.method == 'POST':
# Get selected origin from form
origin_id = request.form.get('origin_id')
if not origin_id:
flash('Please select an origin story.', 'error')
return redirect(url_for('character_views.create_origin'))
# Validate origin exists using cached data
try:
origins = _get_cached_origins(api_client)
# Check if selected origin_id is valid
valid_origin = None
for origin in origins:
if origin.get('id') == origin_id:
valid_origin = origin
break
if not valid_origin:
flash('Invalid origin selected.', 'error')
return redirect(url_for('character_views.create_origin'))
except APIError as e:
flash(f'Error validating origin: {e.message}', 'error')
return redirect(url_for('character_views.create_origin'))
# Store in session with timestamp
session['character_creation'] = {
'origin_id': origin_id,
'step': 1,
'started_at': time.time()
}
logger.info("Origin selected", user_id=user.get('id'), origin_id=origin_id)
return redirect(url_for('character_views.create_class'))
# GET: Display origin selection using cached data
try:
origins = _get_cached_origins(api_client)
except APIError as e:
logger.error("Failed to load origins", error=str(e))
flash('Failed to load origins. Please try again.', 'error')
origins = []
return render_template(
'character/create_origin.html',
origins=origins,
current_step=1
)
@character_bp.route('/create/class', methods=['GET', 'POST'])
@require_auth_web
def create_class():
"""
Step 2: Class Selection
GET: Display all available classes for user to choose from
POST: Save selected class to session and redirect to customization
"""
user = get_current_user()
api_client = get_api_client()
# Ensure we have origin selected first
if 'character_creation' not in session or session['character_creation'].get('step', 0) < 1:
flash('Please start from the beginning.', 'warning')
return redirect(url_for('character_views.create_origin'))
if request.method == 'POST':
# Get selected class from form
class_id = request.form.get('class_id')
if not class_id:
flash('Please select a class.', 'error')
return redirect(url_for('character_views.create_class'))
# Validate class exists using cached data
try:
classes = _get_cached_classes(api_client)
# Check if selected class_id is valid
valid_class = None
for player_class in classes:
if player_class.get('class_id') == class_id:
valid_class = player_class
break
if not valid_class:
flash('Invalid class selected.', 'error')
return redirect(url_for('character_views.create_class'))
except APIError as e:
flash(f'Error validating class: {e.message}', 'error')
return redirect(url_for('character_views.create_class'))
# Store in session
session['character_creation']['class_id'] = class_id
session['character_creation']['step'] = 2
session.modified = True
logger.info("Class selected", user_id=user.get('id'), class_id=class_id)
return redirect(url_for('character_views.create_customize'))
# GET: Display class selection using cached data
try:
classes = _get_cached_classes(api_client)
except APIError as e:
logger.error("Failed to load classes", error=str(e))
flash('Failed to load classes. Please try again.', 'error')
classes = []
return render_template(
'character/create_class.html',
classes=classes,
current_step=2
)
@character_bp.route('/create/customize', methods=['GET', 'POST'])
@require_auth_web
def create_customize():
"""
Step 3: Customize Character
GET: Display form to enter character name
POST: Save character name to session and redirect to confirmation
"""
user = get_current_user()
api_client = get_api_client()
# Ensure we have both origin and class selected
if 'character_creation' not in session or session['character_creation'].get('step', 0) < 2:
flash('Please complete previous steps first.', 'warning')
return redirect(url_for('character_views.create_origin'))
if request.method == 'POST':
# Get character name from form
character_name = request.form.get('name', '').strip()
if not character_name:
flash('Please enter a character name.', 'error')
return redirect(url_for('character_views.create_customize'))
# Validate name length (3-30 characters)
if len(character_name) < 3 or len(character_name) > 30:
flash('Character name must be between 3 and 30 characters.', 'error')
return redirect(url_for('character_views.create_customize'))
# Store in session
session['character_creation']['name'] = character_name
session['character_creation']['step'] = 3
session.modified = True
logger.info("Character name entered", user_id=user.get('id'), name=character_name)
return redirect(url_for('character_views.create_confirm'))
# GET: Display customization form
creation_data = session.get('character_creation', {})
# Load origin and class for display using cached data
origin = None
player_class = None
try:
# Find origin in cached list
if creation_data.get('origin_id'):
origins = _get_cached_origins(api_client)
for o in origins:
if o.get('id') == creation_data['origin_id']:
origin = o
break
# Fetch class - can use single endpoint
if creation_data.get('class_id'):
response = api_client.get(f"/api/v1/classes/{creation_data['class_id']}")
player_class = response.get('result')
except APIError as e:
logger.error("Failed to load origin/class data", error=str(e))
return render_template(
'character/create_customize.html',
origin=origin,
player_class=player_class,
current_step=3
)
@character_bp.route('/create/confirm', methods=['GET', 'POST'])
@require_auth_web
def create_confirm():
"""
Step 4: Confirm and Create Character
GET: Display character summary for final confirmation
POST: Create the character via API and redirect to character list
"""
user = get_current_user()
api_client = get_api_client()
# Ensure we have all data
if 'character_creation' not in session or session['character_creation'].get('step', 0) < 3:
flash('Please complete all steps first.', 'warning')
return redirect(url_for('character_views.create_origin'))
creation_data = session.get('character_creation', {})
if request.method == 'POST':
# Create the character via API
try:
response = api_client.post("/api/v1/characters", data={
'name': creation_data['name'],
'class_id': creation_data['class_id'],
'origin_id': creation_data['origin_id']
})
character = response.get('result', {})
# Clear session data
session.pop('character_creation', None)
logger.info(
"Character created successfully",
user_id=user.get('id'),
character_id=character.get('id'),
character_name=character.get('name')
)
flash(f'Character "{character.get("name")}" created successfully!', 'success')
return redirect(url_for('character_views.list_characters'))
except APIError as e:
if 'limit' in e.message.lower():
logger.warning("Character limit exceeded", user_id=user.get('id'), error=str(e))
flash(e.message, 'error')
return redirect(url_for('character_views.list_characters'))
logger.error(
"Failed to create character",
user_id=user.get('id'),
error=str(e)
)
flash('An error occurred while creating your character. Please try again.', 'error')
return redirect(url_for('character_views.create_origin'))
# GET: Display confirmation page using cached data
origin = None
player_class = None
try:
# Find origin in cached list
origins = _get_cached_origins(api_client)
for o in origins:
if o.get('id') == creation_data['origin_id']:
origin = o
break
# Fetch class - can use single endpoint
response = api_client.get(f"/api/v1/classes/{creation_data['class_id']}")
player_class = response.get('result')
except APIError as e:
logger.error("Failed to load origin/class data", error=str(e))
return render_template(
'character/create_confirm.html',
character_name=creation_data['name'],
origin=origin,
player_class=player_class,
current_step=4
)
# ===== CHARACTER MANAGEMENT =====
@character_bp.route('/')
@require_auth_web
def list_characters():
"""
Display list of all characters for the current user.
Also fetches active sessions and maps them to characters.
"""
user = get_current_user()
api_client = get_api_client()
try:
response = api_client.get("/api/v1/characters")
result = response.get('result', {})
# API returns characters in nested structure
characters = result.get('characters', [])
api_tier = result.get('tier', 'free')
api_limit = result.get('limit', 1)
current_tier = api_tier
max_characters = api_limit
can_create = len(characters) < max_characters
# Fetch all user sessions and map to characters
sessions_by_character = {}
try:
sessions_response = api_client.get("/api/v1/sessions")
sessions = sessions_response.get('result', [])
# Handle case where result is a list or a dict with sessions key
if isinstance(sessions, dict):
sessions = sessions.get('sessions', [])
for sess in sessions:
char_id = sess.get('character_id')
if char_id:
if char_id not in sessions_by_character:
sessions_by_character[char_id] = []
sessions_by_character[char_id].append(sess)
except (APIError, APINotFoundError) as e:
# Sessions endpoint may not exist or have issues
logger.debug("Could not fetch sessions", error=str(e))
# Attach sessions to each character
for character in characters:
char_id = character.get('character_id')
character['sessions'] = sessions_by_character.get(char_id, [])
# Fetch usage info for daily turn limits
usage_info = {}
try:
usage_response = api_client.get("/api/v1/usage")
usage_info = usage_response.get('result', {})
except (APIError, APINotFoundError) as e:
logger.debug("Could not fetch usage info", error=str(e))
logger.info(
"Characters listed",
user_id=user.get('id'),
count=len(characters),
tier=current_tier
)
return render_template(
'character/list.html',
characters=characters,
current_tier=current_tier,
max_characters=max_characters,
can_create=can_create,
# Usage display variables
remaining=usage_info.get('remaining', 0),
daily_limit=usage_info.get('daily_limit', 0),
is_limited=usage_info.get('is_limited', False),
is_unlimited=usage_info.get('is_unlimited', False),
reset_time=usage_info.get('reset_time', '')
)
except APITimeoutError:
logger.error("API timeout while listing characters", user_id=user.get('id'))
flash('Request timed out. Please try again.', 'error')
return render_template(
'character/list.html',
characters=[],
can_create=False,
current_tier='free',
max_characters=1,
remaining=0,
daily_limit=0,
is_limited=False,
is_unlimited=False,
reset_time=''
)
except APIError as e:
logger.error("Failed to list characters", user_id=user.get('id'), error=str(e))
flash('An error occurred while loading your characters.', 'error')
return render_template(
'character/list.html',
characters=[],
can_create=False,
current_tier='free',
max_characters=1,
remaining=0,
daily_limit=0,
is_limited=False,
is_unlimited=False,
reset_time=''
)
@character_bp.route('/<character_id>')
@require_auth_web
def view_character(character_id: str):
"""
Display detailed view of a specific character.
Args:
character_id: ID of the character to view
"""
user = get_current_user()
api_client = get_api_client()
try:
response = api_client.get(f"/api/v1/characters/{character_id}")
character = response.get('result')
logger.info(
"Character viewed",
user_id=user.get('id'),
character_id=character_id
)
return render_template('character/detail.html', character=character)
except APINotFoundError:
logger.warning("Character not found", user_id=user.get('id'), character_id=character_id)
flash('Character not found.', 'error')
return redirect(url_for('character_views.list_characters'))
except APIError as e:
logger.error(
"Failed to view character",
user_id=user.get('id'),
character_id=character_id,
error=str(e)
)
flash('An error occurred while loading the character.', 'error')
return redirect(url_for('character_views.list_characters'))
@character_bp.route('/<character_id>/delete', methods=['POST'])
@require_auth_web
def delete_character(character_id: str):
"""
Delete a character (soft delete - marks as inactive).
Args:
character_id: ID of the character to delete
"""
user = get_current_user()
api_client = get_api_client()
try:
api_client.delete(f"/api/v1/characters/{character_id}")
logger.info("Character deleted", user_id=user.get('id'), character_id=character_id)
flash('Character deleted successfully.', 'success')
except APINotFoundError:
logger.warning("Character not found for deletion", user_id=user.get('id'), character_id=character_id)
flash('Character not found.', 'error')
except APIError as e:
logger.error(
"Failed to delete character",
user_id=user.get('id'),
character_id=character_id,
error=str(e)
)
flash('An error occurred while deleting the character.', 'error')
return redirect(url_for('character_views.list_characters'))
# ===== SESSION MANAGEMENT =====
@character_bp.route('/<character_id>/play', methods=['POST'])
@require_auth_web
def create_session(character_id: str):
"""
Create a new game session for a character and redirect to play screen.
Args:
character_id: ID of the character to start a session with
"""
user = get_current_user()
api_client = get_api_client()
try:
# Create new session via API
response = api_client.post("/api/v1/sessions", data={
'character_id': character_id
})
result = response.get('result', {})
session_id = result.get('session_id')
if not session_id:
flash('Failed to create session - no session ID returned.', 'error')
return redirect(url_for('character_views.list_characters'))
logger.info(
"Session created",
user_id=user.get('id'),
character_id=character_id,
session_id=session_id
)
# Redirect to play screen
return redirect(url_for('game.play_session', session_id=session_id))
except APINotFoundError:
logger.warning("Character not found for session creation", user_id=user.get('id'), character_id=character_id)
flash('Character not found.', 'error')
return redirect(url_for('character_views.list_characters'))
except APIError as e:
logger.error(
"Failed to create session",
user_id=user.get('id'),
character_id=character_id,
error=str(e)
)
# Check for specific errors (session limit, etc.)
if 'limit' in str(e).lower():
flash(f'Session limit reached: {e.message}', 'error')
else:
flash(f'Failed to create session: {e.message}', 'error')
return redirect(url_for('character_views.list_characters'))
@character_bp.route('/sessions/<session_id>/delete', methods=['DELETE'])
@require_auth_web
def delete_session(session_id: str):
"""
Delete a game session via HTMX.
This permanently removes the session from the database.
Returns a response that triggers page refresh to update UI properly
(handles "Continue Playing" button visibility).
Args:
session_id: ID of the session to delete
"""
from flask import make_response
user = get_current_user()
api_client = get_api_client()
try:
api_client.delete(f"/api/v1/sessions/{session_id}")
logger.info(
"Session deleted via web UI",
user_id=user.get('id'),
session_id=session_id
)
# Return empty response with HX-Refresh to reload the page
# This ensures "Continue Playing" button visibility is correct
response = make_response('', 200)
response.headers['HX-Refresh'] = 'true'
return response
except APINotFoundError:
logger.warning(
"Session not found for deletion",
user_id=user.get('id'),
session_id=session_id
)
# Return error message that HTMX can display
return '<span class="error-message">Session not found</span>', 404
except APIError as e:
logger.error(
"Failed to delete session",
user_id=user.get('id'),
session_id=session_id,
error=str(e)
)
return '<span class="error-message">Failed to delete session</span>', 500
@character_bp.route('/<character_id>/skills')
@require_auth_web
def view_skills(character_id: str):
"""
Display skill tree view for a specific character.
Args:
character_id: ID of the character to view skills for
"""
user = get_current_user()
api_client = get_api_client()
try:
# Get character data
response = api_client.get(f"/api/v1/characters/{character_id}")
character = response.get('result')
# Load class data to get skill trees
class_id = character.get('class_id')
player_class = None
if class_id:
response = api_client.get(f"/api/v1/classes/{class_id}")
player_class = response.get('result')
logger.info(
"Skill tree viewed",
user_id=user.get('id'),
character_id=character_id
)
return render_template(
'character/skills.html',
character=character,
player_class=player_class
)
except APINotFoundError:
logger.warning("Character not found for skills view", user_id=user.get('id'), character_id=character_id)
flash('Character not found.', 'error')
return redirect(url_for('character_views.list_characters'))
except APIError as e:
logger.error(
"Failed to view skills",
user_id=user.get('id'),
character_id=character_id,
error=str(e)
)
flash('An error occurred while loading the skill tree.', 'error')
return redirect(url_for('character_views.list_characters'))