nordabiz/blueprints/admin/routes_social_publisher.py
Maciej Pienczyn 779f0b0b73
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
feat: add cursor-based pagination to Facebook posts API
Previously get_page_posts returned a flat list with no pagination support.
Now returns dict with posts and next_cursor, enabling infinite scrolling
through all Facebook page posts via the after query parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:51:31 +01:00

604 lines
24 KiB
Python

"""
Admin Social Publisher Routes
==============================
Social media publishing management with per-company Facebook configuration.
"""
import logging
import os
from datetime import datetime
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from database import (SessionLocal, SocialPost, SocialMediaConfig, Company,
NordaEvent, SystemRole, OAuthToken, UserCompanyPermissions)
from utils.decorators import role_required
logger = logging.getLogger(__name__)
def _is_admin():
"""Check if current user is ADMIN or higher."""
return current_user.system_role >= SystemRole.ADMIN
def _get_user_company_ids(db):
"""Get list of company IDs the current user has access to."""
if _is_admin():
return None # None = all companies
company_ids = set()
if current_user.company_id:
company_ids.add(current_user.company_id)
perms = db.query(UserCompanyPermissions).filter(
UserCompanyPermissions.user_id == current_user.id
).all()
for p in perms:
company_ids.add(p.company_id)
return list(company_ids)
def _get_user_companies(db):
"""Get companies the current user has access to for social publishing."""
company_ids = _get_user_company_ids(db)
if company_ids is None:
return db.query(Company).filter(Company.status == 'active').order_by(Company.name).all()
if not company_ids:
return []
return db.query(Company).filter(
Company.id.in_(company_ids),
Company.status == 'active'
).order_by(Company.name).all()
def _user_can_access_company(db, company_id):
"""Check if current user can manage social publisher for a company."""
if _is_admin():
return True
company_ids = _get_user_company_ids(db)
return company_ids and company_id in company_ids
# ============================================================
# SOCIAL PUBLISHER - LIST & DASHBOARD
# ============================================================
@bp.route('/social-publisher')
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_list():
"""Lista postow social media z filtrami."""
from services.social_publisher_service import social_publisher, POST_TYPES
status_filter = request.args.get('status', 'all')
type_filter = request.args.get('type', 'all')
company_filter = request.args.get('company', 'all')
db = SessionLocal()
try:
query = db.query(SocialPost).order_by(SocialPost.created_at.desc())
# Manager widzi tylko posty swojej firmy
user_company_ids = _get_user_company_ids(db)
if user_company_ids is not None:
query = query.filter(SocialPost.publishing_company_id.in_(user_company_ids))
if status_filter != 'all':
query = query.filter(SocialPost.status == status_filter)
if type_filter != 'all':
query = query.filter(SocialPost.post_type == type_filter)
if company_filter != 'all':
try:
query = query.filter(SocialPost.publishing_company_id == int(company_filter))
except (ValueError, TypeError):
pass
posts = query.limit(100).all()
stats = social_publisher.get_stats()
configured_companies = social_publisher.get_configured_companies(user_company_ids)
# Load Facebook API stats for connected companies
fb_stats = {}
if user_company_ids:
from database import CompanySocialMedia
fb_profiles = db.query(CompanySocialMedia).filter(
CompanySocialMedia.company_id.in_(user_company_ids),
CompanySocialMedia.platform == 'facebook',
CompanySocialMedia.source == 'facebook_api',
).all()
for fp in fb_profiles:
fb_stats[fp.company_id] = fp
return render_template('admin/social_publisher.html',
posts=posts,
stats=stats,
post_types=POST_TYPES,
status_filter=status_filter,
type_filter=type_filter,
company_filter=company_filter,
configured_companies=configured_companies,
fb_stats=fb_stats)
finally:
db.close()
# ============================================================
# SOCIAL PUBLISHER - CREATE / EDIT FORM
# ============================================================
@bp.route('/social-publisher/new', methods=['GET', 'POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_new():
"""Tworzenie nowego posta."""
from services.social_publisher_service import social_publisher, POST_TYPES, POST_TONES, DEFAULT_TONE, AI_MODELS, DEFAULT_AI_MODEL
db = SessionLocal()
try:
user_company_ids = _get_user_company_ids(db)
companies = _get_user_companies(db)
events = db.query(NordaEvent).order_by(NordaEvent.event_date.desc()).limit(20).all()
configured_companies = social_publisher.get_configured_companies(user_company_ids)
if request.method == 'POST':
action = request.form.get('action', 'draft')
post_type = request.form.get('post_type', 'chamber_news')
content = request.form.get('content', '').strip()
hashtags = request.form.get('hashtags', '').strip()
company_id = request.form.get('company_id', type=int)
event_id = request.form.get('event_id', type=int)
publishing_company_id = request.form.get('publishing_company_id', type=int)
# Default to first company if not selected
if not publishing_company_id and companies:
publishing_company_id = companies[0].id
if not content:
flash('Treść posta jest wymagana.', 'danger')
return render_template('admin/social_publisher_form.html',
post=None, companies=companies, events=events,
post_types=POST_TYPES, post_tones=POST_TONES, default_tone=DEFAULT_TONE,
ai_models=AI_MODELS, default_ai_model=DEFAULT_AI_MODEL,
configured_companies=configured_companies)
post = social_publisher.create_post(
post_type=post_type,
content=content,
hashtags=hashtags or None,
company_id=company_id,
event_id=event_id,
publishing_company_id=publishing_company_id,
user_id=current_user.id,
)
if action in ('publish', 'publish_live'):
force_live = (action == 'publish_live')
success, message = social_publisher.publish_post(post.id, force_live=force_live)
flash(message, 'success' if success else 'danger')
else:
flash('Post utworzony (szkic).', 'success')
return redirect(url_for('admin.social_publisher_edit', post_id=post.id))
return render_template('admin/social_publisher_form.html',
post=None, companies=companies, events=events,
post_types=POST_TYPES, post_tones=POST_TONES, default_tone=DEFAULT_TONE,
ai_models=AI_MODELS, default_ai_model=DEFAULT_AI_MODEL,
configured_companies=configured_companies)
finally:
db.close()
@bp.route('/social-publisher/<int:post_id>/edit', methods=['GET', 'POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_edit(post_id):
"""Edycja istniejacego posta."""
from services.social_publisher_service import social_publisher, POST_TYPES, POST_TONES, DEFAULT_TONE, AI_MODELS, DEFAULT_AI_MODEL
db = SessionLocal()
try:
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
if not post:
flash('Post nie znaleziony.', 'danger')
return redirect(url_for('admin.social_publisher_list'))
# Manager moze edytowac tylko posty swojej firmy
user_company_ids = _get_user_company_ids(db)
if user_company_ids is not None and post.publishing_company_id not in user_company_ids:
flash('Nie masz uprawnień do edycji tego posta.', 'danger')
return redirect(url_for('admin.social_publisher_list'))
companies = _get_user_companies(db)
events = db.query(NordaEvent).order_by(NordaEvent.event_date.desc()).limit(20).all()
configured_companies = social_publisher.get_configured_companies(user_company_ids)
if request.method == 'POST':
action = request.form.get('action', 'save')
# Handle non-edit actions
if action == 'approve':
result = social_publisher.approve_post(post_id, current_user.id)
flash('Post zatwierdzony.' if result else 'Nie można zatwierdzić posta.',
'success' if result else 'danger')
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
if action in ('publish', 'publish_live'):
force_live = (action == 'publish_live')
success, message = social_publisher.publish_post(post_id, force_live=force_live)
flash(message, 'success' if success else 'danger')
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
if action == 'delete':
if social_publisher.delete_post(post_id):
flash('Post usunięty.', 'success')
return redirect(url_for('admin.social_publisher_list'))
flash('Nie można usunąć posta.', 'danger')
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
# Default: save/update
content = request.form.get('content', '').strip()
hashtags = request.form.get('hashtags', '').strip()
post_type = request.form.get('post_type')
company_id = request.form.get('company_id', type=int)
event_id = request.form.get('event_id', type=int)
publishing_company_id = request.form.get('publishing_company_id', type=int)
if not content:
flash('Treść posta jest wymagana.', 'danger')
return render_template('admin/social_publisher_form.html',
post=post, companies=companies, events=events,
post_types=POST_TYPES, post_tones=POST_TONES, default_tone=DEFAULT_TONE,
ai_models=AI_MODELS, default_ai_model=DEFAULT_AI_MODEL,
configured_companies=configured_companies)
social_publisher.update_post(
post_id=post_id,
content=content,
hashtags=hashtags or None,
post_type=post_type,
company_id=company_id,
event_id=event_id,
publishing_company_id=publishing_company_id,
)
flash('Post zaktualizowany.', 'success')
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
return render_template('admin/social_publisher_form.html',
post=post, companies=companies, events=events,
post_types=POST_TYPES, post_tones=POST_TONES, default_tone=DEFAULT_TONE,
ai_models=AI_MODELS, default_ai_model=DEFAULT_AI_MODEL,
configured_companies=configured_companies)
finally:
db.close()
# ============================================================
# SOCIAL PUBLISHER - ACTIONS
# ============================================================
@bp.route('/social-publisher/<int:post_id>/approve', methods=['POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_approve(post_id):
"""Zatwierdz post."""
from services.social_publisher_service import social_publisher
result = social_publisher.approve_post(post_id, current_user.id)
if result:
flash('Post zatwierdzony.', 'success')
else:
flash('Nie można zatwierdzić posta.', 'danger')
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
@bp.route('/social-publisher/<int:post_id>/publish', methods=['POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_publish(post_id):
"""Opublikuj post na Facebook."""
from services.social_publisher_service import social_publisher
success, message = social_publisher.publish_post(post_id)
# AJAX response
if request.headers.get('X-CSRFToken') and not request.form:
return jsonify({'success': success, 'message': message, 'error': None if success else message})
flash(message, 'success' if success else 'danger')
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
@bp.route('/social-publisher/<int:post_id>/delete', methods=['POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_delete(post_id):
"""Usuń post."""
from services.social_publisher_service import social_publisher
success = social_publisher.delete_post(post_id)
# AJAX response
if request.headers.get('X-CSRFToken') and not request.form:
return jsonify({'success': success, 'error': None if success else 'Nie można usunąć posta.'})
flash('Post usunięty.' if success else 'Nie można usunąć posta.', 'success' if success else 'danger')
return redirect(url_for('admin.social_publisher_list'))
@bp.route('/social-publisher/<int:post_id>/toggle-visibility', methods=['POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_toggle_visibility(post_id):
"""Opublikuj draft post publicznie na Facebook (debug -> live)."""
from services.social_publisher_service import social_publisher
success, message = social_publisher.toggle_visibility(post_id)
flash(message, 'success' if success else 'danger')
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
@bp.route('/social-publisher/<int:post_id>/withdraw', methods=['POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_withdraw(post_id):
"""Usun post z Facebooka i przywroc do szkicu."""
from services.social_publisher_service import social_publisher
success, message = social_publisher.withdraw_from_fb(post_id)
flash(message, 'success' if success else 'danger')
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
@bp.route('/social-publisher/<int:post_id>/refresh-engagement', methods=['POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_refresh_engagement(post_id):
"""Odswiez metryki engagement z Facebook."""
from services.social_publisher_service import social_publisher
result = social_publisher.refresh_engagement(post_id)
if result:
flash('Engagement zaktualizowany.', 'success')
else:
flash('Nie udało się pobrać engagement.', 'warning')
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()
after = request.args.get('after')
result = social_publisher.get_page_recent_posts(company_id, after=after)
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)
# ============================================================
@bp.route('/social-publisher/generate', methods=['POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_generate():
"""Generuj tresc posta AI (AJAX endpoint)."""
from services.social_publisher_service import social_publisher
post_type = request.json.get('post_type')
company_id = request.json.get('company_id')
event_id = request.json.get('event_id')
tone = request.json.get('tone', '')
ai_model = request.json.get('ai_model', '')
custom_context = request.json.get('custom_context', {})
try:
# Build context
context = {}
if company_id:
context.update(social_publisher.get_company_context(int(company_id)))
if event_id:
context.update(social_publisher.get_event_context(int(event_id)))
context.update(custom_context)
# Fill defaults for missing fields
defaults = {
'company_name': '', 'category': '', 'city': 'Wejherowo',
'description': '', 'website': '', 'social_media_links': '',
'event_title': '', 'event_date': '', 'event_location': '',
'event_description': '', 'event_topics': '', 'attendees_count': '',
'topic': '', 'source': '', 'facts': '', 'details': '',
}
for key, default in defaults.items():
context.setdefault(key, default)
pub_company_id = request.json.get('publishing_company_id')
content, hashtags, model = social_publisher.generate_content(
post_type, context, tone=tone, ai_model=ai_model,
user_id=current_user.id,
company_id=int(pub_company_id) if pub_company_id else (int(company_id) if company_id else None),
)
return jsonify({'success': True, 'content': content, 'hashtags': hashtags, 'model': model})
except Exception as e:
logger.error(f"AI generation failed: {e}")
return jsonify({'success': False, 'error': 'Nie udalo sie wygenerowac tresci. Sprobuj ponownie lub wpisz tresc recznie.'}), 500
@bp.route('/social-publisher/generate-hashtags', methods=['POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_generate_hashtags():
"""Generuj hashtagi AI na podstawie tresci posta (AJAX endpoint)."""
from services.social_publisher_service import social_publisher
content = request.json.get('content', '').strip()
post_type = request.json.get('post_type', '')
ai_model = request.json.get('ai_model', '')
if not content:
return jsonify({'success': False, 'error': 'Wpisz najpierw tresc posta, aby wygenerowac hashtagi.'}), 400
try:
hashtags, model = social_publisher.generate_hashtags(
content, post_type, ai_model=ai_model,
user_id=current_user.id,
)
return jsonify({'success': True, 'hashtags': hashtags, 'model': model})
except Exception as e:
logger.error(f"Hashtag generation failed: {e}")
return jsonify({'success': False, 'error': 'Nie udalo sie wygenerowac hashtagow. Sprobuj ponownie.'}), 500
# ============================================================
# SOCIAL PUBLISHER - SETTINGS (per company)
# ============================================================
@bp.route('/social-publisher/settings')
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_settings():
"""Lista firm z konfiguracjami Facebook."""
from services.social_publisher_service import social_publisher
db = SessionLocal()
try:
user_companies = _get_user_companies(db)
# Get existing configs for each company
company_configs = []
for company in user_companies:
config = db.query(SocialMediaConfig).filter(
SocialMediaConfig.platform == 'facebook',
SocialMediaConfig.company_id == company.id,
).first()
# Check OAuth connection
oauth_token = db.query(OAuthToken).filter(
OAuthToken.company_id == company.id,
OAuthToken.provider == 'meta',
OAuthToken.service == 'facebook',
OAuthToken.is_active == True,
).first()
company_configs.append({
'company': company,
'config': config,
'oauth_connected': bool(oauth_token),
'oauth_page_name': oauth_token.account_name if oauth_token else None,
})
return render_template('admin/social_publisher_settings.html',
company_configs=company_configs)
finally:
db.close()
@bp.route('/social-publisher/settings/<int:company_id>', methods=['GET', 'POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_company_settings(company_id):
"""Konfiguracja Facebook dla konkretnej firmy."""
from services.social_publisher_service import social_publisher
db = SessionLocal()
try:
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
flash('Firma nie znaleziona.', 'danger')
return redirect(url_for('admin.social_publisher_settings'))
if not _user_can_access_company(db, company_id):
flash('Nie masz uprawnień do konfiguracji tej firmy.', 'danger')
return redirect(url_for('admin.social_publisher_settings'))
config = db.query(SocialMediaConfig).filter(
SocialMediaConfig.platform == 'facebook',
SocialMediaConfig.company_id == company_id,
).first()
oauth_token = db.query(OAuthToken).filter(
OAuthToken.company_id == company_id,
OAuthToken.provider == 'meta',
OAuthToken.service == 'facebook',
OAuthToken.is_active == True,
).first()
if request.method == 'POST':
debug_mode = request.form.get('debug_mode') == 'on'
# If we have OAuth + config with page, just update debug_mode
if config and config.page_id:
social_publisher.save_fb_config(
company_id=company_id,
page_id=config.page_id,
page_name=config.page_name or '',
debug_mode=debug_mode,
user_id=current_user.id,
)
flash('Ustawienia zapisane.', 'success')
else:
# Manual config (legacy)
page_id = request.form.get('page_id', '').strip()
page_name = request.form.get('page_name', '').strip()
access_token = request.form.get('access_token', '').strip()
if not page_id:
flash('Page ID jest wymagany.', 'danger')
else:
social_publisher.save_fb_config(
company_id=company_id,
page_id=page_id,
page_name=page_name,
debug_mode=debug_mode,
user_id=current_user.id,
access_token=access_token or None,
)
flash('Konfiguracja Facebook zapisana.', 'success')
return redirect(url_for('admin.social_publisher_company_settings',
company_id=company_id))
return render_template('admin/social_publisher_company_settings.html',
company=company,
config=config,
oauth_token=oauth_token)
finally:
db.close()