feat: add traffic sources, company popularity, search CTR, and content open rates to analytics
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
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
New insights for admin dashboard: - Referrer sources (top 10 by domain, 30 days) in Overview tab - Company profile popularity ranking (views + unique viewers) - Search effectiveness: CTR, avg click position, top clicked companies - Content open rates: announcements, forum, classifieds (published vs read) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7aa8b8bdc3
commit
f46bf101a0
@ -25,7 +25,8 @@ from database import (
|
||||
ConversionEvent, JSError, EmailLog, SecurityAlert,
|
||||
AuditLog, AnalyticsDaily, SystemRole,
|
||||
AIChatConversation, AIChatMessage, AIChatFeedback,
|
||||
Company, NordaEvent
|
||||
Company, NordaEvent, Announcement, AnnouncementRead,
|
||||
ForumTopic, ForumTopicRead, Classified, ClassifiedRead
|
||||
)
|
||||
from utils.decorators import role_required
|
||||
|
||||
@ -925,12 +926,105 @@ def _tab_pages(db, start_date, days):
|
||||
func.date(SearchQuery.searched_at) >= start_date
|
||||
).group_by(SearchQuery.query_normalized).order_by(desc('count')).limit(10).all()
|
||||
|
||||
# Search effectiveness (CTR)
|
||||
total_searches = db.query(func.count(SearchQuery.id)).filter(
|
||||
func.date(SearchQuery.searched_at) >= start_date
|
||||
).scalar() or 0
|
||||
searches_with_click = db.query(func.count(SearchQuery.id)).filter(
|
||||
func.date(SearchQuery.searched_at) >= start_date,
|
||||
SearchQuery.clicked_result_position.isnot(None)
|
||||
).scalar() or 0
|
||||
search_ctr = round(searches_with_click / total_searches * 100) if total_searches > 0 else 0
|
||||
|
||||
avg_click_position = db.query(func.avg(SearchQuery.clicked_result_position)).filter(
|
||||
func.date(SearchQuery.searched_at) >= start_date,
|
||||
SearchQuery.clicked_result_position.isnot(None)
|
||||
).scalar()
|
||||
avg_click_position = round(avg_click_position, 1) if avg_click_position else None
|
||||
|
||||
# Most clicked companies from search
|
||||
top_clicked_companies = db.query(
|
||||
SearchQuery.clicked_company_id,
|
||||
Company.name,
|
||||
func.count(SearchQuery.id).label('clicks')
|
||||
).join(Company, SearchQuery.clicked_company_id == Company.id).filter(
|
||||
func.date(SearchQuery.searched_at) >= start_date,
|
||||
SearchQuery.clicked_company_id.isnot(None)
|
||||
).group_by(SearchQuery.clicked_company_id, Company.name).order_by(desc('clicks')).limit(10).all()
|
||||
|
||||
search_effectiveness = {
|
||||
'total': total_searches,
|
||||
'with_click': searches_with_click,
|
||||
'ctr': search_ctr,
|
||||
'avg_position': avg_click_position,
|
||||
'top_clicked': [{'name': r.name, 'clicks': r.clicks} for r in top_clicked_companies],
|
||||
}
|
||||
|
||||
# Content open rates (30 days)
|
||||
start_30d = datetime.combine(date.today() - timedelta(days=30), datetime.min.time())
|
||||
|
||||
# Announcements: total vs read
|
||||
total_announcements = db.query(func.count(Announcement.id)).filter(
|
||||
Announcement.created_at >= start_30d
|
||||
).scalar() or 0
|
||||
announcement_reads = db.query(func.count(func.distinct(AnnouncementRead.announcement_id))).filter(
|
||||
AnnouncementRead.read_at >= start_30d
|
||||
).scalar() or 0
|
||||
total_announcement_readers = db.query(func.count(func.distinct(AnnouncementRead.user_id))).filter(
|
||||
AnnouncementRead.read_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
# Forum: topics published vs read
|
||||
total_forum_topics = db.query(func.count(ForumTopic.id)).filter(
|
||||
ForumTopic.created_at >= start_30d
|
||||
).scalar() or 0
|
||||
forum_reads_count = db.query(func.count(func.distinct(ForumTopicRead.topic_id))).filter(
|
||||
ForumTopicRead.read_at >= start_30d
|
||||
).scalar() or 0
|
||||
total_forum_readers = db.query(func.count(func.distinct(ForumTopicRead.user_id))).filter(
|
||||
ForumTopicRead.read_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
# Classifieds: published vs read
|
||||
total_classifieds = db.query(func.count(Classified.id)).filter(
|
||||
Classified.created_at >= start_30d
|
||||
).scalar() or 0
|
||||
classified_reads_count = db.query(func.count(func.distinct(ClassifiedRead.classified_id))).filter(
|
||||
ClassifiedRead.read_at >= start_30d
|
||||
).scalar() or 0
|
||||
total_classified_readers = db.query(func.count(func.distinct(ClassifiedRead.user_id))).filter(
|
||||
ClassifiedRead.read_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
content_engagement = {
|
||||
'announcements': {
|
||||
'published': total_announcements,
|
||||
'read_by': total_announcement_readers,
|
||||
'unique_read': announcement_reads,
|
||||
'open_rate': round(announcement_reads / total_announcements * 100) if total_announcements > 0 else 0,
|
||||
},
|
||||
'forum': {
|
||||
'published': total_forum_topics,
|
||||
'read_by': total_forum_readers,
|
||||
'unique_read': forum_reads_count,
|
||||
'open_rate': round(forum_reads_count / total_forum_topics * 100) if total_forum_topics > 0 else 0,
|
||||
},
|
||||
'classifieds': {
|
||||
'published': total_classifieds,
|
||||
'read_by': total_classified_readers,
|
||||
'unique_read': classified_reads_count,
|
||||
'open_rate': round(classified_reads_count / total_classifieds * 100) if total_classifieds > 0 else 0,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
'sections': sections,
|
||||
'top_pages': pages_list,
|
||||
'ignored_pages': [{'path': _mask_token_path(p.path), 'views': p.views} for p in ignored],
|
||||
'top_searches': search_query,
|
||||
'searches_no_results': no_results_query,
|
||||
'search_effectiveness': search_effectiveness,
|
||||
'content_engagement': content_engagement,
|
||||
}
|
||||
|
||||
|
||||
@ -1298,6 +1392,68 @@ def _tab_overview(db, start_date, days):
|
||||
'rsvp': rsvp_map.get(r.id, 0),
|
||||
})
|
||||
|
||||
# Referrer sources (top 10, 30 days, exclude bots + internal)
|
||||
referrer_raw = db.query(
|
||||
PageView.referrer,
|
||||
func.count(PageView.id).label('cnt')
|
||||
).join(UserSession, PageView.session_id == UserSession.id).filter(
|
||||
PageView.viewed_at >= start_30d,
|
||||
PageView.referrer.isnot(None),
|
||||
PageView.referrer != '',
|
||||
UserSession.is_bot == False
|
||||
).group_by(PageView.referrer).order_by(desc('cnt')).limit(50).all()
|
||||
|
||||
# Aggregate by domain
|
||||
from urllib.parse import urlparse
|
||||
domain_counts = {}
|
||||
for r in referrer_raw:
|
||||
try:
|
||||
domain = urlparse(r.referrer).netloc or r.referrer
|
||||
# Skip own domain
|
||||
if 'nordabiznes.pl' in domain or 'nordabiznes' in domain:
|
||||
continue
|
||||
# Simplify well-known domains
|
||||
if 'facebook' in domain or 'fbclid' in (r.referrer or ''):
|
||||
domain = 'Facebook'
|
||||
elif 'google' in domain:
|
||||
domain = 'Google'
|
||||
elif 'linkedin' in domain:
|
||||
domain = 'LinkedIn'
|
||||
elif 'instagram' in domain:
|
||||
domain = 'Instagram'
|
||||
elif 't.co' in domain or 'twitter' in domain:
|
||||
domain = 'X / Twitter'
|
||||
domain_counts[domain] = domain_counts.get(domain, 0) + r.cnt
|
||||
except Exception:
|
||||
pass
|
||||
referrer_sources = sorted(domain_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
max_ref = referrer_sources[0][1] if referrer_sources else 1
|
||||
|
||||
# Company profile popularity (top 15, 30 days)
|
||||
company_views_raw = db.query(
|
||||
PageView.path,
|
||||
func.count(PageView.id).label('views'),
|
||||
func.count(func.distinct(PageView.user_id)).label('unique_viewers')
|
||||
).join(UserSession, PageView.session_id == UserSession.id).filter(
|
||||
PageView.viewed_at >= start_30d,
|
||||
PageView.path.like('/company/%'),
|
||||
UserSession.is_bot == False
|
||||
).group_by(PageView.path).order_by(desc('views')).limit(20).all()
|
||||
|
||||
company_popularity = []
|
||||
for r in company_views_raw:
|
||||
if _is_technical_path(r.path):
|
||||
continue
|
||||
label = _humanize_path(r.path, db)
|
||||
company_popularity.append({
|
||||
'name': label,
|
||||
'path': r.path,
|
||||
'views': r.views,
|
||||
'unique_viewers': r.unique_viewers,
|
||||
})
|
||||
company_popularity = company_popularity[:15]
|
||||
max_company = company_popularity[0]['views'] if company_popularity else 1
|
||||
|
||||
return {
|
||||
'filter_type': filter_type,
|
||||
'kpi': kpi,
|
||||
@ -1316,6 +1472,9 @@ def _tab_overview(db, start_date, days):
|
||||
},
|
||||
'adoption': adoption,
|
||||
'events_comparison': events_comparison,
|
||||
'referrer_sources': [{'domain': d, 'count': c, 'bar_pct': int(c / max_ref * 100)} for d, c in referrer_sources],
|
||||
'company_popularity': company_popularity,
|
||||
'max_company_views': max_company,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -653,6 +653,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Effectiveness -->
|
||||
<div class="section-card">
|
||||
<h2>Skuteczność wyszukiwarki</h2>
|
||||
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); margin-bottom: var(--spacing-lg);">
|
||||
<div class="stat-card info">
|
||||
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.total }}</div>
|
||||
<div class="stat-label">Wyszukiwań</div>
|
||||
</div>
|
||||
<div class="stat-card {% if data.search_effectiveness.ctr >= 40 %}success{% elif data.search_effectiveness.ctr >= 20 %}warning{% else %}error{% endif %}">
|
||||
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.ctr }}%</div>
|
||||
<div class="stat-label">Klikalność (CTR)</div>
|
||||
</div>
|
||||
<div class="stat-card info">
|
||||
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.avg_position or '—' }}</div>
|
||||
<div class="stat-label">Śr. pozycja kliknięcia</div>
|
||||
</div>
|
||||
<div class="stat-card {% if data.search_effectiveness.ctr < 20 %}error{% else %}success{% endif %}">
|
||||
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.with_click }}</div>
|
||||
<div class="stat-label">Z kliknięciem</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if data.search_effectiveness.top_clicked %}
|
||||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-sm); color: var(--text-secondary);">Najczęściej wybierane firmy z wyszukiwarki</h3>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||
{% for c in data.search_effectiveness.top_clicked %}
|
||||
<span style="padding: 4px 12px; background: var(--background); border-radius: var(--radius); font-size: var(--font-size-sm);">
|
||||
<strong>{{ c.name }}</strong> <span style="color: var(--text-muted);">({{ c.clicks }}x)</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Content Engagement (Open Rates) -->
|
||||
<div class="section-card">
|
||||
<h2>Zasięg treści <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--spacing-lg);">
|
||||
{% set content_types = [
|
||||
('Aktualności', data.content_engagement.announcements, '#3b82f6'),
|
||||
('Forum', data.content_engagement.forum, '#8b5cf6'),
|
||||
('Tablica B2B', data.content_engagement.classifieds, '#10b981')
|
||||
] %}
|
||||
{% for label, stats, color in content_types %}
|
||||
<div style="background: var(--background); padding: var(--spacing-lg); border-radius: var(--radius-lg); border-left: 4px solid {{ color }};">
|
||||
<h3 style="font-size: var(--font-size-base); font-weight: 600; margin-bottom: var(--spacing-md);">{{ label }}</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-sm); margin-bottom: var(--spacing-md);">
|
||||
<div>
|
||||
<div style="font-size: var(--font-size-2xl); font-weight: 700;">{{ stats.published }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-muted);">opublikowanych</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: var(--font-size-2xl); font-weight: 700;">{{ stats.read_by }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-muted);">czytelników</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 4px; display: flex; justify-content: space-between;">
|
||||
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Open rate</span>
|
||||
<span style="font-size: var(--font-size-sm); font-weight: 600; color: {{ '#16a34a' if stats.open_rate >= 50 else ('#f59e0b' if stats.open_rate >= 25 else '#ef4444') }};">{{ stats.open_rate }}%</span>
|
||||
</div>
|
||||
<div style="height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden;">
|
||||
<div style="height: 100%; width: {{ stats.open_rate }}%; background: {{ color }}; border-radius: 4px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- TAB: PATHS -->
|
||||
<!-- ============================================================ -->
|
||||
@ -929,6 +996,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Referrer Sources + Company Popularity -->
|
||||
<div class="two-columns">
|
||||
<!-- Referrer Sources -->
|
||||
<div class="section-card">
|
||||
<h2>Skąd przychodzą użytkownicy <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
|
||||
{% if data.referrer_sources %}
|
||||
{% for r in data.referrer_sources %}
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
|
||||
<div style="width: 110px; font-size: var(--font-size-sm); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ r.domain }}">{{ r.domain }}</div>
|
||||
<div style="flex: 1; height: 18px; background: #e2e8f0; border-radius: 4px; overflow: hidden;">
|
||||
<div style="height: 100%; width: {{ r.bar_pct }}%; background: #6366f1; border-radius: 4px;"></div>
|
||||
</div>
|
||||
<div style="width: 45px; font-size: var(--font-size-xs); color: var(--text-muted); text-align: right;">{{ r.count }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak danych o źródłach ruchu</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Company Profile Popularity -->
|
||||
<div class="section-card">
|
||||
<h2>Popularność profili firm <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
|
||||
{% if data.company_popularity %}
|
||||
<div class="table-scroll" style="max-height: 350px;">
|
||||
<table class="data-table" style="font-size: var(--font-size-sm);">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Firma</th>
|
||||
<th style="text-align: center;">Odsłony</th>
|
||||
<th style="text-align: center;">Osoby</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in data.company_popularity %}
|
||||
<tr>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">
|
||||
<a href="{{ c.path }}" target="_blank" style="color: var(--primary); text-decoration: none;">{{ c.name }}</a>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<div style="display: flex; align-items: center; gap: 6px; justify-content: center;">
|
||||
<div style="width: 60px; height: 12px; background: #e2e8f0; border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; width: {{ (c.views / data.max_company_views * 100)|int }}%; background: #10b981; border-radius: 3px;"></div>
|
||||
</div>
|
||||
{{ c.views }}
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align: center;">{{ c.unique_viewers }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak danych o profilach firm</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- TAB: CHAT & CONVERSIONS -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
Loading…
Reference in New Issue
Block a user