From a148d464af8bbebd604f9aea61932c0a4b873d37 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Tue, 13 Jan 2026 19:40:37 +0100 Subject: [PATCH] feat: Add user analytics panel (/admin/analytics) - Track user sessions, page views, clicks - Measure engagement score and session duration - Device breakdown (desktop/mobile/tablet) - User rankings by activity - Popular pages statistics - Recent sessions feed - SQL migration for analytics tables - JavaScript tracker for frontend events Co-Authored-By: Claude Opus 4.5 --- app.py | 394 ++++++++++- database.py | 175 +++++ database/migrations/014_user_analytics.sql | 250 +++++++ static/js/analytics-tracker.js | 125 ++++ templates/admin/analytics_dashboard.html | 770 +++++++++++++++++++++ templates/base.html | 4 + 6 files changed, 1717 insertions(+), 1 deletion(-) create mode 100644 database/migrations/014_user_analytics.sql create mode 100644 static/js/analytics-tracker.js create mode 100644 templates/admin/analytics_dashboard.html diff --git a/app.py b/app.py index 928ccbc..5c83acd 100644 --- a/app.py +++ b/app.py @@ -32,6 +32,8 @@ from flask_limiter import Limiter from flask_limiter.util import get_remote_address from werkzeug.security import generate_password_hash, check_password_hash from dotenv import load_dotenv +from user_agents import parse as parse_user_agent +import uuid # Load environment variables (override any existing env vars) # Try .env first, then nordabiz_config.txt for production flexibility @@ -114,7 +116,12 @@ from database import ( ITAudit, KRSAudit, CompanyPKD, - CompanyFinancialReport + CompanyFinancialReport, + UserSession, + PageView, + UserClick, + AnalyticsDaily, + PopularPagesDaily ) # Import services @@ -337,6 +344,157 @@ def create_news_notification(company_id, news_id, news_title): db.close() +# ============================================================ +# USER ANALYTICS - TRACKING HELPERS +# ============================================================ + +# Global variable to store current page_view_id for templates +_current_page_view_id = {} + +def get_or_create_analytics_session(): + """ + Get existing analytics session or create new one. + Returns the database session ID (integer). + """ + analytics_session_id = session.get('analytics_session_id') + + if not analytics_session_id: + analytics_session_id = str(uuid.uuid4()) + session['analytics_session_id'] = analytics_session_id + + db = SessionLocal() + try: + user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first() + + if not user_session: + # Parse user agent + ua_string = request.headers.get('User-Agent', '') + try: + ua = parse_user_agent(ua_string) + device_type = 'mobile' if ua.is_mobile else ('tablet' if ua.is_tablet else 'desktop') + browser = ua.browser.family + browser_version = ua.browser.version_string + os_name = ua.os.family + os_version = ua.os.version_string + except Exception: + device_type = 'desktop' + browser = 'Unknown' + browser_version = '' + os_name = 'Unknown' + os_version = '' + + user_session = UserSession( + session_id=analytics_session_id, + user_id=current_user.id if current_user.is_authenticated else None, + ip_address=request.remote_addr, + user_agent=ua_string[:2000] if ua_string else None, + device_type=device_type, + browser=browser[:50] if browser else None, + browser_version=browser_version[:20] if browser_version else None, + os=os_name[:50] if os_name else None, + os_version=os_version[:20] if os_version else None + ) + db.add(user_session) + db.commit() + db.refresh(user_session) + else: + # Update last activity + user_session.last_activity_at = datetime.now() + if current_user.is_authenticated and not user_session.user_id: + user_session.user_id = current_user.id + db.commit() + + return user_session.id + except Exception as e: + logger.error(f"Analytics session error: {e}") + db.rollback() + return None + finally: + db.close() + + +@app.before_request +def track_page_view(): + """Track page views (excluding static files and API calls)""" + # Skip static files + if request.path.startswith('/static'): + return + + # Skip API calls except selected ones + if request.path.startswith('/api'): + return + + # Skip analytics tracking endpoints + if request.path in ['/api/analytics/track', '/api/analytics/heartbeat']: + return + + # Skip health checks + if request.path == '/health': + return + + # Skip favicon + if request.path == '/favicon.ico': + return + + try: + session_db_id = get_or_create_analytics_session() + if not session_db_id: + return + + db = SessionLocal() + try: + page_view = PageView( + session_id=session_db_id, + user_id=current_user.id if current_user.is_authenticated else None, + url=request.url[:2000] if request.url else '', + path=request.path[:500] if request.path else '/', + referrer=request.referrer[:2000] if request.referrer else None + ) + + # Extract company_id from path if on company page + if request.path.startswith('/company/'): + try: + slug = request.path.split('/')[2].split('?')[0] + company = db.query(Company).filter_by(slug=slug).first() + if company: + page_view.company_id = company.id + except Exception: + pass + + db.add(page_view) + + # Update session page count + user_session = db.query(UserSession).filter_by(id=session_db_id).first() + if user_session: + user_session.page_views_count = (user_session.page_views_count or 0) + 1 + + db.commit() + + # Store page_view_id for click tracking (in request context) + _current_page_view_id[id(request)] = page_view.id + + except Exception as e: + logger.error(f"Page view tracking error: {e}") + db.rollback() + finally: + db.close() + except Exception as e: + logger.error(f"Page view tracking outer error: {e}") + + +@app.context_processor +def inject_page_view_id(): + """Inject page_view_id into all templates for JS tracking""" + page_view_id = _current_page_view_id.get(id(request), '') + return {'page_view_id': page_view_id} + + +@app.teardown_request +def cleanup_page_view_id(exception=None): + """Clean up page_view_id from global dict after request""" + _current_page_view_id.pop(id(request), None) + + # ============================================================ # SECURITY MIDDLEWARE & HELPERS # ============================================================ @@ -2937,6 +3095,92 @@ def api_notifications_unread_count(): db.close() +# ============================================================ +# USER ANALYTICS API ROUTES +# ============================================================ + +@app.route('/api/analytics/track', methods=['POST']) +def api_analytics_track(): + """Track clicks and interactions from frontend""" + data = request.get_json() + if not data: + return jsonify({'error': 'No data'}), 400 + + analytics_session_id = session.get('analytics_session_id') + if not analytics_session_id: + return jsonify({'error': 'No session'}), 400 + + db = SessionLocal() + try: + user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first() + if not user_session: + return jsonify({'error': 'Session not found'}), 404 + + event_type = data.get('type') + + if event_type == 'click': + click = UserClick( + session_id=user_session.id, + page_view_id=data.get('page_view_id'), + user_id=current_user.id if current_user.is_authenticated else None, + element_type=data.get('element_type', '')[:50] if data.get('element_type') else None, + element_id=data.get('element_id', '')[:100] if data.get('element_id') else None, + element_text=(data.get('element_text', '') or '')[:255], + element_class=(data.get('element_class', '') or '')[:500], + target_url=data.get('target_url', '')[:2000] if data.get('target_url') else None, + x_position=data.get('x'), + y_position=data.get('y') + ) + db.add(click) + + user_session.clicks_count = (user_session.clicks_count or 0) + 1 + db.commit() + + elif event_type == 'page_time': + # Update time on page + page_view_id = data.get('page_view_id') + time_seconds = data.get('time_seconds') + if page_view_id and time_seconds: + page_view = db.query(PageView).filter_by(id=page_view_id).first() + if page_view: + page_view.time_on_page_seconds = min(time_seconds, 86400) # Max 24h + db.commit() + + return jsonify({'success': True}), 200 + + except Exception as e: + logger.error(f"Analytics track error: {e}") + db.rollback() + return jsonify({'error': 'Internal error'}), 500 + finally: + db.close() + + +@app.route('/api/analytics/heartbeat', methods=['POST']) +def api_analytics_heartbeat(): + """Keep session alive and update duration""" + analytics_session_id = session.get('analytics_session_id') + if not analytics_session_id: + return jsonify({'success': False}), 200 + + db = SessionLocal() + try: + user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first() + if user_session: + user_session.last_activity_at = datetime.now() + user_session.duration_seconds = int( + (datetime.now() - user_session.started_at).total_seconds() + ) + db.commit() + return jsonify({'success': True}), 200 + except Exception as e: + logger.error(f"Analytics heartbeat error: {e}") + db.rollback() + return jsonify({'success': False}), 200 + finally: + db.close() + + # ============================================================ # RECOMMENDATIONS API ROUTES # ============================================================ @@ -6390,6 +6634,154 @@ def chat_analytics(): db.close() +@app.route('/admin/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')) + + from sqlalchemy import func, desc + from sqlalchemy.orm import joinedload + from datetime import date, timedelta + + 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 + } + + 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 + ) + except Exception as e: + logger.error(f"Admin analytics error: {e}") + flash('Blad podczas ladowania analityki.', 'error') + return redirect(url_for('admin_panel')) + finally: + db.close() + + @app.route('/api/admin/ai-learning-status') @login_required def api_ai_learning_status(): diff --git a/database.py b/database.py index 2e2b7e9..af89594 100644 --- a/database.py +++ b/database.py @@ -2349,6 +2349,181 @@ class CompanyFinancialReport(Base): return f'' +# ============================================================ +# USER ANALYTICS - SESJE I AKTYWNOŚĆ +# ============================================================ + +class UserSession(Base): + """ + Sesje użytkowników portalu. + Śledzi czas trwania sesji, urządzenie, lokalizację i aktywność. + """ + __tablename__ = 'user_sessions' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True) + session_id = Column(String(100), unique=True, nullable=False, index=True) + + # Czas sesji + started_at = Column(DateTime, nullable=False, default=datetime.now) + ended_at = Column(DateTime, nullable=True) + last_activity_at = Column(DateTime, nullable=False, default=datetime.now) + duration_seconds = Column(Integer, nullable=True) + + # Urządzenie + ip_address = Column(String(45), nullable=True) + user_agent = Column(Text, nullable=True) + device_type = Column(String(20), nullable=True) # desktop, mobile, tablet + browser = Column(String(50), nullable=True) + browser_version = Column(String(20), nullable=True) + os = Column(String(50), nullable=True) + os_version = Column(String(20), nullable=True) + + # Lokalizacja (z IP) + country = Column(String(100), nullable=True) + city = Column(String(100), nullable=True) + region = Column(String(100), nullable=True) + + # Metryki sesji + page_views_count = Column(Integer, default=0) + clicks_count = Column(Integer, default=0) + + created_at = Column(DateTime, default=datetime.now) + + # Relationships + user = relationship('User', backref='analytics_sessions') + page_views = relationship('PageView', back_populates='session', cascade='all, delete-orphan') + + def __repr__(self): + return f"" + + +class PageView(Base): + """ + Wyświetlenia stron przez użytkowników. + Śledzi odwiedzone strony i czas spędzony na każdej z nich. + """ + __tablename__ = 'page_views' + + id = Column(Integer, primary_key=True) + session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='CASCADE'), nullable=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) + + # Strona + url = Column(String(2000), nullable=False) + path = Column(String(500), nullable=False, index=True) + page_title = Column(String(500), nullable=True) + referrer = Column(String(2000), nullable=True) + + # Czas + viewed_at = Column(DateTime, nullable=False, default=datetime.now, index=True) + time_on_page_seconds = Column(Integer, nullable=True) + + # Kontekst + company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True) + + created_at = Column(DateTime, default=datetime.now) + + # Relationships + session = relationship('UserSession', back_populates='page_views') + clicks = relationship('UserClick', back_populates='page_view', cascade='all, delete-orphan') + + def __repr__(self): + return f"" + + +class UserClick(Base): + """ + Kliknięcia elementów na stronach. + Śledzi interakcje użytkowników z elementami UI. + """ + __tablename__ = 'user_clicks' + + id = Column(Integer, primary_key=True) + session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='CASCADE'), nullable=True) + page_view_id = Column(Integer, ForeignKey('page_views.id', ondelete='CASCADE'), nullable=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) + + # Element kliknięty + element_type = Column(String(50), nullable=True) # button, link, card, nav, form + element_id = Column(String(100), nullable=True) + element_text = Column(String(255), nullable=True) + element_class = Column(String(500), nullable=True) + target_url = Column(String(2000), nullable=True) + + # Pozycja kliknięcia + x_position = Column(Integer, nullable=True) + y_position = Column(Integer, nullable=True) + + clicked_at = Column(DateTime, nullable=False, default=datetime.now, index=True) + + # Relationships + page_view = relationship('PageView', back_populates='clicks') + + def __repr__(self): + return f"" + + +class AnalyticsDaily(Base): + """ + Dzienne statystyki agregowane. + Automatycznie aktualizowane przez trigger PostgreSQL. + """ + __tablename__ = 'analytics_daily' + + id = Column(Integer, primary_key=True) + date = Column(Date, unique=True, nullable=False, index=True) + + # Sesje + total_sessions = Column(Integer, default=0) + unique_users = Column(Integer, default=0) + new_users = Column(Integer, default=0) + returning_users = Column(Integer, default=0) + anonymous_sessions = Column(Integer, default=0) + + # Aktywność + total_page_views = Column(Integer, default=0) + total_clicks = Column(Integer, default=0) + avg_session_duration_seconds = Column(Integer, nullable=True) + avg_pages_per_session = Column(Numeric(5, 2), nullable=True) + + # Urządzenia + desktop_sessions = Column(Integer, default=0) + mobile_sessions = Column(Integer, default=0) + tablet_sessions = Column(Integer, default=0) + + # Engagement + bounce_rate = Column(Numeric(5, 2), nullable=True) + + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + def __repr__(self): + return f"" + + +class PopularPagesDaily(Base): + """ + Popularne strony - dzienne agregaty. + """ + __tablename__ = 'popular_pages_daily' + + id = Column(Integer, primary_key=True) + date = Column(Date, nullable=False, index=True) + path = Column(String(500), nullable=False) + page_title = Column(String(500), nullable=True) + + views_count = Column(Integer, default=0) + unique_visitors = Column(Integer, default=0) + avg_time_seconds = Column(Integer, nullable=True) + + __table_args__ = ( + UniqueConstraint('date', 'path', name='uq_popular_pages_date_path'), + ) + + def __repr__(self): + return f"" + + # ============================================================ # DATABASE INITIALIZATION # ============================================================ diff --git a/database/migrations/014_user_analytics.sql b/database/migrations/014_user_analytics.sql new file mode 100644 index 0000000..ab52cdb --- /dev/null +++ b/database/migrations/014_user_analytics.sql @@ -0,0 +1,250 @@ +-- Migration: 014_user_analytics.sql +-- Description: User activity tracking and analytics +-- Created: 2026-01-13 +-- Author: Claude + +-- ============================================================ +-- TABELE ANALITYKI UŻYTKOWNIKÓW +-- ============================================================ + +-- Sesje użytkowników +CREATE TABLE IF NOT EXISTS user_sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + session_id VARCHAR(100) UNIQUE NOT NULL, + + -- Czas sesji + started_at TIMESTAMP NOT NULL DEFAULT NOW(), + ended_at TIMESTAMP, + last_activity_at TIMESTAMP NOT NULL DEFAULT NOW(), + duration_seconds INTEGER, + + -- Urządzenie + ip_address VARCHAR(45), + user_agent TEXT, + device_type VARCHAR(20), -- desktop, mobile, tablet + browser VARCHAR(50), + browser_version VARCHAR(20), + os VARCHAR(50), + os_version VARCHAR(20), + + -- Lokalizacja (z IP) + country VARCHAR(100), + city VARCHAR(100), + region VARCHAR(100), + + -- Metryki sesji + page_views_count INTEGER DEFAULT 0, + clicks_count INTEGER DEFAULT 0, + + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_started ON user_sessions(started_at); +CREATE INDEX IF NOT EXISTS idx_user_sessions_session_id ON user_sessions(session_id); + +-- Wyświetlenia stron +CREATE TABLE IF NOT EXISTS page_views ( + id SERIAL PRIMARY KEY, + session_id INTEGER REFERENCES user_sessions(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + + -- Strona + url VARCHAR(2000) NOT NULL, + path VARCHAR(500) NOT NULL, + page_title VARCHAR(500), + referrer VARCHAR(2000), + + -- Czas + viewed_at TIMESTAMP NOT NULL DEFAULT NOW(), + time_on_page_seconds INTEGER, + + -- Kontekst + company_id INTEGER REFERENCES companies(id) ON DELETE SET NULL, + + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_page_views_session ON page_views(session_id); +CREATE INDEX IF NOT EXISTS idx_page_views_user ON page_views(user_id); +CREATE INDEX IF NOT EXISTS idx_page_views_path ON page_views(path); +CREATE INDEX IF NOT EXISTS idx_page_views_viewed ON page_views(viewed_at); +CREATE INDEX IF NOT EXISTS idx_page_views_company ON page_views(company_id); + +-- Kliknięcia użytkowników +CREATE TABLE IF NOT EXISTS user_clicks ( + id SERIAL PRIMARY KEY, + session_id INTEGER REFERENCES user_sessions(id) ON DELETE CASCADE, + page_view_id INTEGER REFERENCES page_views(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + + -- Element kliknięty + element_type VARCHAR(50), -- button, link, card, nav, form + element_id VARCHAR(100), + element_text VARCHAR(255), + element_class VARCHAR(500), + target_url VARCHAR(2000), + + -- Pozycja + x_position INTEGER, + y_position INTEGER, + + clicked_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_clicks_session ON user_clicks(session_id); +CREATE INDEX IF NOT EXISTS idx_user_clicks_element ON user_clicks(element_type); +CREATE INDEX IF NOT EXISTS idx_user_clicks_clicked ON user_clicks(clicked_at); + +-- Dzienne statystyki (agregowane) +CREATE TABLE IF NOT EXISTS analytics_daily ( + id SERIAL PRIMARY KEY, + date DATE UNIQUE NOT NULL, + + -- Sesje + total_sessions INTEGER DEFAULT 0, + unique_users INTEGER DEFAULT 0, + new_users INTEGER DEFAULT 0, + returning_users INTEGER DEFAULT 0, + anonymous_sessions INTEGER DEFAULT 0, + + -- Aktywność + total_page_views INTEGER DEFAULT 0, + total_clicks INTEGER DEFAULT 0, + avg_session_duration_seconds INTEGER, + avg_pages_per_session NUMERIC(5,2), + + -- Urządzenia + desktop_sessions INTEGER DEFAULT 0, + mobile_sessions INTEGER DEFAULT 0, + tablet_sessions INTEGER DEFAULT 0, + + -- Engagement + bounce_rate NUMERIC(5,2), -- % sesji z 1 page view + + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_analytics_daily_date ON analytics_daily(date); + +-- Popularne strony (dzienne) +CREATE TABLE IF NOT EXISTS popular_pages_daily ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + path VARCHAR(500) NOT NULL, + page_title VARCHAR(500), + + views_count INTEGER DEFAULT 0, + unique_visitors INTEGER DEFAULT 0, + avg_time_seconds INTEGER, + + UNIQUE(date, path) +); + +CREATE INDEX IF NOT EXISTS idx_popular_pages_date ON popular_pages_daily(date); + +-- ============================================================ +-- GRANT PERMISSIONS +-- ============================================================ +GRANT ALL ON TABLE user_sessions TO nordabiz_app; +GRANT ALL ON TABLE page_views TO nordabiz_app; +GRANT ALL ON TABLE user_clicks TO nordabiz_app; +GRANT ALL ON TABLE analytics_daily TO nordabiz_app; +GRANT ALL ON TABLE popular_pages_daily TO nordabiz_app; + +GRANT USAGE, SELECT ON SEQUENCE user_sessions_id_seq TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE page_views_id_seq TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE user_clicks_id_seq TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE analytics_daily_id_seq TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE popular_pages_daily_id_seq TO nordabiz_app; + +-- ============================================================ +-- TRIGGER: Update analytics_daily on new session/page_view +-- ============================================================ + +-- Function to update daily analytics +CREATE OR REPLACE FUNCTION update_analytics_daily() +RETURNS TRIGGER AS $$ +DECLARE + target_date DATE; +BEGIN + IF TG_TABLE_NAME = 'user_sessions' THEN + target_date := DATE(NEW.started_at); + ELSIF TG_TABLE_NAME = 'page_views' THEN + target_date := DATE(NEW.viewed_at); + ELSE + RETURN NEW; + END IF; + + -- Insert or update daily stats + INSERT INTO analytics_daily (date, total_sessions, total_page_views, updated_at) + VALUES (target_date, 0, 0, NOW()) + ON CONFLICT (date) DO NOTHING; + + -- Update counts based on trigger source + IF TG_TABLE_NAME = 'user_sessions' THEN + UPDATE analytics_daily SET + total_sessions = total_sessions + 1, + unique_users = ( + SELECT COUNT(DISTINCT user_id) + FROM user_sessions + WHERE DATE(started_at) = target_date AND user_id IS NOT NULL + ), + anonymous_sessions = ( + SELECT COUNT(*) + FROM user_sessions + WHERE DATE(started_at) = target_date AND user_id IS NULL + ), + desktop_sessions = ( + SELECT COUNT(*) FROM user_sessions + WHERE DATE(started_at) = target_date AND device_type = 'desktop' + ), + mobile_sessions = ( + SELECT COUNT(*) FROM user_sessions + WHERE DATE(started_at) = target_date AND device_type = 'mobile' + ), + tablet_sessions = ( + SELECT COUNT(*) FROM user_sessions + WHERE DATE(started_at) = target_date AND device_type = 'tablet' + ), + updated_at = NOW() + WHERE date = target_date; + + ELSIF TG_TABLE_NAME = 'page_views' THEN + UPDATE analytics_daily SET + total_page_views = total_page_views + 1, + updated_at = NOW() + WHERE date = target_date; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create triggers +DROP TRIGGER IF EXISTS trg_update_analytics_on_session ON user_sessions; +CREATE TRIGGER trg_update_analytics_on_session + AFTER INSERT ON user_sessions + FOR EACH ROW + EXECUTE FUNCTION update_analytics_daily(); + +DROP TRIGGER IF EXISTS trg_update_analytics_on_pageview ON page_views; +CREATE TRIGGER trg_update_analytics_on_pageview + AFTER INSERT ON page_views + FOR EACH ROW + EXECUTE FUNCTION update_analytics_daily(); + +-- ============================================================ +-- COMMENTS +-- ============================================================ +COMMENT ON TABLE user_sessions IS 'Sesje użytkowników portalu NordaBiznes'; +COMMENT ON TABLE page_views IS 'Wyświetlenia stron przez użytkowników'; +COMMENT ON TABLE user_clicks IS 'Kliknięcia elementów na stronach'; +COMMENT ON TABLE analytics_daily IS 'Dzienne statystyki agregowane'; +COMMENT ON TABLE popular_pages_daily IS 'Popularne strony - dzienne agregaty'; + +COMMENT ON COLUMN user_sessions.device_type IS 'Typ urządzenia: desktop, mobile, tablet'; +COMMENT ON COLUMN user_sessions.duration_seconds IS 'Czas trwania sesji w sekundach'; +COMMENT ON COLUMN page_views.time_on_page_seconds IS 'Czas spędzony na stronie w sekundach'; +COMMENT ON COLUMN analytics_daily.bounce_rate IS 'Procent sesji z tylko 1 wyświetleniem strony'; diff --git a/static/js/analytics-tracker.js b/static/js/analytics-tracker.js new file mode 100644 index 0000000..720b061 --- /dev/null +++ b/static/js/analytics-tracker.js @@ -0,0 +1,125 @@ +/** + * NordaBiz Analytics Tracker + * Tracks user clicks, time on page, and session heartbeats + * Created: 2026-01-13 + */ +(function() { + 'use strict'; + + const HEARTBEAT_INTERVAL = 30000; // 30 seconds + const TRACK_ENDPOINT = '/api/analytics/track'; + const HEARTBEAT_ENDPOINT = '/api/analytics/heartbeat'; + + let pageStartTime = Date.now(); + let currentPageViewId = null; + + // Get page view ID from meta tag (set by Flask) + function init() { + const pageViewMeta = document.querySelector('meta[name="page-view-id"]'); + if (pageViewMeta && pageViewMeta.content) { + currentPageViewId = parseInt(pageViewMeta.content, 10); + } + + // Start heartbeat + setInterval(sendHeartbeat, HEARTBEAT_INTERVAL); + + // Track clicks + document.addEventListener('click', handleClick, true); + + // Track time on page before leaving + window.addEventListener('beforeunload', handleUnload); + + // Also track visibility change (tab switch) + document.addEventListener('visibilitychange', handleVisibilityChange); + } + + function handleClick(e) { + // Find the closest interactive element + const target = e.target.closest('a, button, [data-track], input[type="submit"], .clickable, .company-card, nav a'); + if (!target) return; + + const data = { + type: 'click', + page_view_id: currentPageViewId, + element_type: getElementType(target, e), + element_id: target.id || null, + element_text: (target.textContent || '').trim().substring(0, 100) || null, + element_class: target.className || null, + target_url: target.href || target.closest('a')?.href || null, + x: e.clientX, + y: e.clientY + }; + + sendTracking(data); + } + + function getElementType(target, e) { + // Determine element type more precisely + if (target.closest('nav')) return 'nav'; + if (target.closest('.company-card')) return 'company_card'; + if (target.closest('form')) return 'form'; + if (target.closest('.sidebar')) return 'sidebar'; + if (target.closest('.dropdown')) return 'dropdown'; + if (target.closest('.modal')) return 'modal'; + if (target.tagName === 'A') return 'link'; + if (target.tagName === 'BUTTON') return 'button'; + if (target.tagName === 'INPUT') return 'input'; + if (target.hasAttribute('data-track')) return target.getAttribute('data-track'); + return target.tagName.toLowerCase(); + } + + function handleUnload() { + if (!currentPageViewId) return; + + const timeOnPage = Math.round((Date.now() - pageStartTime) / 1000); + + // Use sendBeacon for reliable tracking on page exit + const data = JSON.stringify({ + type: 'page_time', + page_view_id: currentPageViewId, + time_seconds: timeOnPage + }); + + navigator.sendBeacon(TRACK_ENDPOINT, data); + } + + function handleVisibilityChange() { + if (document.visibilityState === 'hidden' && currentPageViewId) { + // Send page time when tab becomes hidden + const timeOnPage = Math.round((Date.now() - pageStartTime) / 1000); + sendTracking({ + type: 'page_time', + page_view_id: currentPageViewId, + time_seconds: timeOnPage + }); + } + } + + function sendHeartbeat() { + fetch(HEARTBEAT_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin' + }).catch(function() { + // Silently fail + }); + } + + function sendTracking(data) { + fetch(TRACK_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'same-origin' + }).catch(function() { + // Silently fail + }); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/templates/admin/analytics_dashboard.html b/templates/admin/analytics_dashboard.html new file mode 100644 index 0000000..0ce235f --- /dev/null +++ b/templates/admin/analytics_dashboard.html @@ -0,0 +1,770 @@ +{% extends "base.html" %} + +{% block title %}Panel Analityki - Norda Biznes Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+ + + + + Panel Admina + + +
+
+

Analityka Uzytkownikow

+
+
+ +
+
+ + {% if user_detail %} + +
+
+
+ {{ user_detail.user.name[0]|upper if user_detail.user and user_detail.user.name else '?' }} +
+
+

{{ user_detail.user.name if user_detail.user else 'Nieznany' }}

+

{{ user_detail.user.email if user_detail.user else '' }}

+
+ + Zamknij + +
+ +

Ostatnie sesje

+
+ + + + + + + + + + + + {% for s in user_detail.sessions %} + + + + + + + + {% else %} + + + + {% endfor %} + +
DataCzas trwaniaStronyKliknieciaUrzadzenie
{{ s.started_at.strftime('%Y-%m-%d %H:%M') }}{{ (s.duration_seconds // 60)|default(0) }} min{{ s.page_views_count|default(0) }}{{ s.clicks_count|default(0) }}{{ s.device_type|default('?') }} / {{ s.browser|default('?') }}
Brak sesji
+
+ +

Ostatnie odwiedzone strony

+
+ + + + + + + + + + {% for p in user_detail.pages %} + + + + + + {% else %} + + + + {% endfor %} + +
SciezkaCzasData
{{ p.path }}{{ (p.time_on_page_seconds // 60)|default(0) if p.time_on_page_seconds else '-' }} min{{ p.viewed_at.strftime('%Y-%m-%d %H:%M') }}
Brak danych
+
+
+ {% endif %} + + +
+
+
+ + + + + + +
+ {{ stats.total_sessions }} + Sesje +
+
+
+ + + + +
+ {{ stats.unique_users }} + Unikalni uzytkownicy +
+
+
+ + + + +
+ {{ stats.total_page_views }} + Wysw. stron +
+
+
+ + + +
+ {{ stats.total_clicks }} + Klikniecia +
+
+
+ + + + +
+ {{ (stats.avg_duration / 60)|round(1) if stats.avg_duration else 0 }} min + Sr. czas sesji +
+
+ +
+ +
+
+

Urzadzenia

+
+
+
+
+
+ + + + + +
+
{{ device_stats.get('desktop', 0) }}
+
Desktop
+ {% set total_devices = device_stats.get('desktop', 0) + device_stats.get('mobile', 0) + device_stats.get('tablet', 0) %} +
+ {{ ((device_stats.get('desktop', 0) / total_devices * 100)|round(0)|int if total_devices > 0 else 0) }}% +
+
+
+
+ + + + +
+
{{ device_stats.get('mobile', 0) }}
+
Mobile
+
+ {{ ((device_stats.get('mobile', 0) / total_devices * 100)|round(0)|int if total_devices > 0 else 0) }}% +
+
+
+
+ + + + +
+
{{ device_stats.get('tablet', 0) }}
+
Tablet
+
+ {{ ((device_stats.get('tablet', 0) / total_devices * 100)|round(0)|int if total_devices > 0 else 0) }}% +
+
+
+
+
+ + +
+
+

Popularne strony

+
+
+ + + + + + + + + + {% for page in popular_pages[:10] %} + + + + + + {% else %} + + + + {% endfor %} + +
SciezkaWysw.Unik.
{{ page.path }}{{ page.views }}{{ page.unique_users }}
Brak danych
+
+
+
+ + +
+
+

Ranking uzytkownikow wg aktywnosci

+
+
+ + + + + + + + + + + + + + {% for user in user_rankings %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
UzytkownikSesjeStronyKliknieciaCzasZaangazowanie
+
+
{{ user.name[0]|upper if user.name else '?' }}
+ +
+
{{ user.sessions }}{{ user.page_views|default(0) }}{{ user.clicks|default(0) }}{{ ((user.total_time or 0) / 60)|round(0)|int }} min + {% set engagement = (user.page_views|default(0)) + (user.clicks|default(0)) * 2 + ((user.total_time or 0) / 120)|int %} + {% if engagement >= 50 %} + + {% elif engagement >= 20 %} + + {% else %} + + {% endif %} + + + Szczegoly + +
+ + + + + +

Brak danych o uzytkownikach w wybranym okresie

+
+
+
+ + +
+
+

Ostatnie sesje

+
+
+ {% for s in recent_sessions %} +
+
+ {% if s.user %} +
{{ s.user.name }}
+
{{ s.user.email }}
+ {% else %} +
Niezalogowany
+ {% endif %} +
+
+ {% if s.device_type == 'mobile' %} + + + + {% elif s.device_type == 'tablet' %} + + + + {% else %} + + + + + {% endif %} + {{ s.browser|default('?') }} +
+
+
{{ (s.duration_seconds // 60)|default(0) }} min
+
{{ s.page_views_count|default(0) }} stron
+
+
+ {{ s.started_at.strftime('%d.%m %H:%M') }} +
+
+ {% else %} +
+ + + + + +

Brak sesji w wybranym okresie

+
+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index 0f1e85b..1338246 100755 --- a/templates/base.html +++ b/templates/base.html @@ -6,6 +6,7 @@ + {% block title %}Norda Biznes Hub{% endblock %} @@ -892,6 +893,9 @@ {% block extra_css %}{% endblock %} + + +