feat: add Facebook page posts with metrics to Social Publisher
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

Display last 10 posts from connected Facebook page with engagement
data (likes, comments, shares, reactions). On-demand insights button
loads impressions, reach, engaged users, and clicks per post.
In-memory cache with 5-min TTL prevents API rate limit issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-19 16:19:01 +01:00
parent 74203e8ef3
commit f8a8e345ea
4 changed files with 417 additions and 0 deletions

View File

@ -370,6 +370,46 @@ def social_publisher_refresh_engagement(post_id):
return redirect(url_for('admin.social_publisher_edit', post_id=post_id)) return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
# ============================================================
# SOCIAL PUBLISHER - FACEBOOK PAGE POSTS (AJAX)
# ============================================================
@bp.route('/social-publisher/fb-posts/<int:company_id>')
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_fb_posts(company_id):
"""Pobierz ostatnie posty z Facebook Page (AJAX)."""
from services.social_publisher_service import social_publisher
db = SessionLocal()
try:
if not _user_can_access_company(db, company_id):
return jsonify({'success': False, 'error': 'Brak uprawnień.'}), 403
finally:
db.close()
result = social_publisher.get_page_recent_posts(company_id)
return jsonify(result)
@bp.route('/social-publisher/fb-post-insights/<int:company_id>/<path:post_id>')
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_fb_post_insights(company_id, post_id):
"""Pobierz insights dla konkretnego posta (AJAX)."""
from services.social_publisher_service import social_publisher
db = SessionLocal()
try:
if not _user_can_access_company(db, company_id):
return jsonify({'success': False, 'error': 'Brak uprawnień.'}), 403
finally:
db.close()
result = social_publisher.get_post_insights_detail(company_id, post_id)
return jsonify(result)
# ============================================================ # ============================================================
# SOCIAL PUBLISHER - AI GENERATION (AJAX) # SOCIAL PUBLISHER - AI GENERATION (AJAX)
# ============================================================ # ============================================================

View File

@ -273,6 +273,75 @@ class FacebookGraphService:
'reactions_total': result.get('reactions', {}).get('summary', {}).get('total_count', 0), 'reactions_total': result.get('reactions', {}).get('summary', {}).get('total_count', 0),
} }
def get_page_posts(self, page_id: str, limit: int = 10) -> Optional[List[Dict]]:
"""Get recent posts from a Facebook Page with engagement metrics.
Args:
page_id: Facebook Page ID
limit: Number of posts to fetch (max 100)
Returns:
List of post dicts or None on failure
"""
fields = (
'id,message,created_time,full_picture,permalink_url,status_type,'
'likes.summary(true).limit(0),comments.summary(true).limit(0),'
'shares,reactions.summary(true).limit(0)'
)
result = self._get(f'{page_id}/posts', {'fields': fields, 'limit': limit})
if not result:
return None
posts = []
for item in result.get('data', []):
posts.append({
'id': item.get('id'),
'message': item.get('message', ''),
'created_time': item.get('created_time'),
'full_picture': item.get('full_picture'),
'permalink_url': item.get('permalink_url'),
'status_type': item.get('status_type', ''),
'likes': item.get('likes', {}).get('summary', {}).get('total_count', 0),
'comments': item.get('comments', {}).get('summary', {}).get('total_count', 0),
'shares': item.get('shares', {}).get('count', 0) if item.get('shares') else 0,
'reactions_total': item.get('reactions', {}).get('summary', {}).get('total_count', 0),
})
return posts
def get_post_insights_metrics(self, post_id: str) -> Optional[Dict]:
"""Get detailed insights metrics for a specific post.
Note: Only available for posts on Pages with 100+ fans.
Args:
post_id: Facebook post ID
Returns:
Dict with impressions, reach, engaged_users, clicks or None
"""
metrics = 'post_impressions,post_impressions_unique,post_engaged_users,post_clicks'
result = self._get(f'{post_id}/insights', {'metric': metrics})
if not result:
return None
insights = {}
for metric in result.get('data', []):
name = metric.get('name', '')
values = metric.get('values', [])
if values:
# Lifetime metrics have a single value
value = values[0].get('value', 0)
if name == 'post_impressions':
insights['impressions'] = value
elif name == 'post_impressions_unique':
insights['reach'] = value
elif name == 'post_engaged_users':
insights['engaged_users'] = value
elif name == 'post_clicks':
insights['clicks'] = value
return insights if insights else None
def delete_post(self, post_id: str) -> bool: def delete_post(self, post_id: str) -> bool:
"""Delete a post (published or unpublished). """Delete a post (published or unpublished).

View File

@ -9,6 +9,7 @@ Supports per-company Facebook configuration via OAuth tokens.
import logging import logging
import os import os
import re import re
import time
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, List, Tuple from typing import Optional, Dict, List, Tuple
@ -16,6 +17,10 @@ from database import SessionLocal, SocialPost, SocialMediaConfig, Company, Norda
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cache for Facebook page posts (in-memory, per-process)
_posts_cache = {} # (company_id, page_id) -> {'data': [...], 'ts': float}
_CACHE_TTL = 300 # 5 minutes
# Post types supported # Post types supported
POST_TYPES = { POST_TYPES = {
'member_spotlight': 'Poznaj Członka NORDA', 'member_spotlight': 'Poznaj Członka NORDA',
@ -485,6 +490,10 @@ class SocialPublisherService:
post.updated_at = datetime.now() post.updated_at = datetime.now()
db.commit() db.commit()
# Invalidate posts cache for this company
if config.page_id:
_posts_cache.pop((pub_company_id, config.page_id), None)
mode = "LIVE (force)" if force_live else ("DRAFT (debug)" if config.debug_mode else "LIVE") mode = "LIVE (force)" if force_live else ("DRAFT (debug)" if config.debug_mode else "LIVE")
logger.info(f"Published post #{post_id} -> FB {result['id']} ({mode})") logger.info(f"Published post #{post_id} -> FB {result['id']} ({mode})")
return True, f"Post opublikowany ({mode}): {result['id']}" return True, f"Post opublikowany ({mode}): {result['id']}"
@ -592,6 +601,70 @@ class SocialPublisherService:
finally: finally:
db.close() db.close()
# ---- Facebook Page Posts (read from API) ----
def get_page_recent_posts(self, company_id: int, limit: int = 10) -> Dict:
"""Fetch recent posts from company's Facebook page with engagement metrics.
Uses in-memory cache with 5-minute TTL.
"""
db = SessionLocal()
try:
access_token, config = self._get_publish_token(db, company_id)
if not access_token or not config or not config.page_id:
return {'success': False, 'error': 'Brak konfiguracji Facebook dla tej firmy.'}
page_id = config.page_id
cache_key = (company_id, page_id)
# Check cache
cached = _posts_cache.get(cache_key)
if cached and (time.time() - cached['ts']) < _CACHE_TTL:
return {
'success': True,
'posts': cached['data'],
'page_name': config.page_name or '',
'cached': True,
}
from facebook_graph_service import FacebookGraphService
fb = FacebookGraphService(access_token)
posts = fb.get_page_posts(page_id, limit)
if posts is None:
return {'success': False, 'error': 'Nie udało się pobrać postów z Facebook API.'}
# Update cache
_posts_cache[cache_key] = {'data': posts, 'ts': time.time()}
return {
'success': True,
'posts': posts,
'page_name': config.page_name or '',
'cached': False,
}
finally:
db.close()
def get_post_insights_detail(self, company_id: int, post_id: str) -> Dict:
"""Fetch detailed insights for a specific post (impressions, reach, clicks)."""
db = SessionLocal()
try:
access_token, config = self._get_publish_token(db, company_id)
if not access_token:
return {'success': False, 'error': 'Brak tokena Facebook.'}
from facebook_graph_service import FacebookGraphService
fb = FacebookGraphService(access_token)
insights = fb.get_post_insights_metrics(post_id)
if insights is None:
return {'success': False, 'error': 'Brak danych insights (strona może mieć <100 fanów).'}
return {'success': True, 'insights': insights}
finally:
db.close()
# ---- AI Content Generation ---- # ---- AI Content Generation ----
@staticmethod @staticmethod

View File

@ -175,7 +175,122 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Facebook Page Posts cards */
.fb-posts-section {
margin-bottom: var(--spacing-lg);
}
.fb-posts-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md);
}
.fb-posts-header h3 {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
}
.fb-post-card {
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--spacing-sm);
background: var(--surface);
transition: box-shadow 0.2s;
}
.fb-post-card:hover {
box-shadow: var(--shadow-sm);
}
.fb-post-thumb {
flex-shrink: 0;
width: 120px;
height: 90px;
border-radius: var(--radius-sm);
object-fit: cover;
background: var(--background);
}
.fb-post-body {
flex: 1;
min-width: 0;
}
.fb-post-date {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-bottom: 4px;
}
.fb-post-text {
font-size: var(--font-size-sm);
color: var(--text-primary);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
margin-bottom: var(--spacing-xs);
}
.fb-post-metrics {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.fb-post-metrics span {
display: inline-flex;
align-items: center;
gap: 3px;
}
.fb-post-actions {
display: flex;
gap: var(--spacing-xs);
margin-top: var(--spacing-xs);
align-items: center;
}
.fb-post-actions a,
.fb-post-actions button {
font-size: var(--font-size-xs);
}
.fb-post-insights {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
margin-top: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--background);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.fb-post-insights span {
display: inline-flex;
align-items: center;
gap: 3px;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.fb-post-card {
flex-direction: column;
}
.fb-post-thumb {
width: 100%;
height: 160px;
}
.posts-table { .posts-table {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
@ -304,6 +419,17 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
<!-- Ostatnie posty z Facebook -->
{% for company_id_key, fb in fb_stats.items() %}
<div class="fb-posts-section" id="fbPostsSection-{{ company_id_key }}">
<div class="fb-posts-header">
<h3>Ostatnie posty na Facebook</h3>
<button class="btn btn-secondary btn-small" onclick="loadFbPosts({{ company_id_key }}, this)">Zaladuj posty</button>
</div>
<div id="fbPostsContainer-{{ company_id_key }}"></div>
</div>
{% endfor %}
{% endif %} {% endif %}
<!-- Filtry --> <!-- Filtry -->
@ -556,6 +682,115 @@
} }
} }
function formatFbDate(isoStr) {
if (!isoStr) return '';
var d = new Date(isoStr);
return d.toLocaleDateString('pl-PL', {day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'});
}
function loadFbPosts(companyId, btn) {
var origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Ladowanie...';
var container = document.getElementById('fbPostsContainer-' + companyId);
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Pobieranie postow z Facebook API...</div>';
fetch('/admin/social-publisher/fb-posts/' + companyId)
.then(function(r) { return r.json(); })
.then(function(data) {
btn.textContent = origText;
btn.disabled = false;
if (!data.success) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--error);">' + (data.error || 'Blad') + '</div>';
return;
}
if (!data.posts || data.posts.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Brak postow na stronie.</div>';
return;
}
var html = '';
data.posts.forEach(function(post) {
html += '<div class="fb-post-card">';
if (post.full_picture) {
html += '<img class="fb-post-thumb" src="' + post.full_picture + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
}
html += '<div class="fb-post-body">';
html += '<div class="fb-post-date">' + formatFbDate(post.created_time);
if (post.status_type) html += ' &middot; ' + post.status_type;
html += '</div>';
if (post.message) {
html += '<div class="fb-post-text">' + post.message.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</div>';
} else {
html += '<div class="fb-post-text" style="font-style:italic;color:var(--text-secondary);">(post bez tekstu)</div>';
}
html += '<div class="fb-post-metrics">';
html += '<span title="Polubienia">&#128077; ' + (post.likes || 0) + '</span>';
html += '<span title="Komentarze">&#128172; ' + (post.comments || 0) + '</span>';
html += '<span title="Udostepnienia">&#128257; ' + (post.shares || 0) + '</span>';
html += '<span title="Reakcje">&#10084;&#65039; ' + (post.reactions_total || 0) + '</span>';
html += '</div>';
html += '<div class="fb-post-actions">';
if (post.permalink_url) {
html += '<a href="' + post.permalink_url + '" target="_blank" rel="noopener" class="btn btn-secondary btn-small" style="font-size:11px;">Zobacz na FB</a>';
}
html += '<button class="btn btn-secondary btn-small" style="font-size:11px;" onclick="loadPostInsights(' + companyId + ', \'' + post.id + '\', this)">Insights</button>';
html += '</div>';
html += '<div id="insights-' + post.id.replace(/\./g, '-') + '" style="display:none;"></div>';
html += '</div></div>';
});
if (data.cached) {
html += '<div style="text-align:right;font-size:11px;color:var(--text-secondary);margin-top:4px;">Dane z cache</div>';
}
container.innerHTML = html;
})
.catch(function(err) {
btn.textContent = origText;
btn.disabled = false;
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--error);">Blad polaczenia: ' + err.message + '</div>';
});
}
function loadPostInsights(companyId, postId, btn) {
var safeId = postId.replace(/\./g, '-');
var container = document.getElementById('insights-' + safeId);
if (!container) return;
if (container.style.display !== 'none') {
container.style.display = 'none';
btn.textContent = 'Insights';
return;
}
btn.disabled = true;
btn.textContent = 'Ladowanie...';
container.innerHTML = '<div class="fb-post-insights">Pobieranie insights...</div>';
container.style.display = 'block';
fetch('/admin/social-publisher/fb-post-insights/' + companyId + '/' + postId)
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
btn.textContent = 'Insights';
if (!data.success) {
container.innerHTML = '<div class="fb-post-insights" style="color:var(--text-secondary);">' + (data.error || 'Brak danych') + '</div>';
return;
}
var ins = data.insights;
var html = '<div class="fb-post-insights">';
html += '<span title="Wyswietlenia">&#128065; Wyswietlenia: <strong>' + (ins.impressions != null ? ins.impressions : '-') + '</strong></span>';
html += '<span title="Zasieg">&#128202; Zasieg: <strong>' + (ins.reach != null ? ins.reach : '-') + '</strong></span>';
html += '<span title="Zaangazowani">&#128101; Zaangazowani: <strong>' + (ins.engaged_users != null ? ins.engaged_users : '-') + '</strong></span>';
html += '<span title="Klikniecia">&#128433;&#65039; Klikniecia: <strong>' + (ins.clicks != null ? ins.clicks : '-') + '</strong></span>';
html += '</div>';
container.innerHTML = html;
})
.catch(function(err) {
btn.disabled = false;
btn.textContent = 'Insights';
container.innerHTML = '<div class="fb-post-insights" style="color:var(--error);">Blad: ' + err.message + '</div>';
});
}
function syncFacebookData(companyId, btn) { function syncFacebookData(companyId, btn) {
var origText = btn.innerHTML; var origText = btn.innerHTML;
btn.disabled = true; btn.disabled = true;