diff --git a/blueprints/admin/routes_social_publisher.py b/blueprints/admin/routes_social_publisher.py index 10fdbb2..71c824c 100644 --- a/blueprints/admin/routes_social_publisher.py +++ b/blueprints/admin/routes_social_publisher.py @@ -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/') +@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//') +@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) # ============================================================ diff --git a/facebook_graph_service.py b/facebook_graph_service.py index 923f8d3..cd08435 100644 --- a/facebook_graph_service.py +++ b/facebook_graph_service.py @@ -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). diff --git a/services/social_publisher_service.py b/services/social_publisher_service.py index 102a427..5e39ad1 100644 --- a/services/social_publisher_service.py +++ b/services/social_publisher_service.py @@ -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 diff --git a/templates/admin/social_publisher.html b/templates/admin/social_publisher.html index b602da2..95e8cba 100644 --- a/templates/admin/social_publisher.html +++ b/templates/admin/social_publisher.html @@ -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 @@ {% endfor %} + + + {% for company_id_key, fb in fb_stats.items() %} +
+
+

Ostatnie posty na Facebook

+ +
+
+
+ {% endfor %} {% endif %} @@ -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 = '
Pobieranie postow z Facebook API...
'; + + 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 = '
' + (data.error || 'Blad') + '
'; + return; + } + if (!data.posts || data.posts.length === 0) { + container.innerHTML = '
Brak postow na stronie.
'; + return; + } + var html = ''; + data.posts.forEach(function(post) { + html += '
'; + if (post.full_picture) { + html += ''; + } + html += '
'; + html += ''; + if (post.message) { + html += '
' + post.message.replace(//g, '>') + '
'; + } else { + html += '
(post bez tekstu)
'; + } + html += '
'; + html += '👍 ' + (post.likes || 0) + ''; + html += '💬 ' + (post.comments || 0) + ''; + html += '🔁 ' + (post.shares || 0) + ''; + html += '❤️ ' + (post.reactions_total || 0) + ''; + html += '
'; + html += '
'; + if (post.permalink_url) { + html += 'Zobacz na FB'; + } + html += ''; + html += '
'; + html += ''; + html += '
'; + }); + if (data.cached) { + html += '
Dane z cache
'; + } + container.innerHTML = html; + }) + .catch(function(err) { + btn.textContent = origText; + btn.disabled = false; + container.innerHTML = '
Blad polaczenia: ' + err.message + '
'; + }); + } + + 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 = '
Pobieranie insights...
'; + 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 = '
' + (data.error || 'Brak danych') + '
'; + return; + } + var ins = data.insights; + var html = '
'; + html += '👁 Wyswietlenia: ' + (ins.impressions != null ? ins.impressions : '-') + ''; + html += '📊 Zasieg: ' + (ins.reach != null ? ins.reach : '-') + ''; + html += '👥 Zaangazowani: ' + (ins.engaged_users != null ? ins.engaged_users : '-') + ''; + html += '🖱️ Klikniecia: ' + (ins.clicks != null ? ins.clicks : '-') + ''; + html += '
'; + container.innerHTML = html; + }) + .catch(function(err) { + btn.disabled = false; + btn.textContent = 'Insights'; + container.innerHTML = '
Blad: ' + err.message + '
'; + }); + } + function syncFacebookData(companyId, btn) { var origText = btn.innerHTML; btn.disabled = true;