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

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:
Maciej Pienczyn 2026-02-19 08:15:36 +01:00
parent ee2d4e039d
commit d8e0162e01
10 changed files with 1010 additions and 221 deletions

View File

@ -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()

View File

@ -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()

View File

@ -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}>'
# ============================================================

View 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;

View File

@ -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',
},
},

View File

@ -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:

View File

@ -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;
}

View 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">&#10003;</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">&#63;</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 %}

View File

@ -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>

View File

@ -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 %}