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
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:
parent
74203e8ef3
commit
f8a8e345ea
@ -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)
|
||||
# ============================================================
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 += ' · ' + post.status_type;
|
||||
html += '</div>';
|
||||
if (post.message) {
|
||||
html += '<div class="fb-post-text">' + post.message.replace(/</g, '<').replace(/>/g, '>') + '</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">👍 ' + (post.likes || 0) + '</span>';
|
||||
html += '<span title="Komentarze">💬 ' + (post.comments || 0) + '</span>';
|
||||
html += '<span title="Udostepnienia">🔁 ' + (post.shares || 0) + '</span>';
|
||||
html += '<span title="Reakcje">❤️ ' + (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">👁 Wyswietlenia: <strong>' + (ins.impressions != null ? ins.impressions : '-') + '</strong></span>';
|
||||
html += '<span title="Zasieg">📊 Zasieg: <strong>' + (ins.reach != null ? ins.reach : '-') + '</strong></span>';
|
||||
html += '<span title="Zaangazowani">👥 Zaangazowani: <strong>' + (ins.engaged_users != null ? ins.engaged_users : '-') + '</strong></span>';
|
||||
html += '<span title="Klikniecia">🖱️ 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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user