refactor: Move admin_ai_usage routes to admin blueprint

- Added admin_ai_usage and admin_ai_usage_user to routes_analytics.py
- Updated templates to use full blueprint names (admin.admin_ai_usage)
- Added aliases for backward compatibility
- Commented old routes in app.py with _old_ prefix

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-31 10:43:28 +01:00
parent 8909641ff3
commit 532d666fa8
6 changed files with 365 additions and 18 deletions

12
app.py
View File

@ -4964,9 +4964,9 @@ TWOJE MOŻLIWOŚCI:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/admin/ai-usage')
@login_required
def admin_ai_usage():
# @app.route('/admin/ai-usage') # MOVED TO admin.admin_ai_usage
# @login_required
def _old_admin_ai_usage():
"""Admin dashboard for AI (Gemini) API usage monitoring"""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
@ -5198,9 +5198,9 @@ def admin_ai_usage():
db.close()
@app.route('/admin/ai-usage/user/<int:user_id>')
@login_required
def admin_ai_usage_user(user_id):
# @app.route('/admin/ai-usage/user/<int:user_id>') # MOVED TO admin.admin_ai_usage_user
# @login_required
def _old_admin_ai_usage_user(user_id):
"""Detailed AI usage for a specific user"""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')

View File

@ -264,6 +264,9 @@ def register_blueprints(app):
# Analytics (Phase 6.2b)
'admin_analytics': 'admin.admin_analytics',
'admin_analytics_export': 'admin.admin_analytics_export',
# AI Usage Monitoring (Phase 6.2b)
'admin_ai_usage': 'admin.admin_ai_usage',
'admin_ai_usage_user': 'admin.admin_ai_usage_user',
})
logger.info("Created admin endpoint aliases")
except ImportError as e:

View File

@ -18,7 +18,7 @@ from sqlalchemy.orm import joinedload
from . import bp
from database import (
SessionLocal, User, UserSession, PageView, SearchQuery,
ConversionEvent, JSError
ConversionEvent, JSError, Company, AIUsageLog, AIUsageDaily
)
logger = logging.getLogger(__name__)
@ -365,3 +365,347 @@ def admin_analytics_export():
return redirect(url_for('admin.admin_analytics'))
finally:
db.close()
# ============================================================
# AI USAGE DASHBOARD
# ============================================================
@bp.route('/ai-usage')
@login_required
def admin_ai_usage():
"""Admin dashboard for AI (Gemini) API usage monitoring"""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('dashboard'))
from datetime import datetime, timedelta
# Get period filter from query params
period = request.args.get('period', 'month') # day, week, month, all
db = SessionLocal()
try:
now = datetime.now()
today = now.date()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
day_ago = now - timedelta(hours=24)
# Determine date filter based on period
period_labels = {
'day': ('Dzisiaj', today),
'week': ('Ten tydzień', week_ago),
'month': ('Ten miesiąc', month_ago),
'all': ('Od początku', None)
}
period_label, period_start = period_labels.get(period, period_labels['month'])
# Base query filter for period
def period_filter(query):
if period_start:
return query.filter(func.date(AIUsageLog.created_at) >= period_start)
return query
# Today's stats (always show)
today_stats = db.query(
func.count(AIUsageLog.id).label('requests'),
func.coalesce(func.sum(AIUsageLog.tokens_input), 0).label('tokens_input'),
func.coalesce(func.sum(AIUsageLog.tokens_output), 0).label('tokens_output'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents')
).filter(
func.date(AIUsageLog.created_at) == today
).first()
# Week stats
week_requests = db.query(func.count(AIUsageLog.id)).filter(
func.date(AIUsageLog.created_at) >= week_ago
).scalar() or 0
# Month stats
month_stats = db.query(
func.count(AIUsageLog.id).label('requests'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents')
).filter(
func.date(AIUsageLog.created_at) >= month_ago
).first()
# All-time stats
all_time_stats = db.query(
func.count(AIUsageLog.id).label('requests'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents')
).first()
# Error rate (last 24h)
last_24h_total = db.query(func.count(AIUsageLog.id)).filter(
AIUsageLog.created_at >= day_ago
).scalar() or 0
last_24h_errors = db.query(func.count(AIUsageLog.id)).filter(
AIUsageLog.created_at >= day_ago,
AIUsageLog.success == False
).scalar() or 0
error_rate = (last_24h_errors / last_24h_total * 100) if last_24h_total > 0 else 0
# Average response time (last 24h)
avg_response_time = db.query(func.avg(AIUsageLog.response_time_ms)).filter(
AIUsageLog.created_at >= day_ago,
AIUsageLog.success == True
).scalar() or 0
# Usage by type (filtered by period)
type_query = db.query(
AIUsageLog.request_type,
func.count(AIUsageLog.id).label('count')
)
type_query = period_filter(type_query)
type_stats = type_query.group_by(AIUsageLog.request_type).order_by(desc('count')).all()
# Calculate percentages for type breakdown
total_type_count = sum(t.count for t in type_stats) if type_stats else 0
type_labels = {
'ai_chat': ('Chat AI', 'chat'),
'zopk_news_evaluation': ('Ocena newsów ZOP Kaszubia', 'news'),
'ai_user_parse': ('Tworzenie user', 'user'),
'gbp_audit_ai': ('Audyt GBP', 'image'),
'general': ('Ogólne', 'other')
}
usage_by_type = []
for t in type_stats:
label, css_class = type_labels.get(t.request_type, (t.request_type, 'other'))
percentage = (t.count / total_type_count * 100) if total_type_count > 0 else 0
usage_by_type.append({
'type': t.request_type,
'type_label': label,
'type_class': css_class,
'count': t.count,
'percentage': round(percentage, 1)
})
# User statistics (filtered by period)
user_query = db.query(
User.id,
User.name.label('user_name'),
User.email,
Company.name.label('company_name'),
func.count(AIUsageLog.id).label('requests'),
func.coalesce(func.sum(AIUsageLog.tokens_input), 0).label('tokens_input'),
func.coalesce(func.sum(AIUsageLog.tokens_output), 0).label('tokens_output'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents')
).join(
AIUsageLog, AIUsageLog.user_id == User.id
).outerjoin(
Company, User.company_id == Company.id
)
user_query = period_filter(user_query)
user_stats = user_query.group_by(
User.id, User.name, User.email, Company.name
).order_by(desc('cost_cents')).limit(20).all()
# Format user stats
user_rankings = []
for u in user_stats:
user_rankings.append({
'id': u.id,
'name': u.user_name or u.email,
'email': u.email,
'company': u.company_name or '-',
'requests': u.requests,
'tokens': int(u.tokens_input) + int(u.tokens_output),
'cost_cents': float(u.cost_cents or 0),
'cost_usd': float(u.cost_cents or 0) / 100
})
# Company statistics (filtered by period)
company_query = db.query(
Company.id,
Company.name,
func.count(AIUsageLog.id).label('requests'),
func.count(func.distinct(AIUsageLog.user_id)).label('unique_users'),
func.coalesce(func.sum(AIUsageLog.tokens_input), 0).label('tokens_input'),
func.coalesce(func.sum(AIUsageLog.tokens_output), 0).label('tokens_output'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents')
).join(
User, User.company_id == Company.id
).join(
AIUsageLog, AIUsageLog.user_id == User.id
)
company_query = period_filter(company_query)
company_stats = company_query.group_by(
Company.id, Company.name
).order_by(desc('cost_cents')).limit(20).all()
# Format company stats
company_rankings = []
for c in company_stats:
company_rankings.append({
'id': c.id,
'name': c.name,
'requests': c.requests,
'unique_users': c.unique_users,
'tokens': int(c.tokens_input) + int(c.tokens_output),
'cost_cents': float(c.cost_cents or 0),
'cost_usd': float(c.cost_cents or 0) / 100
})
# Recent logs with user info
recent_logs = db.query(AIUsageLog).order_by(desc(AIUsageLog.created_at)).limit(20).all()
# Enrich recent logs with user names
for log in recent_logs:
label, _ = type_labels.get(log.request_type, (log.request_type, 'other'))
log.type_label = label
if log.user_id:
user = db.query(User).filter_by(id=log.user_id).first()
if user:
log.user_name = user.name or user.email
else:
log.user_name = None
else:
log.user_name = None
# Daily history (last 14 days)
daily_history = db.query(AIUsageDaily).filter(
AIUsageDaily.date >= today - timedelta(days=14)
).order_by(desc(AIUsageDaily.date)).all()
stats = {
'today_requests': today_stats.requests or 0,
'today_tokens_input': int(today_stats.tokens_input) or 0,
'today_tokens_output': int(today_stats.tokens_output) or 0,
'today_cost': float(today_stats.cost_cents or 0) / 100,
'week_requests': week_requests,
'month_requests': month_stats.requests or 0,
'month_cost': float(month_stats.cost_cents or 0) / 100,
'all_requests': all_time_stats.requests or 0,
'all_cost': float(all_time_stats.cost_cents or 0) / 100,
'error_rate': error_rate,
'avg_response_time': int(avg_response_time)
}
return render_template(
'admin/ai_usage_dashboard.html',
stats=stats,
usage_by_type=usage_by_type,
recent_logs=recent_logs,
daily_history=daily_history,
user_rankings=user_rankings,
company_rankings=company_rankings,
current_period=period,
period_label=period_label
)
finally:
db.close()
@bp.route('/ai-usage/user/<int:user_id>')
@login_required
def admin_ai_usage_user(user_id):
"""Detailed AI usage for a specific user"""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('dashboard'))
db = SessionLocal()
try:
# Get user info
user = db.query(User).filter_by(id=user_id).first()
if not user:
flash('Użytkownik nie istnieje.', 'error')
return redirect(url_for('admin.admin_ai_usage'))
company = None
if user.company_id:
company = db.query(Company).filter_by(id=user.company_id).first()
# Get overall stats for this user
stats = db.query(
func.count(AIUsageLog.id).label('total_requests'),
func.coalesce(func.sum(AIUsageLog.tokens_input), 0).label('tokens_input'),
func.coalesce(func.sum(AIUsageLog.tokens_output), 0).label('tokens_output'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents'),
func.count(func.nullif(AIUsageLog.success, True)).label('errors')
).filter(AIUsageLog.user_id == user_id).first()
# Usage by type
type_labels = {
'ai_chat': 'Chat AI',
'zopk_news_evaluation': 'Ocena newsów ZOP Kaszubia',
'ai_user_parse': 'Tworzenie user',
'gbp_audit_ai': 'Audyt GBP',
'general': 'Ogólne'
}
type_stats = db.query(
AIUsageLog.request_type,
func.count(AIUsageLog.id).label('count'),
func.coalesce(func.sum(AIUsageLog.tokens_input + AIUsageLog.tokens_output), 0).label('tokens'),
func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents')
).filter(
AIUsageLog.user_id == user_id
).group_by(AIUsageLog.request_type).order_by(desc('count')).all()
# Calculate total for percentages
total_type_count = sum(t.count for t in type_stats) if type_stats else 1
type_classes = {
'ai_chat': 'chat',
'zopk_news_evaluation': 'news_evaluation',
'ai_user_parse': 'user_creation',
'gbp_audit_ai': 'image_analysis',
'general': 'other'
}
usage_by_type = []
for t in type_stats:
usage_by_type.append({
'type': t.request_type,
'type_label': type_labels.get(t.request_type, t.request_type),
'type_class': type_classes.get(t.request_type, 'other'),
'count': t.count,
'tokens': int(t.tokens),
'cost_usd': float(t.cost_cents) / 100,
'percentage': round(t.count / total_type_count * 100, 1) if total_type_count > 0 else 0
})
# Get all requests for this user (paginated)
page = request.args.get('page', 1, type=int)
per_page = 50
requests_query = db.query(AIUsageLog).filter(
AIUsageLog.user_id == user_id
).order_by(desc(AIUsageLog.created_at))
total_requests = requests_query.count()
total_pages = (total_requests + per_page - 1) // per_page
logs = requests_query.offset((page - 1) * per_page).limit(per_page).all()
# Enrich logs with type labels and cost
for log in logs:
log.type_label = type_labels.get(log.request_type, log.request_type)
log.cost_usd = float(log.cost_cents or 0) / 100
user_stats = {
'total_requests': stats.total_requests or 0,
'tokens_total': int(stats.tokens_input or 0) + int(stats.tokens_output or 0),
'tokens_input': int(stats.tokens_input or 0),
'tokens_output': int(stats.tokens_output or 0),
'cost_usd': float(stats.cost_cents or 0) / 100,
'errors': stats.errors or 0
}
return render_template(
'admin/ai_usage_user.html',
user=user,
company=company,
stats=user_stats,
usage_by_type=usage_by_type,
logs=logs,
page=page,
total_pages=total_pages,
total_requests=total_requests
)
finally:
db.close()

View File

@ -429,10 +429,10 @@
<!-- Period Filters -->
<div class="period-filters">
<span class="text-muted" style="padding: var(--spacing-sm) 0;">Okres:</span>
<a href="{{ url_for('admin_ai_usage', period='day') }}" class="period-btn {% if current_period == 'day' %}active{% endif %}">Dzisiaj</a>
<a href="{{ url_for('admin_ai_usage', period='week') }}" class="period-btn {% if current_period == 'week' %}active{% endif %}">Tydzień</a>
<a href="{{ url_for('admin_ai_usage', period='month') }}" class="period-btn {% if current_period == 'month' %}active{% endif %}">Miesiąc</a>
<a href="{{ url_for('admin_ai_usage', period='all') }}" class="period-btn {% if current_period == 'all' %}active{% endif %}">Od początku</a>
<a href="{{ url_for('admin.admin_ai_usage', period='day') }}" class="period-btn {% if current_period == 'day' %}active{% endif %}">Dzisiaj</a>
<a href="{{ url_for('admin.admin_ai_usage', period='week') }}" class="period-btn {% if current_period == 'week' %}active{% endif %}">Tydzień</a>
<a href="{{ url_for('admin.admin_ai_usage', period='month') }}" class="period-btn {% if current_period == 'month' %}active{% endif %}">Miesiąc</a>
<a href="{{ url_for('admin.admin_ai_usage', period='all') }}" class="period-btn {% if current_period == 'all' %}active{% endif %}">Od początku</a>
</div>
<!-- Main Stats -->
@ -524,7 +524,7 @@
{{ loop.index }}
</td>
<td>
<a href="{{ url_for('admin_ai_usage_user', user_id=user.id) }}" class="user-info-link">
<a href="{{ url_for('admin.admin_ai_usage_user', user_id=user.id) }}" class="user-info-link">
<div class="user-info">
<span class="user-name">{{ user.name }}</span>
<span class="user-company">{{ user.company }}</span>

View File

@ -363,13 +363,13 @@
<div class="page-header">
<div>
<div class="breadcrumb">
<a href="{{ url_for('admin_ai_usage') }}">Monitoring AI</a>
<a href="{{ url_for('admin.admin_ai_usage') }}">Monitoring AI</a>
<span>/</span>
<span>Szczegoly uzytkownika</span>
</div>
<h1>Uzycie AI - {{ user.name or user.email.split('@')[0] }}</h1>
</div>
<a href="{{ url_for('admin_ai_usage') }}" class="btn btn-secondary">Powrot</a>
<a href="{{ url_for('admin.admin_ai_usage') }}" class="btn btn-secondary">Powrot</a>
</div>
<!-- User Card -->
@ -516,7 +516,7 @@
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('admin_ai_usage_user', user_id=user.id, page=page-1) }}">Poprzednia</a>
<a href="{{ url_for('admin.admin_ai_usage_user', user_id=user.id, page=page-1) }}">Poprzednia</a>
{% else %}
<span class="disabled">Poprzednia</span>
{% endif %}
@ -525,14 +525,14 @@
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="{{ url_for('admin_ai_usage_user', user_id=user.id, page=p) }}">{{ p }}</a>
<a href="{{ url_for('admin.admin_ai_usage_user', user_id=user.id, page=p) }}">{{ p }}</a>
{% elif p == page - 3 or p == page + 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('admin_ai_usage_user', user_id=user.id, page=page+1) }}">Nastepna</a>
<a href="{{ url_for('admin.admin_ai_usage_user', user_id=user.id, page=page+1) }}">Nastepna</a>
{% else %}
<span class="disabled">Nastepna</span>
{% endif %}

View File

@ -1341,7 +1341,7 @@
</svg>
NordaGPT
</a>
<a href="{{ url_for('admin_ai_usage') }}">
<a href="{{ url_for('admin.admin_ai_usage') }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>