nordabiz/blueprints/api/routes_analytics.py
Maciej Pienczyn 618bd9b8d3
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat: add search result click tracking and fix content engagement labels
- Pass search_query_id to search results template
- Add POST /api/analytics/search-click endpoint to update SearchQuery
  with clicked_result_position, clicked_company_id, time_to_click_ms
- Add data-position and data-company-id attributes to company cards
- Add JS using navigator.sendBeacon for non-blocking click tracking
- Fix content engagement labels: "nowych (30 dni)" instead of "opublikowanych"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:23:49 +01:00

282 lines
9.6 KiB
Python

"""
Analytics API Routes - API blueprint
Migrated from app.py as part of the blueprint refactoring.
Contains public API routes for user analytics tracking.
"""
import hashlib
import logging
from datetime import datetime
from flask import jsonify, request, session, current_app
from flask_login import current_user
from database import SessionLocal, UserSession, UserClick, PageView, JSError, SearchQuery
from extensions import limiter
from . import bp
logger = logging.getLogger(__name__)
def exempt_from_csrf(app):
"""Exempt analytics routes from CSRF protection."""
csrf = app.extensions.get('csrf')
if csrf:
csrf.exempt(bp)
# ============================================================
# USER ANALYTICS API ROUTES
# ============================================================
@bp.route('/analytics/track', methods=['POST'])
def api_analytics_track():
"""Track clicks and interactions from frontend"""
# Exempt from CSRF for analytics tracking
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()
@bp.route('/analytics/heartbeat', methods=['POST'])
@limiter.exempt
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()
@bp.route('/analytics/scroll', methods=['POST'])
def api_analytics_scroll():
"""Track scroll depth from frontend"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data'}), 400
page_view_id = data.get('page_view_id')
scroll_depth = data.get('scroll_depth')
if not page_view_id or scroll_depth is None:
return jsonify({'error': 'Missing data'}), 400
db = SessionLocal()
try:
page_view = db.query(PageView).filter_by(id=page_view_id).first()
if page_view:
# Zapisz tylko jeśli większe niż poprzednie (max scroll depth)
current_depth = page_view.scroll_depth_percent or 0
if scroll_depth > current_depth:
page_view.scroll_depth_percent = min(scroll_depth, 100)
db.commit()
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Analytics scroll error: {e}")
db.rollback()
return jsonify({'success': False}), 200
finally:
db.close()
@bp.route('/analytics/error', methods=['POST'])
def api_analytics_error():
"""Track JavaScript errors from frontend"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data'}), 400
message = data.get('message', '')[:2000]
if not message:
return jsonify({'error': 'No message'}), 400
analytics_session_id = session.get('analytics_session_id')
db = SessionLocal()
try:
# Znajdź session ID
session_db_id = None
if analytics_session_id:
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
if user_session:
session_db_id = user_session.id
# Utwórz hash dla agregacji
error_key = f"{message}|{data.get('source', '')}|{data.get('lineno', '')}"
error_hash = hashlib.sha256(error_key.encode()).hexdigest()
js_error = JSError(
session_id=session_db_id,
message=message,
source=data.get('source', '')[:500] if data.get('source') else None,
lineno=data.get('lineno'),
colno=data.get('colno'),
stack=data.get('stack', '')[:5000] if data.get('stack') else None,
url=data.get('url', '')[:2000] if data.get('url') else None,
user_agent=request.headers.get('User-Agent', '')[:500],
error_hash=error_hash
)
db.add(js_error)
db.commit()
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Analytics error tracking error: {e}")
db.rollback()
return jsonify({'success': False}), 200
finally:
db.close()
@bp.route('/analytics/performance', methods=['POST'])
def api_analytics_performance():
"""Track page performance metrics from frontend"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data'}), 400
page_view_id = data.get('page_view_id')
if not page_view_id:
return jsonify({'error': 'Missing page_view_id'}), 400
db = SessionLocal()
try:
page_view = db.query(PageView).filter_by(id=page_view_id).first()
if page_view:
# Zapisz metryki performance (tylko jeśli jeszcze nie zapisane)
if page_view.dom_content_loaded_ms is None:
page_view.dom_content_loaded_ms = data.get('dom_content_loaded_ms')
if page_view.load_time_ms is None:
page_view.load_time_ms = data.get('load_time_ms')
if page_view.first_paint_ms is None:
page_view.first_paint_ms = data.get('first_paint_ms')
if page_view.first_contentful_paint_ms is None:
page_view.first_contentful_paint_ms = data.get('first_contentful_paint_ms')
db.commit()
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Analytics performance error: {e}")
db.rollback()
return jsonify({'success': False}), 200
finally:
db.close()
@bp.route('/analytics/conversion', methods=['POST'])
def api_analytics_conversion():
"""Track conversion events from frontend (contact clicks)"""
# Import track_conversion from app module
from app import track_conversion
data = request.get_json()
if not data:
return jsonify({'error': 'No data'}), 400
event_type = data.get('event_type')
if not event_type:
return jsonify({'error': 'Missing event_type'}), 400
track_conversion(
event_type=event_type,
company_id=data.get('company_id'),
target_type=data.get('target_type'),
target_value=data.get('target_value'),
metadata=data.get('metadata')
)
return jsonify({'success': True}), 200
@bp.route('/analytics/search-click', methods=['POST'])
@limiter.limit("30/minute")
def api_analytics_search_click():
"""Track when a user clicks a search result."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data'}), 400
search_query_id = data.get('search_query_id')
if not search_query_id:
return jsonify({'error': 'Missing search_query_id'}), 400
db = SessionLocal()
try:
sq = db.query(SearchQuery).filter_by(id=search_query_id).first()
if sq:
sq.clicked_result_position = data.get('position')
sq.clicked_company_id = data.get('company_id')
sq.time_to_click_ms = data.get('time_to_click_ms')
db.commit()
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Search click tracking error: {e}")
db.rollback()
return jsonify({'success': False}), 200
finally:
db.close()