nordabiz/blueprints/admin/routes_social_publisher.py
Maciej Pienczyn a8a434e99d
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: auto-load charts from DB cache on page load, show cache date
Charts now render automatically on page load via AJAX from DB cache
(no click needed). Info bar above charts shows post count, cache date,
and hint to refresh. GET cache endpoint now returns cached_at date.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 09:18:54 +01:00

660 lines
27 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
# Load cached FB posts from DB for instant rendering
# Inline max 10 posts for fast page load; full set via AJAX "Analityka"
cached_fb_posts = {}
cache_company_ids = user_company_ids if user_company_ids is not None else list(fb_stats.keys())
for cid in cache_company_ids:
cached = social_publisher.get_cached_posts(cid)
if cached:
cached_fb_posts[cid] = {
'posts': cached['posts'][:10],
'cached_at': cached['cached_at'],
'total_count': cached['total_count'],
}
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,
cached_fb_posts=cached_fb_posts)
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-posts-cache/<int:company_id>', methods=['GET'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_get_posts_cache(company_id):
"""Return full cached posts from DB (AJAX, for Analityka button)."""
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()
cached = social_publisher.get_cached_posts(company_id)
if cached and cached['posts']:
cached_at = cached['cached_at'].strftime('%d.%m.%Y %H:%M') if cached.get('cached_at') else None
return jsonify({'success': True, 'posts': cached['posts'], 'total_count': cached['total_count'], 'cached_at': cached_at})
return jsonify({'success': False, 'error': 'Brak danych w cache. Kliknij Analityka aby pobrac.'})
@bp.route('/social-publisher/fb-posts-cache/<int:company_id>', methods=['POST'])
@login_required
@role_required(SystemRole.MANAGER)
def social_publisher_save_posts_cache(company_id):
"""Save all loaded FB posts to DB cache for instant page load (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()
data = request.get_json()
posts = data.get('posts', []) if data else []
if posts:
social_publisher.save_all_posts_to_cache(company_id, posts)
return jsonify({'success': True, 'saved': len(posts)})
@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()