feat: add per-company Facebook configuration 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
Social Publisher now supports multi-company FB publishing via OAuth. Each company can connect its own Facebook page through the existing OAuth framework. Includes discover-pages/select-page endpoints, per-company settings UI, and publishing_company_id on posts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ee2d4e039d
commit
d8e0162e01
@ -2,7 +2,7 @@
|
||||
Admin Social Publisher Routes
|
||||
==============================
|
||||
|
||||
Social media publishing management for NORDA Business Chamber.
|
||||
Social media publishing management with per-company Facebook configuration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -13,12 +13,37 @@ 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
|
||||
from database import (SessionLocal, SocialPost, SocialMediaConfig, Company,
|
||||
NordaEvent, SystemRole, OAuthToken, UserCompanyPermissions)
|
||||
from utils.decorators import role_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_user_companies(db):
|
||||
"""Get companies the current user has access to for social publishing."""
|
||||
# Admin/Office Manager sees all active companies
|
||||
if current_user.system_role in (SystemRole.ADMIN, SystemRole.SUPERADMIN):
|
||||
return db.query(Company).filter(Company.is_active == True).order_by(Company.name).all()
|
||||
|
||||
# Regular users see companies they're assigned to
|
||||
perms = db.query(UserCompanyPermissions).filter(
|
||||
UserCompanyPermissions.user_id == current_user.id
|
||||
).all()
|
||||
company_ids = [p.company_id for p in perms]
|
||||
|
||||
if current_user.company_id and current_user.company_id not in company_ids:
|
||||
company_ids.append(current_user.company_id)
|
||||
|
||||
if not company_ids:
|
||||
return []
|
||||
|
||||
return db.query(Company).filter(
|
||||
Company.id.in_(company_ids),
|
||||
Company.is_active == True
|
||||
).order_by(Company.name).all()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SOCIAL PUBLISHER - LIST & DASHBOARD
|
||||
# ============================================================
|
||||
@ -32,6 +57,7 @@ def social_publisher_list():
|
||||
|
||||
status_filter = request.args.get('status', 'all')
|
||||
type_filter = request.args.get('type', 'all')
|
||||
company_filter = request.args.get('company', 'all')
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@ -41,16 +67,24 @@ def social_publisher_list():
|
||||
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()
|
||||
|
||||
return render_template('admin/social_publisher.html',
|
||||
posts=posts,
|
||||
stats=stats,
|
||||
post_types=POST_TYPES,
|
||||
status_filter=status_filter,
|
||||
type_filter=type_filter)
|
||||
type_filter=type_filter,
|
||||
company_filter=company_filter,
|
||||
configured_companies=configured_companies)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@ -70,6 +104,7 @@ def social_publisher_new():
|
||||
try:
|
||||
companies = db.query(Company).filter(Company.is_active == True).order_by(Company.name).all()
|
||||
events = db.query(NordaEvent).order_by(NordaEvent.event_date.desc()).limit(20).all()
|
||||
configured_companies = social_publisher.get_configured_companies()
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action', 'draft')
|
||||
@ -78,12 +113,14 @@ def social_publisher_new():
|
||||
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)
|
||||
|
||||
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_types=POST_TYPES,
|
||||
configured_companies=configured_companies)
|
||||
|
||||
post = social_publisher.create_post(
|
||||
post_type=post_type,
|
||||
@ -91,6 +128,7 @@ def social_publisher_new():
|
||||
hashtags=hashtags or None,
|
||||
company_id=company_id,
|
||||
event_id=event_id,
|
||||
publishing_company_id=publishing_company_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
@ -104,7 +142,8 @@ def social_publisher_new():
|
||||
|
||||
return render_template('admin/social_publisher_form.html',
|
||||
post=None, companies=companies, events=events,
|
||||
post_types=POST_TYPES)
|
||||
post_types=POST_TYPES,
|
||||
configured_companies=configured_companies)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@ -125,6 +164,7 @@ def social_publisher_edit(post_id):
|
||||
|
||||
companies = db.query(Company).filter(Company.is_active == True).order_by(Company.name).all()
|
||||
events = db.query(NordaEvent).order_by(NordaEvent.event_date.desc()).limit(20).all()
|
||||
configured_companies = social_publisher.get_configured_companies()
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action', 'save')
|
||||
@ -154,12 +194,14 @@ def social_publisher_edit(post_id):
|
||||
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_types=POST_TYPES,
|
||||
configured_companies=configured_companies)
|
||||
|
||||
social_publisher.update_post(
|
||||
post_id=post_id,
|
||||
@ -168,13 +210,15 @@ def social_publisher_edit(post_id):
|
||||
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_types=POST_TYPES,
|
||||
configured_companies=configured_companies)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@ -291,35 +335,113 @@ def social_publisher_generate():
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SOCIAL PUBLISHER - SETTINGS
|
||||
# SOCIAL PUBLISHER - SETTINGS (per company)
|
||||
# ============================================================
|
||||
|
||||
@bp.route('/social-publisher/settings', methods=['GET', 'POST'])
|
||||
@bp.route('/social-publisher/settings')
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def social_publisher_settings():
|
||||
"""Konfiguracja Facebook (tylko admin)."""
|
||||
"""Lista firm z konfiguracjami Facebook."""
|
||||
from services.social_publisher_service import social_publisher
|
||||
|
||||
config = social_publisher.get_fb_config()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user_companies = _get_user_companies(db)
|
||||
|
||||
if request.method == 'POST':
|
||||
page_id = request.form.get('page_id', '').strip()
|
||||
page_name = request.form.get('page_name', '').strip()
|
||||
access_token = request.form.get('access_token', '').strip()
|
||||
debug_mode = request.form.get('debug_mode') == 'on'
|
||||
# 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()
|
||||
|
||||
if not page_id or not access_token:
|
||||
flash('Page ID i Access Token są wymagane.', 'danger')
|
||||
else:
|
||||
social_publisher.save_fb_config(
|
||||
page_id=page_id,
|
||||
page_name=page_name,
|
||||
access_token=access_token,
|
||||
debug_mode=debug_mode,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
flash('Konfiguracja Facebook zapisana.', 'success')
|
||||
# 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.ADMIN)
|
||||
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'))
|
||||
|
||||
return render_template('admin/social_publisher_settings.html', config=config)
|
||||
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()
|
||||
|
||||
@ -228,3 +228,142 @@ def oauth_discover_gbp_locations():
|
||||
return jsonify({'success': False, 'error': 'Błąd podczas wyszukiwania lokalizacji'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/oauth/meta/discover-pages', methods=['POST'])
|
||||
@login_required
|
||||
def oauth_discover_fb_pages():
|
||||
"""Discover Facebook pages managed by the user after OAuth connection.
|
||||
|
||||
POST /api/oauth/meta/discover-pages
|
||||
Body: {"company_id": 123}
|
||||
"""
|
||||
company_id = request.json.get('company_id') if request.is_json else request.form.get('company_id', type=int)
|
||||
if not company_id:
|
||||
return jsonify({'success': False, 'error': 'company_id jest wymagany'}), 400
|
||||
|
||||
from oauth_service import OAuthService
|
||||
oauth = OAuthService()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
token = oauth.get_valid_token(db, company_id, 'meta', 'facebook')
|
||||
if not token:
|
||||
return jsonify({'success': False, 'error': 'Brak połączenia Facebook dla tej firmy'}), 404
|
||||
|
||||
from facebook_graph_service import FacebookGraphService
|
||||
fb = FacebookGraphService(token)
|
||||
pages = fb.get_managed_pages()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'pages': [
|
||||
{
|
||||
'id': p.get('id'),
|
||||
'name': p.get('name'),
|
||||
'category': p.get('category'),
|
||||
'fan_count': p.get('fan_count', 0),
|
||||
}
|
||||
for p in pages
|
||||
]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"FB discover pages error: {e}")
|
||||
return jsonify({'success': False, 'error': 'Błąd podczas wyszukiwania stron Facebook'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/oauth/meta/select-page', methods=['POST'])
|
||||
@login_required
|
||||
def oauth_select_fb_page():
|
||||
"""Select a Facebook page and save its page access token.
|
||||
|
||||
POST /api/oauth/meta/select-page
|
||||
Body: {"company_id": 123, "page_id": "456789"}
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': 'JSON body wymagany'}), 400
|
||||
|
||||
company_id = data.get('company_id')
|
||||
page_id = data.get('page_id')
|
||||
if not company_id or not page_id:
|
||||
return jsonify({'success': False, 'error': 'company_id i page_id są wymagane'}), 400
|
||||
|
||||
from oauth_service import OAuthService
|
||||
oauth = OAuthService()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get current user token to list pages with their page access tokens
|
||||
token = oauth.get_valid_token(db, company_id, 'meta', 'facebook')
|
||||
if not token:
|
||||
return jsonify({'success': False, 'error': 'Brak połączenia Facebook'}), 404
|
||||
|
||||
from facebook_graph_service import FacebookGraphService
|
||||
fb = FacebookGraphService(token)
|
||||
pages = fb.get_managed_pages()
|
||||
|
||||
# Find selected page
|
||||
selected = None
|
||||
for p in pages:
|
||||
if str(p.get('id')) == str(page_id):
|
||||
selected = p
|
||||
break
|
||||
|
||||
if not selected:
|
||||
return jsonify({'success': False, 'error': 'Nie znaleziono wybranej strony'}), 404
|
||||
|
||||
page_access_token = selected.get('access_token')
|
||||
page_name = selected.get('name', '')
|
||||
|
||||
if not page_access_token:
|
||||
return jsonify({'success': False, 'error': 'Brak tokenu strony. Sprawdź uprawnienia.'}), 400
|
||||
|
||||
# Update oauth_tokens with page-specific data
|
||||
oauth_token = db.query(OAuthToken).filter(
|
||||
OAuthToken.company_id == company_id,
|
||||
OAuthToken.provider == 'meta',
|
||||
OAuthToken.service == 'facebook',
|
||||
OAuthToken.is_active == True,
|
||||
).first()
|
||||
|
||||
if oauth_token:
|
||||
oauth_token.access_token = page_access_token
|
||||
oauth_token.account_id = str(page_id)
|
||||
oauth_token.account_name = page_name
|
||||
|
||||
# Create/update social_media_config for this company
|
||||
from database import SocialMediaConfig
|
||||
config = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.company_id == company_id,
|
||||
).first()
|
||||
|
||||
if not config:
|
||||
config = SocialMediaConfig(platform='facebook', company_id=company_id)
|
||||
db.add(config)
|
||||
|
||||
config.page_id = str(page_id)
|
||||
config.page_name = page_name
|
||||
config.is_active = True
|
||||
config.debug_mode = True
|
||||
config.updated_by = current_user.id
|
||||
|
||||
db.commit()
|
||||
logger.info(f"FB page selected: {page_name} (ID: {page_id}) for company {company_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Strona "{page_name}" połączona',
|
||||
'page': {
|
||||
'id': page_id,
|
||||
'name': page_name,
|
||||
'category': selected.get('category'),
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"FB select page error: {e}")
|
||||
return jsonify({'success': False, 'error': 'Błąd zapisu konfiguracji strony'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
16
database.py
16
database.py
@ -5365,6 +5365,9 @@ class SocialPost(Base):
|
||||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
|
||||
event_id = Column(Integer, ForeignKey('norda_events.id'), nullable=True)
|
||||
|
||||
# Firma publikująca (której stroną FB publikujemy)
|
||||
publishing_company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
|
||||
|
||||
# Workflow status
|
||||
status = Column(String(20), nullable=False, default='draft')
|
||||
|
||||
@ -5395,6 +5398,7 @@ class SocialPost(Base):
|
||||
|
||||
# Relationships
|
||||
company = relationship('Company', foreign_keys=[company_id])
|
||||
publishing_company = relationship('Company', foreign_keys=[publishing_company_id])
|
||||
event = relationship('NordaEvent', foreign_keys=[event_id])
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
approver = relationship('User', foreign_keys=[approved_by])
|
||||
@ -5404,11 +5408,12 @@ class SocialPost(Base):
|
||||
|
||||
|
||||
class SocialMediaConfig(Base):
|
||||
"""Configuration for social media platform connections (NORDA chamber page)."""
|
||||
"""Configuration for social media platform connections per company."""
|
||||
__tablename__ = 'social_media_config'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
platform = Column(String(50), nullable=False, unique=True)
|
||||
platform = Column(String(50), nullable=False)
|
||||
company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
|
||||
page_id = Column(String(100))
|
||||
page_name = Column(String(255))
|
||||
access_token = Column(Text)
|
||||
@ -5419,10 +5424,15 @@ class SocialMediaConfig(Base):
|
||||
updated_by = Column(Integer, ForeignKey('users.id'))
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
company = relationship('Company', foreign_keys=[company_id])
|
||||
updater = relationship('User', foreign_keys=[updated_by])
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('platform', 'company_id', name='uq_social_config_platform_company'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SocialMediaConfig {self.platform} page={self.page_name}>'
|
||||
return f'<SocialMediaConfig {self.platform} company_id={self.company_id} page={self.page_name}>'
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
17
database/migrations/073_social_config_per_company.sql
Normal file
17
database/migrations/073_social_config_per_company.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- Migration 073: Social Media Config per Company
|
||||
-- Adds company_id to social_media_config for multi-tenant publishing
|
||||
-- Adds publishing_company_id to social_media_posts
|
||||
|
||||
-- 1. Add company_id column to social_media_config
|
||||
ALTER TABLE social_media_config ADD COLUMN IF NOT EXISTS company_id INTEGER REFERENCES companies(id);
|
||||
|
||||
-- 2. Drop old unique constraint (platform-only) and add compound unique
|
||||
ALTER TABLE social_media_config DROP CONSTRAINT IF EXISTS social_media_config_platform_key;
|
||||
ALTER TABLE social_media_config ADD CONSTRAINT uq_social_config_platform_company UNIQUE(platform, company_id);
|
||||
|
||||
-- 3. Add publishing_company_id to social_media_posts
|
||||
ALTER TABLE social_media_posts ADD COLUMN IF NOT EXISTS publishing_company_id INTEGER REFERENCES companies(id);
|
||||
|
||||
-- 4. Ensure permissions
|
||||
GRANT ALL ON TABLE social_media_config TO nordabiz_app;
|
||||
GRANT ALL ON TABLE social_media_posts TO nordabiz_app;
|
||||
@ -36,7 +36,7 @@ OAUTH_PROVIDERS = {
|
||||
'auth_url': 'https://www.facebook.com/v21.0/dialog/oauth',
|
||||
'token_url': 'https://graph.facebook.com/v21.0/oauth/access_token',
|
||||
'scopes': {
|
||||
'facebook': 'pages_show_list,pages_read_engagement,read_insights',
|
||||
'facebook': 'pages_show_list,pages_read_engagement,pages_manage_posts,read_insights',
|
||||
'instagram': 'instagram_basic,instagram_manage_insights,pages_show_list',
|
||||
},
|
||||
},
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
Social Media Publisher Service
|
||||
==============================
|
||||
|
||||
Business logic for creating, editing, scheduling and publishing social media posts
|
||||
for the NORDA Business Chamber Facebook page.
|
||||
Business logic for creating, editing, scheduling and publishing social media posts.
|
||||
Supports per-company Facebook configuration via OAuth tokens.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -11,7 +11,7 @@ import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
|
||||
from database import SessionLocal, SocialPost, SocialMediaConfig, Company, NordaEvent
|
||||
from database import SessionLocal, SocialPost, SocialMediaConfig, Company, NordaEvent, OAuthToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -117,11 +117,12 @@ Odpowiedz WYŁĄCZNIE tekstem postu.""",
|
||||
|
||||
|
||||
class SocialPublisherService:
|
||||
"""Service for managing social media posts for NORDA chamber."""
|
||||
"""Service for managing social media posts with per-company FB configuration."""
|
||||
|
||||
# ---- CRUD Operations ----
|
||||
|
||||
def get_posts(self, status: str = None, post_type: str = None,
|
||||
publishing_company_id: int = None,
|
||||
limit: int = 50, offset: int = 0) -> List[SocialPost]:
|
||||
"""Get posts with optional filters."""
|
||||
db = SessionLocal()
|
||||
@ -131,6 +132,8 @@ class SocialPublisherService:
|
||||
query = query.filter(SocialPost.status == status)
|
||||
if post_type:
|
||||
query = query.filter(SocialPost.post_type == post_type)
|
||||
if publishing_company_id:
|
||||
query = query.filter(SocialPost.publishing_company_id == publishing_company_id)
|
||||
return query.offset(offset).limit(limit).all()
|
||||
finally:
|
||||
db.close()
|
||||
@ -146,6 +149,7 @@ class SocialPublisherService:
|
||||
def create_post(self, post_type: str, content: str, user_id: int,
|
||||
platform: str = 'facebook', hashtags: str = None,
|
||||
company_id: int = None, event_id: int = None,
|
||||
publishing_company_id: int = None,
|
||||
image_path: str = None, ai_model: str = None,
|
||||
ai_prompt_template: str = None) -> SocialPost:
|
||||
"""Create a new post draft."""
|
||||
@ -159,6 +163,7 @@ class SocialPublisherService:
|
||||
image_path=image_path,
|
||||
company_id=company_id,
|
||||
event_id=event_id,
|
||||
publishing_company_id=publishing_company_id,
|
||||
status='draft',
|
||||
ai_model=ai_model,
|
||||
ai_prompt_template=ai_prompt_template,
|
||||
@ -167,7 +172,7 @@ class SocialPublisherService:
|
||||
db.add(post)
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
logger.info(f"Created social post #{post.id} type={post_type}")
|
||||
logger.info(f"Created social post #{post.id} type={post_type} pub_company={publishing_company_id}")
|
||||
return post
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@ -188,7 +193,7 @@ class SocialPublisherService:
|
||||
return None
|
||||
|
||||
allowed_fields = {'content', 'hashtags', 'image_path', 'post_type',
|
||||
'company_id', 'event_id', 'scheduled_at'}
|
||||
'company_id', 'event_id', 'publishing_company_id', 'scheduled_at'}
|
||||
for key, value in kwargs.items():
|
||||
if key in allowed_fields:
|
||||
setattr(post, key, value)
|
||||
@ -261,6 +266,35 @@ class SocialPublisherService:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _get_publish_token(self, db, publishing_company_id: int) -> Tuple[Optional[str], Optional[SocialMediaConfig]]:
|
||||
"""Get access token and config for publishing.
|
||||
|
||||
Tries OAuth token first (from oauth_tokens table), falls back to
|
||||
manual token in social_media_config.
|
||||
"""
|
||||
config = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.company_id == publishing_company_id,
|
||||
SocialMediaConfig.is_active == True
|
||||
).first()
|
||||
|
||||
if not config or not config.page_id:
|
||||
return None, None
|
||||
|
||||
# Try OAuth token first
|
||||
from oauth_service import OAuthService
|
||||
oauth = OAuthService()
|
||||
oauth_token = oauth.get_valid_token(db, publishing_company_id, 'meta', 'facebook')
|
||||
|
||||
if oauth_token:
|
||||
return oauth_token, config
|
||||
|
||||
# Fallback to manual token in config
|
||||
if config.access_token:
|
||||
return config.access_token, config
|
||||
|
||||
return None, None
|
||||
|
||||
def publish_post(self, post_id: int) -> Tuple[bool, str]:
|
||||
"""Publish a post to Facebook immediately.
|
||||
|
||||
@ -275,14 +309,24 @@ class SocialPublisherService:
|
||||
if post.status not in ('draft', 'approved', 'scheduled'):
|
||||
return False, f"Nie można opublikować posta ze statusem: {post.status}"
|
||||
|
||||
# Get FB config
|
||||
config = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.is_active == True
|
||||
).first()
|
||||
# Determine which company's FB page to publish on
|
||||
pub_company_id = post.publishing_company_id
|
||||
if not pub_company_id:
|
||||
# Fallback: try legacy global config (no company_id)
|
||||
config = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.company_id == None,
|
||||
SocialMediaConfig.is_active == True
|
||||
).first()
|
||||
if config and config.access_token and config.page_id:
|
||||
access_token = config.access_token
|
||||
else:
|
||||
return False, "Nie wybrano firmy publikującej. Ustaw 'Publikuj jako' lub skonfiguruj Facebook."
|
||||
else:
|
||||
access_token, config = self._get_publish_token(db, pub_company_id)
|
||||
|
||||
if not config or not config.access_token or not config.page_id:
|
||||
return False, "Facebook nie jest skonfigurowany. Ustaw token w Ustawieniach."
|
||||
if not access_token or not config:
|
||||
return False, "Facebook nie jest skonfigurowany dla wybranej firmy."
|
||||
|
||||
# Build message with hashtags
|
||||
message = post.content
|
||||
@ -294,7 +338,7 @@ class SocialPublisherService:
|
||||
|
||||
# Publish via Facebook Graph API
|
||||
from facebook_graph_service import FacebookGraphService
|
||||
fb = FacebookGraphService(config.access_token)
|
||||
fb = FacebookGraphService(access_token)
|
||||
|
||||
image_path = None
|
||||
if post.image_path:
|
||||
@ -401,32 +445,77 @@ class SocialPublisherService:
|
||||
|
||||
# ---- Facebook Config ----
|
||||
|
||||
def get_fb_config(self) -> Optional[SocialMediaConfig]:
|
||||
"""Get Facebook configuration."""
|
||||
def get_fb_config(self, company_id: int = None) -> Optional[SocialMediaConfig]:
|
||||
"""Get Facebook configuration for a specific company.
|
||||
|
||||
If company_id is None, returns legacy global config (backward compat).
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.query(SocialMediaConfig).filter(
|
||||
query = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook'
|
||||
).first()
|
||||
)
|
||||
if company_id is not None:
|
||||
query = query.filter(SocialMediaConfig.company_id == company_id)
|
||||
else:
|
||||
query = query.filter(SocialMediaConfig.company_id == None)
|
||||
return query.first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def save_fb_config(self, page_id: str, page_name: str, access_token: str,
|
||||
debug_mode: bool, user_id: int) -> SocialMediaConfig:
|
||||
"""Save/update Facebook configuration."""
|
||||
def get_all_fb_configs(self) -> List[SocialMediaConfig]:
|
||||
"""Get all active Facebook configurations across companies."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.is_active == True,
|
||||
SocialMediaConfig.company_id != None,
|
||||
).all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_configured_companies(self) -> List[Dict]:
|
||||
"""Get companies that have an active FB configuration."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
configs = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.is_active == True,
|
||||
SocialMediaConfig.company_id != None,
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
'company_id': c.company_id,
|
||||
'company_name': c.company.name if c.company else f'Firma #{c.company_id}',
|
||||
'page_name': c.page_name,
|
||||
'page_id': c.page_id,
|
||||
'debug_mode': c.debug_mode,
|
||||
}
|
||||
for c in configs
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def save_fb_config(self, company_id: int, page_id: str, page_name: str,
|
||||
debug_mode: bool, user_id: int,
|
||||
access_token: str = None) -> SocialMediaConfig:
|
||||
"""Save/update Facebook configuration for a company."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
config = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook'
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.company_id == company_id,
|
||||
).first()
|
||||
|
||||
if not config:
|
||||
config = SocialMediaConfig(platform='facebook')
|
||||
config = SocialMediaConfig(platform='facebook', company_id=company_id)
|
||||
db.add(config)
|
||||
|
||||
config.page_id = page_id
|
||||
config.page_name = page_name
|
||||
config.access_token = access_token
|
||||
if access_token is not None:
|
||||
config.access_token = access_token
|
||||
config.debug_mode = debug_mode
|
||||
config.is_active = True
|
||||
config.updated_by = user_id
|
||||
@ -434,7 +523,7 @@ class SocialPublisherService:
|
||||
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
logger.info(f"Saved FB config: page={page_name} debug={debug_mode}")
|
||||
logger.info(f"Saved FB config: company={company_id} page={page_name} debug={debug_mode}")
|
||||
return config
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@ -453,15 +542,25 @@ class SocialPublisherService:
|
||||
if not post or not post.meta_post_id:
|
||||
return None
|
||||
|
||||
config = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.is_active == True
|
||||
).first()
|
||||
if not config or not config.access_token:
|
||||
# Get token from publishing company
|
||||
access_token = None
|
||||
if post.publishing_company_id:
|
||||
access_token, _ = self._get_publish_token(db, post.publishing_company_id)
|
||||
|
||||
# Fallback to legacy global config
|
||||
if not access_token:
|
||||
config = db.query(SocialMediaConfig).filter(
|
||||
SocialMediaConfig.platform == 'facebook',
|
||||
SocialMediaConfig.is_active == True
|
||||
).first()
|
||||
if config:
|
||||
access_token = config.access_token
|
||||
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
from facebook_graph_service import FacebookGraphService
|
||||
fb = FacebookGraphService(config.access_token)
|
||||
fb = FacebookGraphService(access_token)
|
||||
engagement = fb.get_post_engagement(post.meta_post_id)
|
||||
|
||||
if engagement:
|
||||
|
||||
@ -181,10 +181,12 @@
|
||||
}
|
||||
.posts-table th:nth-child(3),
|
||||
.posts-table td:nth-child(3),
|
||||
.posts-table th:nth-child(5),
|
||||
.posts-table td:nth-child(5),
|
||||
.posts-table th:nth-child(4),
|
||||
.posts-table td:nth-child(4),
|
||||
.posts-table th:nth-child(6),
|
||||
.posts-table td:nth-child(6) {
|
||||
.posts-table td:nth-child(6),
|
||||
.posts-table th:nth-child(7),
|
||||
.posts-table td:nth-child(7) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@ -251,6 +253,17 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if configured_companies %}
|
||||
<div class="filter-group">
|
||||
<label for="company-filter">Firma:</label>
|
||||
<select id="company-filter" onchange="applyFilters()">
|
||||
<option value="all" {% if company_filter == 'all' %}selected{% endif %}>Wszystkie</option>
|
||||
{% for cc in configured_companies %}
|
||||
<option value="{{ cc.company_id }}" {% if company_filter == cc.company_id|string %}selected{% endif %}>{{ cc.company_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tabela postow -->
|
||||
@ -262,6 +275,7 @@
|
||||
<th>Typ</th>
|
||||
<th>Tresc</th>
|
||||
<th>Firma</th>
|
||||
<th>Publikuje jako</th>
|
||||
<th>Status</th>
|
||||
<th>Data</th>
|
||||
<th>Engagement</th>
|
||||
@ -280,6 +294,7 @@
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ post.company.name if post.company else '-' }}</td>
|
||||
<td>{{ post.publishing_company.name if post.publishing_company else '-' }}</td>
|
||||
<td>
|
||||
<span class="status-badge {{ post.status }}">
|
||||
{% if post.status == 'draft' %}Szkic
|
||||
@ -372,9 +387,12 @@
|
||||
function applyFilters() {
|
||||
const status = document.getElementById('status-filter').value;
|
||||
const type = document.getElementById('type-filter').value;
|
||||
const companyEl = document.getElementById('company-filter');
|
||||
const company = companyEl ? companyEl.value : 'all';
|
||||
let url = '{{ url_for("admin.social_publisher_list") }}?';
|
||||
if (status !== 'all') url += 'status=' + status + '&';
|
||||
if (type !== 'all') url += 'type=' + type + '&';
|
||||
if (company !== 'all') url += 'company=' + company + '&';
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
|
||||
419
templates/admin/social_publisher_company_settings.html
Normal file
419
templates/admin/social_publisher_company_settings.html
Normal file
@ -0,0 +1,419 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Konfiguracja FB: {{ company.name }} - Social Publisher{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.admin-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
max-width: 700px;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-xl);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.oauth-section {
|
||||
background: var(--background);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.oauth-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oauth-status-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.oauth-status-icon.connected {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.oauth-status-icon.disconnected {
|
||||
background: var(--background);
|
||||
color: var(--text-secondary);
|
||||
border: 2px dashed var(--border);
|
||||
}
|
||||
|
||||
.oauth-status-text h4 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.oauth-status-text p {
|
||||
margin: var(--spacing-xs) 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.page-selector {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.page-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
.page-list li {
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-list li:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-bg);
|
||||
}
|
||||
|
||||
.page-list li.selected {
|
||||
border-color: var(--success);
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-info .name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-info .meta {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-status {
|
||||
background: var(--background);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.config-status strong {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.config-status .status-active {
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#page-loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="admin-header">
|
||||
<h1>Facebook: {{ company.name }}</h1>
|
||||
<a href="{{ url_for('admin.social_publisher_settings') }}" class="btn btn-secondary">Powrot do listy</a>
|
||||
</div>
|
||||
|
||||
{% if config and config.page_id %}
|
||||
<div class="config-status">
|
||||
<strong>Obecna konfiguracja:</strong>
|
||||
Strona: <strong>{{ config.page_name or 'Nie ustawiona' }}</strong>
|
||||
| Status: {% if config.is_active %}<span class="status-active">Aktywna</span>{% else %}Nieaktywna{% endif %}
|
||||
| Debug: {% if config.debug_mode %}<span style="color: var(--warning); font-weight: 600;">Wlaczony</span>{% else %}Wylaczony{% endif %}
|
||||
{% if config.updated_at %}
|
||||
| Ostatnia aktualizacja: {{ config.updated_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- OAuth Section -->
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">Polaczenie z Facebook (OAuth)</h3>
|
||||
|
||||
<div class="oauth-section">
|
||||
<div class="oauth-status">
|
||||
{% if oauth_token %}
|
||||
<div class="oauth-status-icon connected">✓</div>
|
||||
<div class="oauth-status-text">
|
||||
<h4>Polaczono z Facebook</h4>
|
||||
<p>{% if oauth_token.account_name %}Strona: {{ oauth_token.account_name }}{% else %}Token aktywny{% endif %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="oauth-status-icon disconnected">?</div>
|
||||
<div class="oauth-status-text">
|
||||
<h4>Brak polaczenia</h4>
|
||||
<p>Polacz konto Facebook, aby publikowac posty na stronie firmowej</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if oauth_token %}
|
||||
<div style="display: flex; gap: var(--spacing-sm); flex-wrap: wrap;">
|
||||
<button type="button" class="btn btn-secondary btn-small" id="btn-discover-pages">
|
||||
Zmien strone FB
|
||||
</button>
|
||||
<button type="button" class="btn btn-error btn-small" id="btn-disconnect"
|
||||
onclick="disconnectOAuth()">
|
||||
Rozlacz
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-primary" id="btn-connect-fb"
|
||||
onclick="connectFacebook()">
|
||||
Polacz z Facebook
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page selector (hidden by default) -->
|
||||
<div class="page-selector" id="page-selector" style="display: none;">
|
||||
<h4>Wybierz strone Facebook:</h4>
|
||||
<div id="page-loading">Ladowanie stron...</div>
|
||||
<ul class="page-list" id="page-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug mode form -->
|
||||
{% if config and config.page_id %}
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">Ustawienia publikacji</h3>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" name="debug_mode"
|
||||
{% if config and config.debug_mode %}checked{% endif %}>
|
||||
<span>Tryb debug</span>
|
||||
</label>
|
||||
<p class="form-hint">W trybie debug posty sa widoczne tylko dla adminow strony FB (nie sa publiczne)</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('admin.social_publisher_settings') }}" class="btn btn-secondary">Anuluj</a>
|
||||
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
const companyId = {{ company.id }};
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
|
||||
async function connectFacebook() {
|
||||
try {
|
||||
const resp = await fetch('/api/oauth/connect/meta/facebook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.auth_url) {
|
||||
window.location.href = data.auth_url;
|
||||
} else {
|
||||
alert('Blad: ' + (data.error || 'Nie udalo sie zainicjowac polaczenia OAuth'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Blad polaczenia: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectOAuth() {
|
||||
if (!confirm('Czy na pewno chcesz rozlaczyc Facebook?')) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/oauth/disconnect/meta/facebook', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Blad: ' + (data.error || 'Nie udalo sie rozlaczyc'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Blad polaczenia: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-discover-pages')?.addEventListener('click', discoverPages);
|
||||
|
||||
async function discoverPages() {
|
||||
const selector = document.getElementById('page-selector');
|
||||
const loading = document.getElementById('page-loading');
|
||||
const list = document.getElementById('page-list');
|
||||
|
||||
selector.style.display = 'block';
|
||||
loading.style.display = 'block';
|
||||
list.innerHTML = '';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/oauth/meta/discover-pages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ company_id: companyId })
|
||||
});
|
||||
const data = await resp.json();
|
||||
loading.style.display = 'none';
|
||||
|
||||
if (data.success && data.pages && data.pages.length > 0) {
|
||||
data.pages.forEach(page => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `
|
||||
<div class="page-info">
|
||||
<div class="name">${page.name}</div>
|
||||
<div class="meta">${page.category || ''} | ${(page.fan_count || 0).toLocaleString()} fanow</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-small" onclick="selectPage('${page.id}', '${page.name.replace(/'/g, "\\'")}')">Wybierz</button>
|
||||
`;
|
||||
list.appendChild(li);
|
||||
});
|
||||
} else if (data.success) {
|
||||
list.innerHTML = '<li style="cursor:default;">Brak stron Facebook do wyswietlenia. Upewnij sie, ze masz uprawnienia administratora strony.</li>';
|
||||
} else {
|
||||
list.innerHTML = '<li style="cursor:default; color: var(--error);">Blad: ' + (data.error || 'Nieznany') + '</li>';
|
||||
}
|
||||
} catch (err) {
|
||||
loading.style.display = 'none';
|
||||
list.innerHTML = '<li style="cursor:default; color: var(--error);">Blad polaczenia: ' + err.message + '</li>';
|
||||
}
|
||||
}
|
||||
|
||||
async function selectPage(pageId, pageName) {
|
||||
try {
|
||||
const resp = await fetch('/api/oauth/meta/select-page', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
company_id: companyId,
|
||||
page_id: pageId
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Blad: ' + (data.error || 'Nie udalo sie wybrac strony'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Blad polaczenia: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-discover pages if OAuth is connected but no page selected
|
||||
{% if oauth_token and not (config and config.page_id) %}
|
||||
document.addEventListener('DOMContentLoaded', discoverPages);
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@ -203,6 +203,22 @@
|
||||
<form method="POST" id="postForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Publikuj jako (firma z konfiguracją FB) -->
|
||||
{% if configured_companies %}
|
||||
<div class="form-group">
|
||||
<label for="publishing_company_id">Publikuj jako (strona FB)</label>
|
||||
<select id="publishing_company_id" name="publishing_company_id">
|
||||
<option value="">-- Bez publikacji na FB --</option>
|
||||
{% for cc in configured_companies %}
|
||||
<option value="{{ cc.company_id }}" {% if post and post.publishing_company_id == cc.company_id %}selected{% endif %}>
|
||||
{{ cc.company_name }} ({{ cc.page_name }}){% if cc.debug_mode %} [DEBUG]{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="form-hint">Strona Facebook, na ktorej zostanie opublikowany post</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Typ posta -->
|
||||
<div class="form-group">
|
||||
<label for="post_type">Typ posta</label>
|
||||
|
||||
@ -16,121 +16,94 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
.companies-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.company-card {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-xl);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
max-width: 700px;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
border-left: 4px solid var(--border);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
.company-card.connected {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
.company-card.disconnected {
|
||||
border-left-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
.company-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.company-card-header h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connection-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
.connection-badge.connected {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
.connection-badge.disconnected {
|
||||
background: var(--background);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.company-card-details {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.company-card-details p {
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.company-card-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-xl);
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
max-width: 700px;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.info-box h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.info-box ol {
|
||||
padding-left: var(--spacing-lg);
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.info-box ol li {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.config-status {
|
||||
background: var(--background);
|
||||
padding: var(--spacing-md);
|
||||
.info-banner {
|
||||
background: var(--primary-bg);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.config-status strong {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.config-status .status-active {
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-status .status-inactive {
|
||||
color: var(--error);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -142,73 +115,49 @@
|
||||
<a href="{{ url_for('admin.social_publisher_list') }}" class="btn btn-secondary">Powrot do listy</a>
|
||||
</div>
|
||||
|
||||
{% if config %}
|
||||
<div class="config-status">
|
||||
<strong>Obecna konfiguracja:</strong>
|
||||
Strona: <strong>{{ config.page_name or 'Nie ustawiona' }}</strong>
|
||||
| Status: {% if config.is_active %}<span class="status-active">Aktywna</span>{% else %}<span class="status-inactive">Nieaktywna</span>{% endif %}
|
||||
| Debug: {% if config.debug_mode %}<span style="color: var(--warning); font-weight: 600;">Wlaczony</span>{% else %}Wylaczony{% endif %}
|
||||
{% if config.updated_at %}
|
||||
| Ostatnia aktualizacja: {{ config.updated_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
<div class="info-banner">
|
||||
Skonfiguruj Facebook dla firm, na ktorych stronach chcesz publikowac posty.
|
||||
Polacz konto przez OAuth lub wprowadz dane recznie.
|
||||
</div>
|
||||
|
||||
{% if company_configs %}
|
||||
<div class="companies-grid">
|
||||
{% for item in company_configs %}
|
||||
<div class="company-card {{ 'connected' if item.config and item.config.is_active and item.config.page_id else 'disconnected' }}">
|
||||
<div class="company-card-header">
|
||||
<h3>{{ item.company.name }}</h3>
|
||||
{% if item.config and item.config.is_active and item.config.page_id %}
|
||||
<span class="connection-badge connected">Polaczono</span>
|
||||
{% else %}
|
||||
<span class="connection-badge disconnected">Brak</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="company-card-details">
|
||||
{% if item.config and item.config.page_name %}
|
||||
<p>Strona FB: <strong>{{ item.config.page_name }}</strong></p>
|
||||
{% endif %}
|
||||
{% if item.oauth_connected %}
|
||||
<p>OAuth: <strong style="color: var(--success);">Aktywny</strong>
|
||||
{% if item.oauth_page_name %} ({{ item.oauth_page_name }}){% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if item.config %}
|
||||
<p>Debug: {% if item.config.debug_mode %}<span style="color: var(--warning);">Wlaczony</span>{% else %}Wylaczony{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="company-card-actions">
|
||||
<a href="{{ url_for('admin.social_publisher_company_settings', company_id=item.company.id) }}"
|
||||
class="btn btn-primary btn-small">Konfiguruj</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Brak firm do konfiguracji. Dodaj firmy do portalu, aby skonfigurowac Social Publisher.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">Konfiguracja Facebook</h3>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="page_id">Page ID</label>
|
||||
<input type="text" id="page_id" name="page_id"
|
||||
value="{{ config.page_id if config else '' }}"
|
||||
placeholder="np. 123456789012345">
|
||||
<p class="form-hint">Numeryczny identyfikator strony na Facebooku</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="page_name">Nazwa strony</label>
|
||||
<input type="text" id="page_name" name="page_name"
|
||||
value="{{ config.page_name if config else '' }}"
|
||||
placeholder="np. Norda Biznes Partner">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="access_token">Page Access Token</label>
|
||||
<textarea id="access_token" name="access_token"
|
||||
placeholder="Wklej Page Access Token...">{{ config.access_token if config else '' }}</textarea>
|
||||
<p class="form-hint">Token dostepu do strony FB (nie udostepniaj nikomu)</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" name="debug_mode"
|
||||
{% if config and config.debug_mode %}checked{% endif %}>
|
||||
<span>Tryb debug</span>
|
||||
</label>
|
||||
<p class="form-hint">W trybie debug posty sa widoczne tylko dla adminow strony FB (nie sa publiczne)</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('admin.social_publisher_list') }}" class="btn btn-secondary">Anuluj</a>
|
||||
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h2>Jak uzyskac Page Access Token?</h2>
|
||||
<ol>
|
||||
<li>Przejdz do <a href="https://developers.facebook.com/" target="_blank">Facebook Developers</a> i zaloguj sie</li>
|
||||
<li>Utworz aplikacje (typ: Business) lub wybierz istniejaca</li>
|
||||
<li>Dodaj produkt "Facebook Login for Business"</li>
|
||||
<li>Przejdz do <a href="https://developers.facebook.com/tools/explorer/" target="_blank">Graph API Explorer</a></li>
|
||||
<li>Wybierz swoja aplikacje i wygeneruj User Token z uprawnieniami:
|
||||
<code>pages_manage_posts</code>, <code>pages_read_engagement</code>, <code>pages_show_list</code></li>
|
||||
<li>Zamien User Token na Page Token: <code>GET /me/accounts</code> - skopiuj <code>access_token</code> dla wlasciwej strony</li>
|
||||
<li>Opcjonalnie: przedluz token na dlugotrwaly (60 dni) przez endpoint <code>/oauth/access_token</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user