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:
parent
8909641ff3
commit
532d666fa8
12
app.py
12
app.py
@ -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')
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user