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:
parent
9b9ae40932
commit
8909641ff3
12
app.py
12
app.py
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
367
blueprints/admin/routes_analytics.py
Normal file
367
blueprints/admin/routes_analytics.py
Normal 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()
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user