refactor: Migrate analytics routes to blueprints

- Create new blueprints/admin/routes_analytics.py (~350 lines)
- Move admin_analytics and admin_analytics_export routes
- Update templates to use full blueprint names
- Add endpoint aliases for backward compatibility

Phase 6.2b - Analytics dashboard

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-31 10:36:23 +01:00
parent 9b9ae40932
commit 8909641ff3
7 changed files with 389 additions and 18 deletions

12
app.py
View File

@ -4127,9 +4127,9 @@ def _old_api_insights_stats():
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/admin/analytics')
@login_required
def admin_analytics():
# @app.route('/admin/analytics') # MOVED TO admin.admin_analytics
# @login_required
def _old_admin_analytics():
"""Admin dashboard for user analytics - sessions, page views, clicks"""
if not current_user.is_admin:
flash('Brak uprawnien do tej strony.', 'error')
@ -4371,9 +4371,9 @@ def admin_analytics():
db.close()
@app.route('/admin/analytics/export')
@login_required
def admin_analytics_export():
# @app.route('/admin/analytics/export') # MOVED TO admin.admin_analytics_export
# @login_required
def _old_admin_analytics_export():
"""Export analytics data as CSV"""
import csv
import io

View File

@ -261,6 +261,9 @@ def register_blueprints(app):
'api_insights_stats': 'admin.api_insights_stats',
'api_ai_learning_status': 'admin.api_ai_learning_status',
'api_chat_stats': 'admin.api_chat_stats',
# Analytics (Phase 6.2b)
'admin_analytics': 'admin.admin_analytics',
'admin_analytics_export': 'admin.admin_analytics_export',
})
logger.info("Created admin endpoint aliases")
except ImportError as e:

View File

@ -16,3 +16,4 @@ from . import routes_social # noqa: E402, F401
from . import routes_security # noqa: E402, F401
from . import routes_announcements # noqa: E402, F401
from . import routes_insights # noqa: E402, F401
from . import routes_analytics # noqa: E402, F401

View File

@ -0,0 +1,367 @@
"""
Admin Analytics Routes
=======================
User analytics dashboard - sessions, page views, clicks, conversions.
"""
import csv
import io
import logging
from datetime import date, timedelta
from flask import render_template, request, redirect, url_for, flash, Response
from flask_login import login_required, current_user
from sqlalchemy import func, desc
from sqlalchemy.orm import joinedload
from . import bp
from database import (
SessionLocal, User, UserSession, PageView, SearchQuery,
ConversionEvent, JSError
)
logger = logging.getLogger(__name__)
# ============================================================
# ANALYTICS DASHBOARD
# ============================================================
@bp.route('/analytics')
@login_required
def admin_analytics():
"""Admin dashboard for user analytics - sessions, page views, clicks"""
if not current_user.is_admin:
flash('Brak uprawnien do tej strony.', 'error')
return redirect(url_for('dashboard'))
period = request.args.get('period', 'week')
user_id = request.args.get('user_id', type=int)
# Period calculation
today = date.today()
if period == 'day':
start_date = today
elif period == 'week':
start_date = today - timedelta(days=7)
elif period == 'month':
start_date = today - timedelta(days=30)
else:
start_date = None
db = SessionLocal()
try:
# Base query for sessions in period
sessions_query = db.query(UserSession)
if start_date:
sessions_query = sessions_query.filter(
func.date(UserSession.started_at) >= start_date
)
# Overall stats
total_sessions = sessions_query.count()
unique_users = sessions_query.filter(
UserSession.user_id.isnot(None)
).distinct(UserSession.user_id).count()
total_page_views = db.query(func.sum(UserSession.page_views_count)).filter(
func.date(UserSession.started_at) >= start_date if start_date else True
).scalar() or 0
total_clicks = db.query(func.sum(UserSession.clicks_count)).filter(
func.date(UserSession.started_at) >= start_date if start_date else True
).scalar() or 0
avg_duration = db.query(func.avg(UserSession.duration_seconds)).filter(
func.date(UserSession.started_at) >= start_date if start_date else True,
UserSession.duration_seconds.isnot(None)
).scalar() or 0
stats = {
'total_sessions': total_sessions,
'unique_users': unique_users,
'total_page_views': int(total_page_views),
'total_clicks': int(total_clicks),
'avg_duration': float(avg_duration)
}
# Device breakdown
device_query = db.query(
UserSession.device_type,
func.count(UserSession.id)
)
if start_date:
device_query = device_query.filter(
func.date(UserSession.started_at) >= start_date
)
device_stats = dict(device_query.group_by(UserSession.device_type).all())
# Top users by engagement
user_query = db.query(
User.id,
User.name,
User.email,
func.count(UserSession.id).label('sessions'),
func.sum(UserSession.page_views_count).label('page_views'),
func.sum(UserSession.clicks_count).label('clicks'),
func.sum(UserSession.duration_seconds).label('total_time')
).join(UserSession, User.id == UserSession.user_id)
if start_date:
user_query = user_query.filter(
func.date(UserSession.started_at) >= start_date
)
user_rankings = user_query.group_by(User.id).order_by(
desc('page_views')
).limit(20).all()
# Popular pages
page_query = db.query(
PageView.path,
func.count(PageView.id).label('views'),
func.count(func.distinct(PageView.user_id)).label('unique_users'),
func.avg(PageView.time_on_page_seconds).label('avg_time')
)
if start_date:
page_query = page_query.filter(
func.date(PageView.viewed_at) >= start_date
)
popular_pages = page_query.group_by(PageView.path).order_by(
desc('views')
).limit(20).all()
# Recent sessions (last 50)
recent_sessions = db.query(UserSession).options(
joinedload(UserSession.user)
).order_by(UserSession.started_at.desc()).limit(50).all()
# Single user detail (if requested)
user_detail = None
if user_id:
user_obj = db.query(User).filter_by(id=user_id).first()
user_sessions = db.query(UserSession).filter_by(user_id=user_id).order_by(
UserSession.started_at.desc()
).limit(20).all()
user_pages = db.query(PageView).filter_by(user_id=user_id).order_by(
PageView.viewed_at.desc()
).limit(50).all()
user_detail = {
'user': user_obj,
'sessions': user_sessions,
'pages': user_pages
}
# Bounce rate: sesje z 1 pageview LUB czas < 10s
bounced_sessions = sessions_query.filter(
(UserSession.page_views_count <= 1) |
((UserSession.duration_seconds.isnot(None)) & (UserSession.duration_seconds < 10))
).count()
bounce_rate = round((bounced_sessions / total_sessions * 100), 1) if total_sessions > 0 else 0
# Geolokalizacja - top 10 krajów
country_query = db.query(
UserSession.country,
func.count(UserSession.id).label('count')
).filter(UserSession.country.isnot(None))
if start_date:
country_query = country_query.filter(func.date(UserSession.started_at) >= start_date)
country_stats = dict(country_query.group_by(UserSession.country).order_by(desc('count')).limit(10).all())
# UTM sources
utm_query = db.query(
UserSession.utm_source,
func.count(UserSession.id).label('count')
).filter(UserSession.utm_source.isnot(None))
if start_date:
utm_query = utm_query.filter(func.date(UserSession.started_at) >= start_date)
utm_stats = dict(utm_query.group_by(UserSession.utm_source).order_by(desc('count')).limit(10).all())
# Top wyszukiwania
search_query = db.query(
SearchQuery.query_normalized,
func.count(SearchQuery.id).label('count'),
func.avg(SearchQuery.results_count).label('avg_results')
)
if start_date:
search_query = search_query.filter(func.date(SearchQuery.searched_at) >= start_date)
top_searches = search_query.group_by(SearchQuery.query_normalized).order_by(desc('count')).limit(15).all()
# Wyszukiwania bez wyników
no_results_query = db.query(
SearchQuery.query_normalized,
func.count(SearchQuery.id).label('count')
).filter(SearchQuery.has_results == False)
if start_date:
no_results_query = no_results_query.filter(func.date(SearchQuery.searched_at) >= start_date)
searches_no_results = no_results_query.group_by(SearchQuery.query_normalized).order_by(desc('count')).limit(10).all()
# Konwersje
conversion_query = db.query(
ConversionEvent.event_type,
func.count(ConversionEvent.id).label('count')
)
if start_date:
conversion_query = conversion_query.filter(func.date(ConversionEvent.converted_at) >= start_date)
conversion_stats = dict(conversion_query.group_by(ConversionEvent.event_type).all())
# Błędy JS (agregowane)
error_query = db.query(
JSError.message,
JSError.source,
func.count(JSError.id).label('count')
)
if start_date:
error_query = error_query.filter(func.date(JSError.occurred_at) >= start_date)
js_errors = error_query.group_by(JSError.error_hash, JSError.message, JSError.source).order_by(desc('count')).limit(10).all()
# Średni scroll depth
avg_scroll = db.query(func.avg(PageView.scroll_depth_percent)).filter(
PageView.scroll_depth_percent.isnot(None)
)
if start_date:
avg_scroll = avg_scroll.filter(func.date(PageView.viewed_at) >= start_date)
avg_scroll_depth = round(avg_scroll.scalar() or 0, 1)
# Wzorce czasowe - aktywność wg godziny
hourly_query = db.query(
func.extract('hour', UserSession.started_at).label('hour'),
func.count(UserSession.id).label('count')
)
if start_date:
hourly_query = hourly_query.filter(func.date(UserSession.started_at) >= start_date)
hourly_activity = dict(hourly_query.group_by('hour').all())
# Dodaj nowe statystyki do stats
stats['bounce_rate'] = bounce_rate
stats['avg_scroll_depth'] = avg_scroll_depth
return render_template(
'admin/analytics_dashboard.html',
stats=stats,
device_stats=device_stats,
user_rankings=user_rankings,
popular_pages=popular_pages,
recent_sessions=recent_sessions,
user_detail=user_detail,
current_period=period,
# Nowe dane
country_stats=country_stats,
utm_stats=utm_stats,
top_searches=top_searches,
searches_no_results=searches_no_results,
conversion_stats=conversion_stats,
js_errors=js_errors,
hourly_activity=hourly_activity
)
except Exception as e:
logger.error(f"Admin analytics error: {e}")
flash('Blad podczas ladowania analityki.', 'error')
return redirect(url_for('admin.admin_users'))
finally:
db.close()
@bp.route('/analytics/export')
@login_required
def admin_analytics_export():
"""Export analytics data as CSV"""
if not current_user.is_admin:
flash('Brak uprawnien.', 'error')
return redirect(url_for('dashboard'))
export_type = request.args.get('type', 'sessions')
period = request.args.get('period', 'month')
today = date.today()
if period == 'day':
start_date = today
elif period == 'week':
start_date = today - timedelta(days=7)
elif period == 'month':
start_date = today - timedelta(days=30)
else:
start_date = today - timedelta(days=365) # year
db = SessionLocal()
try:
output = io.StringIO()
writer = csv.writer(output)
if export_type == 'sessions':
writer.writerow(['ID', 'User ID', 'Started At', 'Duration (s)', 'Page Views', 'Clicks',
'Device', 'Browser', 'OS', 'Country', 'UTM Source', 'UTM Campaign'])
sessions = db.query(UserSession).filter(
func.date(UserSession.started_at) >= start_date
).order_by(UserSession.started_at.desc()).all()
for s in sessions:
writer.writerow([
s.id, s.user_id, s.started_at.isoformat() if s.started_at else '',
s.duration_seconds or 0, s.page_views_count or 0, s.clicks_count or 0,
s.device_type or '', s.browser or '', s.os or '',
s.country or '', s.utm_source or '', s.utm_campaign or ''
])
elif export_type == 'pageviews':
writer.writerow(['ID', 'Session ID', 'User ID', 'Path', 'Viewed At', 'Time on Page (s)',
'Scroll Depth (%)', 'Company ID'])
views = db.query(PageView).filter(
func.date(PageView.viewed_at) >= start_date
).order_by(PageView.viewed_at.desc()).limit(10000).all()
for v in views:
writer.writerow([
v.id, v.session_id, v.user_id, v.path,
v.viewed_at.isoformat() if v.viewed_at else '',
v.time_on_page_seconds or 0, v.scroll_depth_percent or 0, v.company_id or ''
])
elif export_type == 'searches':
writer.writerow(['ID', 'User ID', 'Query', 'Results Count', 'Has Results', 'Clicked Company',
'Search Type', 'Searched At'])
searches = db.query(SearchQuery).filter(
func.date(SearchQuery.searched_at) >= start_date
).order_by(SearchQuery.searched_at.desc()).limit(10000).all()
for s in searches:
writer.writerow([
s.id, s.user_id, s.query, s.results_count, s.has_results,
s.clicked_company_id or '', s.search_type,
s.searched_at.isoformat() if s.searched_at else ''
])
elif export_type == 'conversions':
writer.writerow(['ID', 'User ID', 'Event Type', 'Event Category', 'Company ID',
'Target Type', 'Converted At'])
conversions = db.query(ConversionEvent).filter(
func.date(ConversionEvent.converted_at) >= start_date
).order_by(ConversionEvent.converted_at.desc()).all()
for c in conversions:
writer.writerow([
c.id, c.user_id, c.event_type, c.event_category or '',
c.company_id or '', c.target_type or '',
c.converted_at.isoformat() if c.converted_at else ''
])
output.seek(0)
return Response(
output.getvalue(),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename=analytics_{export_type}_{period}.csv'}
)
except Exception as e:
logger.error(f"Export error: {e}")
flash('Blad podczas eksportu.', 'error')
return redirect(url_for('admin.admin_analytics'))
finally:
db.close()

View File

@ -417,13 +417,13 @@
</div>
<div class="header-actions">
<div class="period-tabs">
<a href="{{ url_for('admin_analytics', period='day') }}"
<a href="{{ url_for('admin.admin_analytics', period='day') }}"
class="period-tab {% if current_period == 'day' %}active{% endif %}">Dzis</a>
<a href="{{ url_for('admin_analytics', period='week') }}"
<a href="{{ url_for('admin.admin_analytics', period='week') }}"
class="period-tab {% if current_period == 'week' %}active{% endif %}">7 dni</a>
<a href="{{ url_for('admin_analytics', period='month') }}"
<a href="{{ url_for('admin.admin_analytics', period='month') }}"
class="period-tab {% if current_period == 'month' %}active{% endif %}">30 dni</a>
<a href="{{ url_for('admin_analytics', period='all') }}"
<a href="{{ url_for('admin.admin_analytics', period='all') }}"
class="period-tab {% if current_period == 'all' %}active{% endif %}">Wszystko</a>
</div>
</div>
@ -440,7 +440,7 @@
<h2>{{ user_detail.user.name if user_detail.user else 'Nieznany' }}</h2>
<p>{{ user_detail.user.email if user_detail.user else '' }}</p>
</div>
<a href="{{ url_for('admin_analytics', period=current_period) }}" class="btn btn-secondary">
<a href="{{ url_for('admin.admin_analytics', period=current_period) }}" class="btn btn-secondary">
Zamknij
</a>
</div>
@ -709,7 +709,7 @@
{% endif %}
</td>
<td>
<a href="{{ url_for('admin_analytics', period=current_period, user_id=user.id) }}"
<a href="{{ url_for('admin.admin_analytics', period=current_period, user_id=user.id) }}"
style="color: var(--primary); text-decoration: none;">
Szczegóły
</a>
@ -967,16 +967,16 @@
<div class="section-body">
<p style="margin-bottom: var(--spacing-md); color: var(--text-secondary);">Pobierz dane analityczne w formacie CSV</p>
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
<a href="{{ url_for('admin_analytics_export', type='sessions', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
<a href="{{ url_for('admin.admin_analytics_export', type='sessions', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
Sesje (CSV)
</a>
<a href="{{ url_for('admin_analytics_export', type='pageviews', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
<a href="{{ url_for('admin.admin_analytics_export', type='pageviews', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
Page views (CSV)
</a>
<a href="{{ url_for('admin_analytics_export', type='searches', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
<a href="{{ url_for('admin.admin_analytics_export', type='searches', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
Wyszukiwania (CSV)
</a>
<a href="{{ url_for('admin_analytics_export', type='conversions', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
<a href="{{ url_for('admin.admin_analytics_export', type='conversions', period=current_period) }}" class="btn btn-secondary" style="text-decoration: none;">
Konwersje (CSV)
</a>
</div>

View File

@ -608,7 +608,7 @@
<div class="quick-actions">
<a href="{{ url_for('debug_panel') }}" class="quick-action-btn">🐛 Debug panel</a>
<a href="{{ url_for('admin_analytics') }}" class="quick-action-btn">📈 Analityka</a>
<a href="{{ url_for('admin.admin_analytics') }}" class="quick-action-btn">📈 Analityka</a>
</div>
</div>
</div>

View File

@ -1329,7 +1329,7 @@
</svg>
</button>
<div class="admin-dropdown-menu">
<a href="{{ url_for('admin_analytics') }}">
<a href="{{ url_for('admin.admin_analytics') }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75M9 7a4 4 0 1 0 0 8 4 4 0 0 0 0-8z"/>
</svg>