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))
# ============================================================
# 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)
# ============================================================

View File

@ -273,6 +273,75 @@ class FacebookGraphService:
'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:
"""Delete a post (published or unpublished).

View File

@ -9,6 +9,7 @@ Supports per-company Facebook configuration via OAuth tokens.
import logging
import os
import re
import time
from datetime import datetime
from typing import Optional, Dict, List, Tuple
@ -16,6 +17,10 @@ from database import SessionLocal, SocialPost, SocialMediaConfig, Company, Norda
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 = {
'member_spotlight': 'Poznaj Członka NORDA',
@ -485,6 +490,10 @@ class SocialPublisherService:
post.updated_at = datetime.now()
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")
logger.info(f"Published post #{post_id} -> FB {result['id']} ({mode})")
return True, f"Post opublikowany ({mode}): {result['id']}"
@ -592,6 +601,70 @@ class SocialPublisherService:
finally:
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 ----
@staticmethod

View File

@ -175,7 +175,122 @@
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) {
.fb-post-card {
flex-direction: column;
}
.fb-post-thumb {
width: 100%;
height: 160px;
}
.posts-table {
font-size: var(--font-size-sm);
}
@ -304,6 +419,17 @@
</div>
</div>
{% 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 %}
<!-- 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) {
var origText = btn.innerHTML;
btn.disabled = true;