feat: sync Facebook OAuth stats to company social media profiles
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
After connecting a FB page via OAuth, automatically fetch page stats (followers, engagement, bio) from Graph API and persist to CompanySocialMedia table. Adds manual refresh endpoint and UI badge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1cbbb6fc80
commit
592ceff30d
@ -380,6 +380,17 @@ def oauth_select_fb_page():
|
||||
db.commit()
|
||||
logger.info(f"FB page selected: {page_name} (ID: {page_id}) for company {company_id}")
|
||||
|
||||
# Sync Facebook stats to CompanySocialMedia (non-blocking)
|
||||
sync_data = None
|
||||
try:
|
||||
from facebook_graph_service import sync_facebook_to_social_media
|
||||
sync_result = sync_facebook_to_social_media(db, company_id)
|
||||
logger.info(f"FB sync after page select: {sync_result}")
|
||||
if sync_result.get('success'):
|
||||
sync_data = sync_result.get('data')
|
||||
except Exception as e:
|
||||
logger.error(f"FB sync error (non-blocking): {e}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Strona "{page_name}" połączona',
|
||||
@ -387,7 +398,8 @@ def oauth_select_fb_page():
|
||||
'id': page_id,
|
||||
'name': page_name,
|
||||
'category': selected.get('category'),
|
||||
}
|
||||
},
|
||||
'sync': sync_data,
|
||||
})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@ -395,3 +407,36 @@ def oauth_select_fb_page():
|
||||
return jsonify({'success': False, 'error': 'Błąd zapisu konfiguracji strony'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/oauth/meta/sync-facebook', methods=['POST'])
|
||||
@login_required
|
||||
def oauth_sync_facebook_data():
|
||||
"""Manually refresh Facebook page stats for a company.
|
||||
|
||||
POST /api/oauth/meta/sync-facebook
|
||||
Body: {"company_id": 123}
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data or not data.get('company_id'):
|
||||
return jsonify({'success': False, 'error': 'company_id jest wymagany'}), 400
|
||||
|
||||
company_id = data['company_id']
|
||||
|
||||
# Permission check: user must be able to edit this company
|
||||
if not current_user.can_edit_company(company_id):
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from facebook_graph_service import sync_facebook_to_social_media
|
||||
result = sync_facebook_to_social_media(db, company_id)
|
||||
if result.get('success'):
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
logger.error(f"FB manual sync error: {e}")
|
||||
return jsonify({'success': False, 'error': 'Błąd synchronizacji danych Facebook'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@ -284,3 +284,129 @@ class FacebookGraphService:
|
||||
"""
|
||||
result = self._delete(post_id)
|
||||
return bool(result and result.get('success'))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SYNC: Facebook Page → CompanySocialMedia
|
||||
# ============================================================
|
||||
|
||||
def sync_facebook_to_social_media(db, company_id: int) -> dict:
|
||||
"""Fetch Facebook page stats via Graph API and upsert into CompanySocialMedia.
|
||||
|
||||
Requires an active OAuth token and SocialMediaConfig for the company.
|
||||
Called automatically after page selection and manually via sync endpoint.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session
|
||||
company_id: Company ID to sync data for
|
||||
|
||||
Returns:
|
||||
dict with 'success' bool and either 'data' or 'error'
|
||||
"""
|
||||
from oauth_service import OAuthService
|
||||
from database import SocialMediaConfig, CompanySocialMedia
|
||||
|
||||
# 1. Get valid page access token
|
||||
oauth = OAuthService()
|
||||
token = oauth.get_valid_token(db, company_id, 'meta', 'facebook')
|
||||
if not token:
|
||||
return {'success': False, 'error': 'no_token', 'message': 'Brak aktywnego tokenu Facebook'}
|
||||
|
||||
# 2. Get page_id from SocialMediaConfig
|
||||
config = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.company_id == company_id,
|
||||
SocialMediaConfig.is_active == True,
|
||||
).first()
|
||||
|
||||
if not config or not config.page_id:
|
||||
return {'success': False, 'error': 'no_page', 'message': 'Brak skonfigurowanej strony Facebook'}
|
||||
|
||||
page_id = config.page_id
|
||||
|
||||
# 3. Fetch page info from Graph API
|
||||
fb = FacebookGraphService(token)
|
||||
page_info = fb.get_page_info(page_id)
|
||||
|
||||
if not page_info:
|
||||
return {'success': False, 'error': 'api_failed', 'message': 'Nie udało się pobrać danych strony z Facebook API'}
|
||||
|
||||
# 4. Fetch page insights (best-effort, may be empty)
|
||||
insights = fb.get_page_insights(page_id, 28)
|
||||
|
||||
# 5. Calculate metrics
|
||||
followers = page_info.get('followers_count') or page_info.get('fan_count') or 0
|
||||
engaged_users = insights.get('page_engaged_users', 0)
|
||||
engagement_rate = None
|
||||
if followers > 0 and engaged_users > 0:
|
||||
engagement_rate = round((engaged_users / followers) * 100, 2)
|
||||
|
||||
# Profile completeness: 25 points each for about, website, phone, address
|
||||
completeness = 0
|
||||
if page_info.get('about'):
|
||||
completeness += 25
|
||||
if page_info.get('website'):
|
||||
completeness += 25
|
||||
if page_info.get('phone'):
|
||||
completeness += 25
|
||||
if page_info.get('single_line_address'):
|
||||
completeness += 25
|
||||
|
||||
page_url = page_info.get('link') or f"https://facebook.com/{page_id}"
|
||||
|
||||
# 6. Upsert CompanySocialMedia record
|
||||
# Look for existing Facebook record for this company (any URL)
|
||||
existing = db.query(CompanySocialMedia).filter(
|
||||
CompanySocialMedia.company_id == company_id,
|
||||
CompanySocialMedia.platform == 'facebook',
|
||||
).first()
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
if existing:
|
||||
csm = existing
|
||||
csm.url = page_url
|
||||
else:
|
||||
csm = CompanySocialMedia(
|
||||
company_id=company_id,
|
||||
platform='facebook',
|
||||
url=page_url,
|
||||
)
|
||||
db.add(csm)
|
||||
|
||||
csm.source = 'facebook_api'
|
||||
csm.is_valid = True
|
||||
csm.check_status = 'ok'
|
||||
csm.page_name = page_info.get('name', '')
|
||||
csm.followers_count = followers if followers > 0 else csm.followers_count
|
||||
csm.has_bio = bool(page_info.get('about'))
|
||||
csm.profile_description = (page_info.get('about') or '')[:500]
|
||||
if engagement_rate is not None:
|
||||
csm.engagement_rate = engagement_rate
|
||||
csm.profile_completeness_score = completeness
|
||||
csm.verified_at = now
|
||||
csm.last_checked_at = now
|
||||
|
||||
# Append to followers_history
|
||||
history = csm.followers_history or []
|
||||
if followers > 0:
|
||||
today_str = now.strftime('%Y-%m-%d')
|
||||
# Don't duplicate entries for the same day
|
||||
if not history or history[-1].get('date') != today_str:
|
||||
history.append({'date': today_str, 'count': followers})
|
||||
csm.followers_history = history
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"FB sync OK for company {company_id}: {followers} followers, engagement={engagement_rate}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'data': {
|
||||
'page_name': csm.page_name,
|
||||
'followers_count': followers,
|
||||
'engagement_rate': engagement_rate,
|
||||
'profile_completeness_score': completeness,
|
||||
'source': 'facebook_api',
|
||||
}
|
||||
}
|
||||
|
||||
@ -2582,6 +2582,12 @@
|
||||
<svg width="14" height="14" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
Aktywny profil
|
||||
</div>
|
||||
{% if sm.source == 'facebook_api' %}
|
||||
<div style="color: #1877f2; font-size: var(--font-size-xs); display: flex; align-items: center; gap: 4px; margin-top: 2px;">
|
||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
Dane z Facebook API
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if platform == 'linkedin' and (can_edit_profile or is_admin) %}
|
||||
{% if '/in/' in sm.url %}
|
||||
<div style="color: #d97706; font-size: var(--font-size-xs); display: flex; align-items: center; gap: 4px; margin-top: 2px;">
|
||||
@ -2631,6 +2637,14 @@
|
||||
{% if platform == 'youtube' %}Subskrybentów{% else %}Obserwujących{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if sm.engagement_rate %}
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: var(--font-size-xl); font-weight: 700; color: #10b981;">
|
||||
{{ sm.engagement_rate }}%
|
||||
</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Engagement</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if sm.total_posts %}
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: var(--font-size-xl); font-weight: 700; color: var(--text-primary);">
|
||||
@ -2645,6 +2659,17 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Refresh button for Facebook API connected profiles -->
|
||||
{% if sm.source == 'facebook_api' and (can_edit_profile or is_admin) %}
|
||||
<button onclick="syncFacebookData({{ company.id }}, this)" style="display: block; width: 100%; text-align: center; padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: transparent; color: #1877f2; border: 1px solid #1877f2; border-radius: var(--radius);
|
||||
font-size: var(--font-size-xs); cursor: pointer; margin-bottom: var(--spacing-xs);
|
||||
transition: background 0.2s;">
|
||||
<svg width="14" height="14" fill="currentColor" viewBox="0 0 20 20" style="vertical-align: middle; margin-right: 4px;"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
|
||||
Odśwież dane z API
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Visit Button -->
|
||||
<a href="{{ sm.url }}" target="_blank" rel="noopener noreferrer"
|
||||
style="display: block; text-align: center; padding: var(--spacing-sm) var(--spacing-md);
|
||||
@ -4840,4 +4865,41 @@ function updateLogoStep(stepId, status, message) {
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function syncFacebookData(companyId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Synchronizuję...';
|
||||
fetch('/api/oauth/meta/sync-facebook', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''},
|
||||
body: JSON.stringify({company_id: companyId})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
btn.textContent = 'Zaktualizowano!';
|
||||
btn.style.background = '#10b981';
|
||||
btn.style.color = 'white';
|
||||
btn.style.borderColor = '#10b981';
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
btn.textContent = data.message || 'Błąd synchronizacji';
|
||||
btn.style.borderColor = '#dc2626';
|
||||
btn.style.color = '#dc2626';
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Odśwież dane z API';
|
||||
btn.disabled = false;
|
||||
btn.style.borderColor = '#1877f2';
|
||||
btn.style.color = '#1877f2';
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btn.textContent = 'Błąd połączenia';
|
||||
btn.disabled = false;
|
||||
setTimeout(() => { btn.textContent = 'Odśwież dane z API'; }, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -860,6 +860,14 @@
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if profile.source == 'facebook_api' %}
|
||||
<div style="margin: 6px 0; padding: 8px 12px; background: rgba(24, 119, 242, 0.08); border-left: 3px solid #1877f2; border-radius: 4px; font-size: 12px; color: #1e40af;">
|
||||
<svg width="14" height="14" fill="#1877f2" viewBox="0 0 20 20" style="vertical-align: middle; margin-right: 4px;"><path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
<strong>Dane z Facebook API</strong>
|
||||
{% if profile.engagement_rate is not none %} · Engagement: {{ '%.1f'|format(profile.engagement_rate) }}%{% endif %}
|
||||
{% if profile.profile_completeness_score %} · Kompletność profilu: {{ profile.profile_completeness_score }}%{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="platform-meta">
|
||||
{% if profile.page_name %}
|
||||
<div class="platform-meta-item">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user