feat: Add Social Media Publisher module (MVP)
Admin panel module for publishing posts on NORDA chamber Facebook page. Includes AI content generation (Gemini), post workflow (draft/approved/ scheduled/published), Facebook Graph API publishing, and engagement tracking. New: migration 070, SocialPost/SocialMediaConfig models, publisher service, admin routes with AJAX, 3 templates (list/form/settings). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a0c85b6182
commit
4a033f0d81
@ -28,3 +28,4 @@ from . import routes_people # noqa: E402, F401
|
|||||||
from . import routes_membership # noqa: E402, F401
|
from . import routes_membership # noqa: E402, F401
|
||||||
from . import routes_benefits # noqa: E402, F401
|
from . import routes_benefits # noqa: E402, F401
|
||||||
from . import routes_competitors # noqa: E402, F401
|
from . import routes_competitors # noqa: E402, F401
|
||||||
|
from . import routes_social_publisher # noqa: E402, F401
|
||||||
|
|||||||
325
blueprints/admin/routes_social_publisher.py
Normal file
325
blueprints/admin/routes_social_publisher.py
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
"""
|
||||||
|
Admin Social Publisher Routes
|
||||||
|
==============================
|
||||||
|
|
||||||
|
Social media publishing management for NORDA Business Chamber.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
|
||||||
|
from . import bp
|
||||||
|
from database import SessionLocal, SocialPost, SocialMediaConfig, Company, NordaEvent, SystemRole
|
||||||
|
from utils.decorators import role_required
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SOCIAL PUBLISHER - LIST & DASHBOARD
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@bp.route('/social-publisher')
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.OFFICE_MANAGER)
|
||||||
|
def social_publisher_list():
|
||||||
|
"""Lista postow social media z filtrami."""
|
||||||
|
from services.social_publisher_service import social_publisher, POST_TYPES
|
||||||
|
|
||||||
|
status_filter = request.args.get('status', 'all')
|
||||||
|
type_filter = request.args.get('type', 'all')
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
query = db.query(SocialPost).order_by(SocialPost.created_at.desc())
|
||||||
|
|
||||||
|
if status_filter != 'all':
|
||||||
|
query = query.filter(SocialPost.status == status_filter)
|
||||||
|
if type_filter != 'all':
|
||||||
|
query = query.filter(SocialPost.post_type == type_filter)
|
||||||
|
|
||||||
|
posts = query.limit(100).all()
|
||||||
|
stats = social_publisher.get_stats()
|
||||||
|
|
||||||
|
return render_template('admin/social_publisher.html',
|
||||||
|
posts=posts,
|
||||||
|
stats=stats,
|
||||||
|
post_types=POST_TYPES,
|
||||||
|
status_filter=status_filter,
|
||||||
|
type_filter=type_filter)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SOCIAL PUBLISHER - CREATE / EDIT FORM
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@bp.route('/social-publisher/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.OFFICE_MANAGER)
|
||||||
|
def social_publisher_new():
|
||||||
|
"""Tworzenie nowego posta."""
|
||||||
|
from services.social_publisher_service import social_publisher, POST_TYPES
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
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()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.form.get('action', 'draft')
|
||||||
|
post_type = request.form.get('post_type', 'chamber_news')
|
||||||
|
content = request.form.get('content', '').strip()
|
||||||
|
hashtags = request.form.get('hashtags', '').strip()
|
||||||
|
company_id = request.form.get('company_id', type=int)
|
||||||
|
event_id = request.form.get('event_id', type=int)
|
||||||
|
|
||||||
|
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 = social_publisher.create_post(
|
||||||
|
post_type=post_type,
|
||||||
|
content=content,
|
||||||
|
hashtags=hashtags or None,
|
||||||
|
company_id=company_id,
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == 'publish':
|
||||||
|
success, message = social_publisher.publish_post(post.id)
|
||||||
|
flash(message, 'success' if success else 'danger')
|
||||||
|
else:
|
||||||
|
flash('Post utworzony (szkic).', 'success')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.social_publisher_edit', post_id=post.id))
|
||||||
|
|
||||||
|
return render_template('admin/social_publisher_form.html',
|
||||||
|
post=None, companies=companies, events=events,
|
||||||
|
post_types=POST_TYPES)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/social-publisher/<int:post_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.OFFICE_MANAGER)
|
||||||
|
def social_publisher_edit(post_id):
|
||||||
|
"""Edycja istniejacego posta."""
|
||||||
|
from services.social_publisher_service import social_publisher, POST_TYPES
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
flash('Post nie znaleziony.', 'danger')
|
||||||
|
return redirect(url_for('admin.social_publisher_list'))
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.form.get('action', 'save')
|
||||||
|
|
||||||
|
# Handle non-edit actions
|
||||||
|
if action == 'approve':
|
||||||
|
result = social_publisher.approve_post(post_id, current_user.id)
|
||||||
|
flash('Post zatwierdzony.' if result else 'Nie można zatwierdzić posta.',
|
||||||
|
'success' if result else 'danger')
|
||||||
|
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
|
||||||
|
|
||||||
|
if action == 'publish':
|
||||||
|
success, message = social_publisher.publish_post(post_id)
|
||||||
|
flash(message, 'success' if success else 'danger')
|
||||||
|
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
|
||||||
|
|
||||||
|
if action == 'delete':
|
||||||
|
if social_publisher.delete_post(post_id):
|
||||||
|
flash('Post usunięty.', 'success')
|
||||||
|
return redirect(url_for('admin.social_publisher_list'))
|
||||||
|
flash('Nie można usunąć posta.', 'danger')
|
||||||
|
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
|
||||||
|
|
||||||
|
# Default: save/update
|
||||||
|
content = request.form.get('content', '').strip()
|
||||||
|
hashtags = request.form.get('hashtags', '').strip()
|
||||||
|
post_type = request.form.get('post_type')
|
||||||
|
company_id = request.form.get('company_id', type=int)
|
||||||
|
event_id = request.form.get('event_id', type=int)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
social_publisher.update_post(
|
||||||
|
post_id=post_id,
|
||||||
|
content=content,
|
||||||
|
hashtags=hashtags or None,
|
||||||
|
post_type=post_type,
|
||||||
|
company_id=company_id,
|
||||||
|
event_id=event_id,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SOCIAL PUBLISHER - ACTIONS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@bp.route('/social-publisher/<int:post_id>/approve', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.OFFICE_MANAGER)
|
||||||
|
def social_publisher_approve(post_id):
|
||||||
|
"""Zatwierdz post."""
|
||||||
|
from services.social_publisher_service import social_publisher
|
||||||
|
|
||||||
|
result = social_publisher.approve_post(post_id, current_user.id)
|
||||||
|
if result:
|
||||||
|
flash('Post zatwierdzony.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Nie można zatwierdzić posta.', 'danger')
|
||||||
|
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/social-publisher/<int:post_id>/publish', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.OFFICE_MANAGER)
|
||||||
|
def social_publisher_publish(post_id):
|
||||||
|
"""Opublikuj post na Facebook."""
|
||||||
|
from services.social_publisher_service import social_publisher
|
||||||
|
|
||||||
|
success, message = social_publisher.publish_post(post_id)
|
||||||
|
|
||||||
|
# AJAX response
|
||||||
|
if request.headers.get('X-CSRFToken') and not request.form:
|
||||||
|
return jsonify({'success': success, 'message': message, 'error': None if success else message})
|
||||||
|
|
||||||
|
flash(message, 'success' if success else 'danger')
|
||||||
|
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/social-publisher/<int:post_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.OFFICE_MANAGER)
|
||||||
|
def social_publisher_delete(post_id):
|
||||||
|
"""Usuń post."""
|
||||||
|
from services.social_publisher_service import social_publisher
|
||||||
|
|
||||||
|
success = social_publisher.delete_post(post_id)
|
||||||
|
|
||||||
|
# AJAX response
|
||||||
|
if request.headers.get('X-CSRFToken') and not request.form:
|
||||||
|
return jsonify({'success': success, 'error': None if success else 'Nie można usunąć posta.'})
|
||||||
|
|
||||||
|
flash('Post usunięty.' if success else 'Nie można usunąć posta.', 'success' if success else 'danger')
|
||||||
|
return redirect(url_for('admin.social_publisher_list'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/social-publisher/<int:post_id>/refresh-engagement', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.OFFICE_MANAGER)
|
||||||
|
def social_publisher_refresh_engagement(post_id):
|
||||||
|
"""Odswiez metryki engagement z Facebook."""
|
||||||
|
from services.social_publisher_service import social_publisher
|
||||||
|
|
||||||
|
result = social_publisher.refresh_engagement(post_id)
|
||||||
|
if result:
|
||||||
|
flash('Engagement zaktualizowany.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Nie udało się pobrać engagement.', 'warning')
|
||||||
|
return redirect(url_for('admin.social_publisher_edit', post_id=post_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SOCIAL PUBLISHER - AI GENERATION (AJAX)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@bp.route('/social-publisher/generate', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.OFFICE_MANAGER)
|
||||||
|
def social_publisher_generate():
|
||||||
|
"""Generuj tresc posta AI (AJAX endpoint)."""
|
||||||
|
from services.social_publisher_service import social_publisher
|
||||||
|
|
||||||
|
post_type = request.json.get('post_type')
|
||||||
|
company_id = request.json.get('company_id')
|
||||||
|
event_id = request.json.get('event_id')
|
||||||
|
custom_context = request.json.get('custom_context', {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build context
|
||||||
|
context = {}
|
||||||
|
if company_id:
|
||||||
|
context.update(social_publisher.get_company_context(int(company_id)))
|
||||||
|
if event_id:
|
||||||
|
context.update(social_publisher.get_event_context(int(event_id)))
|
||||||
|
context.update(custom_context)
|
||||||
|
|
||||||
|
# Fill defaults for missing fields
|
||||||
|
defaults = {
|
||||||
|
'company_name': '', 'category': '', 'city': 'Wejherowo',
|
||||||
|
'description': '', 'website': '',
|
||||||
|
'event_title': '', 'event_date': '', 'event_location': '',
|
||||||
|
'event_description': '', 'event_topics': '', 'attendees_count': '',
|
||||||
|
'topic': '', 'source': '', 'facts': '', 'details': '',
|
||||||
|
}
|
||||||
|
for key, default in defaults.items():
|
||||||
|
context.setdefault(key, default)
|
||||||
|
|
||||||
|
content, model = social_publisher.generate_content(post_type, context)
|
||||||
|
return jsonify({'success': True, 'content': content, 'model': model})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI generation failed: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SOCIAL PUBLISHER - SETTINGS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@bp.route('/social-publisher/settings', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.ADMIN)
|
||||||
|
def social_publisher_settings():
|
||||||
|
"""Konfiguracja Facebook (tylko admin)."""
|
||||||
|
from services.social_publisher_service import social_publisher
|
||||||
|
|
||||||
|
config = social_publisher.get_fb_config()
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
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')
|
||||||
|
return redirect(url_for('admin.social_publisher_settings'))
|
||||||
|
|
||||||
|
return render_template('admin/social_publisher_settings.html', config=config)
|
||||||
83
database.py
83
database.py
@ -5322,6 +5322,89 @@ class OAuthToken(Base):
|
|||||||
return f'<OAuthToken {self.provider}/{self.service} for company_id={self.company_id}>'
|
return f'<OAuthToken {self.provider}/{self.service} for company_id={self.company_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SOCIAL MEDIA PUBLISHER
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class SocialPost(Base):
|
||||||
|
"""Social media posts for NORDA chamber page publishing."""
|
||||||
|
__tablename__ = 'social_media_posts'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
||||||
|
# Typ i platforma
|
||||||
|
post_type = Column(String(50), nullable=False) # member_spotlight, regional_news, event_invitation, event_recap, chamber_news
|
||||||
|
platform = Column(String(20), nullable=False, default='facebook')
|
||||||
|
|
||||||
|
# Treść
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
hashtags = Column(Text)
|
||||||
|
image_path = Column(String(500))
|
||||||
|
|
||||||
|
# Kontekst (opcjonalny)
|
||||||
|
company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
|
||||||
|
event_id = Column(Integer, ForeignKey('norda_events.id'), nullable=True)
|
||||||
|
|
||||||
|
# Workflow status
|
||||||
|
status = Column(String(20), nullable=False, default='draft')
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
scheduled_at = Column(DateTime, nullable=True)
|
||||||
|
published_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Facebook response
|
||||||
|
meta_post_id = Column(String(100))
|
||||||
|
meta_response = Column(JSONBType)
|
||||||
|
|
||||||
|
# Engagement (cache z FB API)
|
||||||
|
engagement_likes = Column(Integer, default=0)
|
||||||
|
engagement_comments = Column(Integer, default=0)
|
||||||
|
engagement_shares = Column(Integer, default=0)
|
||||||
|
engagement_reach = Column(Integer)
|
||||||
|
engagement_updated_at = Column(DateTime)
|
||||||
|
|
||||||
|
# AI metadata
|
||||||
|
ai_model = Column(String(100))
|
||||||
|
ai_prompt_template = Column(String(100))
|
||||||
|
|
||||||
|
# Audyt
|
||||||
|
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||||
|
approved_by = Column(Integer, ForeignKey('users.id'))
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
company = relationship('Company', foreign_keys=[company_id])
|
||||||
|
event = relationship('NordaEvent', foreign_keys=[event_id])
|
||||||
|
creator = relationship('User', foreign_keys=[created_by])
|
||||||
|
approver = relationship('User', foreign_keys=[approved_by])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<SocialPost {self.id} type={self.post_type} status={self.status}>'
|
||||||
|
|
||||||
|
|
||||||
|
class SocialMediaConfig(Base):
|
||||||
|
"""Configuration for social media platform connections (NORDA chamber page)."""
|
||||||
|
__tablename__ = 'social_media_config'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
platform = Column(String(50), nullable=False, unique=True)
|
||||||
|
page_id = Column(String(100))
|
||||||
|
page_name = Column(String(255))
|
||||||
|
access_token = Column(Text)
|
||||||
|
token_expires_at = Column(DateTime)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
debug_mode = Column(Boolean, default=True)
|
||||||
|
config_data = Column(JSONBType)
|
||||||
|
updated_by = Column(Integer, ForeignKey('users.id'))
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
updater = relationship('User', foreign_keys=[updated_by])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<SocialMediaConfig {self.platform} page={self.page_name}>'
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# DATABASE INITIALIZATION
|
# DATABASE INITIALIZATION
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
75
database/migrations/070_social_publisher.sql
Normal file
75
database/migrations/070_social_publisher.sql
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
-- Social Media Publisher Tables
|
||||||
|
-- Created: 2026-02-17
|
||||||
|
|
||||||
|
-- Table 1: social_media_posts (posty do publikacji)
|
||||||
|
CREATE TABLE IF NOT EXISTS social_media_posts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Typ i platforma
|
||||||
|
post_type VARCHAR(50) NOT NULL, -- member_spotlight, regional_news, event_invitation, event_recap, chamber_news
|
||||||
|
platform VARCHAR(20) NOT NULL DEFAULT 'facebook',
|
||||||
|
|
||||||
|
-- Treść
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
hashtags TEXT,
|
||||||
|
image_path VARCHAR(500), -- ścieżka w static/uploads/social/
|
||||||
|
|
||||||
|
-- Kontekst (opcjonalny)
|
||||||
|
company_id INTEGER REFERENCES companies(id) ON DELETE SET NULL,
|
||||||
|
event_id INTEGER REFERENCES norda_events(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Workflow status
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, approved, scheduled, published, failed
|
||||||
|
|
||||||
|
-- Scheduling
|
||||||
|
scheduled_at TIMESTAMP,
|
||||||
|
published_at TIMESTAMP,
|
||||||
|
|
||||||
|
-- Facebook response
|
||||||
|
meta_post_id VARCHAR(100),
|
||||||
|
meta_response JSONB,
|
||||||
|
|
||||||
|
-- Engagement (cache z FB API)
|
||||||
|
engagement_likes INTEGER DEFAULT 0,
|
||||||
|
engagement_comments INTEGER DEFAULT 0,
|
||||||
|
engagement_shares INTEGER DEFAULT 0,
|
||||||
|
engagement_reach INTEGER,
|
||||||
|
engagement_updated_at TIMESTAMP,
|
||||||
|
|
||||||
|
-- AI metadata
|
||||||
|
ai_model VARCHAR(100),
|
||||||
|
ai_prompt_template VARCHAR(100),
|
||||||
|
|
||||||
|
-- Audyt
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
approved_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indeksy
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_social_posts_status ON social_media_posts(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_social_posts_type ON social_media_posts(post_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_social_posts_company ON social_media_posts(company_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_social_posts_scheduled ON social_media_posts(scheduled_at) WHERE status = 'scheduled';
|
||||||
|
|
||||||
|
-- Table 2: social_media_config (konfiguracja strony FB NORDA)
|
||||||
|
CREATE TABLE IF NOT EXISTS social_media_config (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
platform VARCHAR(50) NOT NULL UNIQUE, -- facebook
|
||||||
|
page_id VARCHAR(100),
|
||||||
|
page_name VARCHAR(255),
|
||||||
|
access_token TEXT, -- Page Access Token (never-expiring)
|
||||||
|
token_expires_at TIMESTAMP, -- NULL = never expires
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
debug_mode BOOLEAN DEFAULT TRUE, -- True = drafty, False = live
|
||||||
|
config_data JSONB, -- dodatkowa konfiguracja
|
||||||
|
updated_by INTEGER REFERENCES users(id),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Uprawnienia
|
||||||
|
GRANT ALL ON TABLE social_media_posts TO nordabiz_app;
|
||||||
|
GRANT ALL ON TABLE social_media_config TO nordabiz_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE social_media_posts_id_seq TO nordabiz_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE social_media_config_id_seq TO nordabiz_app;
|
||||||
@ -7,6 +7,7 @@ Uses OAuth 2.0 page tokens to access Facebook Page and Instagram Business data.
|
|||||||
API docs: https://developers.facebook.com/docs/graph-api/
|
API docs: https://developers.facebook.com/docs/graph-api/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
@ -121,3 +122,154 @@ class FacebookGraphService:
|
|||||||
result[f'ig_{name}_total'] = total
|
result[f'ig_{name}_total'] = total
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PUBLISHING METHODS (Social Publisher)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _post(self, endpoint: str, data: dict = None, files: dict = None) -> Optional[Dict]:
|
||||||
|
"""Make authenticated POST request."""
|
||||||
|
data = data or {}
|
||||||
|
params = {'access_token': self.access_token}
|
||||||
|
try:
|
||||||
|
if files:
|
||||||
|
resp = self.session.post(f"{self.BASE_URL}/{endpoint}", params=params, data=data, files=files)
|
||||||
|
else:
|
||||||
|
resp = self.session.post(f"{self.BASE_URL}/{endpoint}", params=params, data=data)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
error_data = None
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.error(f"Facebook API POST {endpoint} failed: {e}, response: {error_data}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Facebook API POST {endpoint} failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _delete(self, endpoint: str) -> Optional[Dict]:
|
||||||
|
"""Make authenticated DELETE request."""
|
||||||
|
params = {'access_token': self.access_token}
|
||||||
|
try:
|
||||||
|
resp = self.session.delete(f"{self.BASE_URL}/{endpoint}", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Facebook API DELETE {endpoint} failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def upload_photo_unpublished(self, page_id: str, image_path: str) -> Optional[str]:
|
||||||
|
"""Upload photo as unpublished to get photo_id for feed attachment.
|
||||||
|
|
||||||
|
Two-step process:
|
||||||
|
1. Upload photo with published=false -> get photo_id
|
||||||
|
2. Use photo_id in create_post() attached_media
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_id: Facebook Page ID
|
||||||
|
image_path: Local path to image file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Photo ID (media_fbid) or None on failure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
files = {'source': (image_path.split('/')[-1], f, 'image/png')}
|
||||||
|
result = self._post(f'{page_id}/photos', data={'published': 'false'}, files=files)
|
||||||
|
if result and 'id' in result:
|
||||||
|
logger.info(f"Uploaded unpublished photo: {result['id']}")
|
||||||
|
return result['id']
|
||||||
|
logger.error(f"Photo upload failed: {result}")
|
||||||
|
return None
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"Image file not found: {image_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_post(self, page_id: str, message: str, image_path: str = None,
|
||||||
|
published: bool = True, scheduled_time: int = None) -> Optional[Dict]:
|
||||||
|
"""Create a post on Facebook Page feed.
|
||||||
|
|
||||||
|
For posts with images, uses two-step process:
|
||||||
|
1. Upload photo as unpublished -> photo_id
|
||||||
|
2. Create feed post with attached_media[0]
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_id: Facebook Page ID
|
||||||
|
message: Post text content
|
||||||
|
image_path: Optional local path to image
|
||||||
|
published: If False, creates draft (visible only to page admins)
|
||||||
|
scheduled_time: Unix timestamp for scheduled publishing (min 10 min ahead)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response dict with 'id' key, or None on failure
|
||||||
|
"""
|
||||||
|
data = {'message': message}
|
||||||
|
|
||||||
|
# Handle image upload
|
||||||
|
if image_path:
|
||||||
|
photo_id = self.upload_photo_unpublished(page_id, image_path)
|
||||||
|
if photo_id:
|
||||||
|
data['attached_media[0]'] = json.dumps({'media_fbid': photo_id})
|
||||||
|
else:
|
||||||
|
logger.warning("Image upload failed, posting without image")
|
||||||
|
|
||||||
|
# Handle scheduling
|
||||||
|
if scheduled_time:
|
||||||
|
data['published'] = 'false'
|
||||||
|
data['scheduled_publish_time'] = str(scheduled_time)
|
||||||
|
elif not published:
|
||||||
|
data['published'] = 'false'
|
||||||
|
|
||||||
|
result = self._post(f'{page_id}/feed', data=data)
|
||||||
|
if result and 'id' in result:
|
||||||
|
logger.info(f"Created post: {result['id']} (published={published})")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def publish_draft(self, post_id: str) -> Optional[Dict]:
|
||||||
|
"""Publish an unpublished (draft) post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: Facebook post ID (format: PAGE_ID_POST_ID)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response or None on failure
|
||||||
|
"""
|
||||||
|
return self._post(post_id, data={'is_published': 'true'})
|
||||||
|
|
||||||
|
def get_post_engagement(self, post_id: str) -> Optional[Dict]:
|
||||||
|
"""Get engagement metrics for a published post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: Facebook post ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with likes, comments, shares, reactions_total or None
|
||||||
|
"""
|
||||||
|
fields = 'id,created_time,message,likes.summary(true),comments.summary(true),shares,reactions.summary(true).limit(0)'
|
||||||
|
result = self._get(post_id, {'fields': fields})
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'post_id': result.get('id'),
|
||||||
|
'created_time': result.get('created_time'),
|
||||||
|
'likes': result.get('likes', {}).get('summary', {}).get('total_count', 0),
|
||||||
|
'comments': result.get('comments', {}).get('summary', {}).get('total_count', 0),
|
||||||
|
'shares': result.get('shares', {}).get('count', 0) if result.get('shares') else 0,
|
||||||
|
'reactions_total': result.get('reactions', {}).get('summary', {}).get('total_count', 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_post(self, post_id: str) -> bool:
|
||||||
|
"""Delete a post (published or unpublished).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_id: Facebook post ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted successfully
|
||||||
|
"""
|
||||||
|
result = self._delete(post_id)
|
||||||
|
return bool(result and result.get('success'))
|
||||||
|
|||||||
520
services/social_publisher_service.py
Normal file
520
services/social_publisher_service.py
Normal file
@ -0,0 +1,520 @@
|
|||||||
|
"""
|
||||||
|
Social Media Publisher Service
|
||||||
|
==============================
|
||||||
|
|
||||||
|
Business logic for creating, editing, scheduling and publishing social media posts
|
||||||
|
for the NORDA Business Chamber Facebook page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, List, Tuple
|
||||||
|
|
||||||
|
from database import SessionLocal, SocialPost, SocialMediaConfig, Company, NordaEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Post types supported
|
||||||
|
POST_TYPES = {
|
||||||
|
'member_spotlight': 'Poznaj Członka NORDA',
|
||||||
|
'event_invitation': 'Zaproszenie na Wydarzenie',
|
||||||
|
'event_recap': 'Relacja z Wydarzenia',
|
||||||
|
'regional_news': 'Aktualności Regionalne',
|
||||||
|
'chamber_news': 'Aktualności Izby',
|
||||||
|
}
|
||||||
|
|
||||||
|
# AI Prompt templates for NORDA chamber posts (Polish)
|
||||||
|
AI_PROMPTS = {
|
||||||
|
'member_spotlight': """Napisz post na Facebook dla Izby Gospodarczej NORDA Biznes, przedstawiający firmę członkowską:
|
||||||
|
|
||||||
|
Firma: {company_name}
|
||||||
|
Branża: {category}
|
||||||
|
Miasto: {city}
|
||||||
|
Opis: {description}
|
||||||
|
Strona WWW: {website}
|
||||||
|
|
||||||
|
Post powinien:
|
||||||
|
- Zaczynać się od ciepłego nagłówka typu "Poznajcie naszego członka!" lub podobnego
|
||||||
|
- Opisać czym zajmuje się firma (2-3 zdania)
|
||||||
|
- Podkreślić wartość tej firmy dla społeczności biznesowej
|
||||||
|
- Zachęcić do odwiedzenia strony firmy
|
||||||
|
- Zawierać 3-5 hashtagów (#NordaBiznes #IzbaGospodarcza #Wejherowo + branżowe)
|
||||||
|
- Być napisany ciepło, z dumą i wspierająco
|
||||||
|
- Mieć 100-200 słów
|
||||||
|
- Być po polsku
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem postu (z hashtagami na końcu).""",
|
||||||
|
|
||||||
|
'event_invitation': """Napisz post na Facebook zapraszający na wydarzenie Izby Gospodarczej NORDA Biznes:
|
||||||
|
|
||||||
|
Wydarzenie: {event_title}
|
||||||
|
Data: {event_date}
|
||||||
|
Miejsce: {event_location}
|
||||||
|
Opis: {event_description}
|
||||||
|
|
||||||
|
Post powinien:
|
||||||
|
- Być entuzjastyczny i zachęcający do udziału
|
||||||
|
- Zawierać najważniejsze informacje (co, kiedy, gdzie)
|
||||||
|
- Wspomnieć korzyści z udziału
|
||||||
|
- Zawierać CTA (zachęta do rejestracji/kontaktu)
|
||||||
|
- Zawierać 3-5 hashtagów (#NordaBiznes #Networking #Wejherowo + tematyczne)
|
||||||
|
- Mieć 100-150 słów
|
||||||
|
- Być po polsku
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem postu.""",
|
||||||
|
|
||||||
|
'event_recap': """Napisz post na Facebook będący relacją z wydarzenia Izby Gospodarczej NORDA Biznes:
|
||||||
|
|
||||||
|
Wydarzenie: {event_title}
|
||||||
|
Data: {event_date}
|
||||||
|
Miejsce: {event_location}
|
||||||
|
Tematy: {event_topics}
|
||||||
|
Liczba uczestników: {attendees_count}
|
||||||
|
|
||||||
|
Post powinien:
|
||||||
|
- Dziękować uczestnikom za obecność
|
||||||
|
- Podsumować najważniejsze tematy/wnioski
|
||||||
|
- Wspomnieć atmosferę i wartość spotkania
|
||||||
|
- Zachęcić do udziału w kolejnych wydarzeniach
|
||||||
|
- Zawierać 3-5 hashtagów
|
||||||
|
- Mieć 100-200 słów
|
||||||
|
- Być po polsku, relacyjny i dziękujący
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem postu.""",
|
||||||
|
|
||||||
|
'regional_news': """Napisz post na Facebook dla Izby Gospodarczej NORDA Biznes komentujący aktualność regionalną:
|
||||||
|
|
||||||
|
Temat: {topic}
|
||||||
|
Źródło: {source}
|
||||||
|
Fakty: {facts}
|
||||||
|
|
||||||
|
Post powinien:
|
||||||
|
- Informować o temacie w kontekście biznesowym regionu
|
||||||
|
- Być informacyjny i ekspercki
|
||||||
|
- Dodać perspektywę Izby (jak to wpływa na lokalny biznes)
|
||||||
|
- Zawierać 3-5 hashtagów (#NordaBiznes #Pomorze #Wejherowo + tematyczne)
|
||||||
|
- Mieć 100-200 słów
|
||||||
|
- Być po polsku
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem postu.""",
|
||||||
|
|
||||||
|
'chamber_news': """Napisz post na Facebook z aktualnościami Izby Gospodarczej NORDA Biznes:
|
||||||
|
|
||||||
|
Temat: {topic}
|
||||||
|
Szczegóły: {details}
|
||||||
|
|
||||||
|
Post powinien:
|
||||||
|
- Być oficjalny ale przystępny
|
||||||
|
- Przekazać najważniejsze informacje
|
||||||
|
- Zachęcić do interakcji (pytania, komentarze)
|
||||||
|
- Zawierać 3-5 hashtagów (#NordaBiznes #IzbaGospodarcza + tematyczne)
|
||||||
|
- Mieć 80-150 słów
|
||||||
|
- Być po polsku
|
||||||
|
|
||||||
|
Odpowiedz WYŁĄCZNIE tekstem postu.""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SocialPublisherService:
|
||||||
|
"""Service for managing social media posts for NORDA chamber."""
|
||||||
|
|
||||||
|
# ---- CRUD Operations ----
|
||||||
|
|
||||||
|
def get_posts(self, status: str = None, post_type: str = None,
|
||||||
|
limit: int = 50, offset: int = 0) -> List[SocialPost]:
|
||||||
|
"""Get posts with optional filters."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
query = db.query(SocialPost).order_by(SocialPost.created_at.desc())
|
||||||
|
if status:
|
||||||
|
query = query.filter(SocialPost.status == status)
|
||||||
|
if post_type:
|
||||||
|
query = query.filter(SocialPost.post_type == post_type)
|
||||||
|
return query.offset(offset).limit(limit).all()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def get_post(self, post_id: int) -> Optional[SocialPost]:
|
||||||
|
"""Get single post by ID."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
return db.query(SocialPost).filter(SocialPost.id == post_id).first()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
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,
|
||||||
|
image_path: str = None, ai_model: str = None,
|
||||||
|
ai_prompt_template: str = None) -> SocialPost:
|
||||||
|
"""Create a new post draft."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
post = SocialPost(
|
||||||
|
post_type=post_type,
|
||||||
|
platform=platform,
|
||||||
|
content=content,
|
||||||
|
hashtags=hashtags,
|
||||||
|
image_path=image_path,
|
||||||
|
company_id=company_id,
|
||||||
|
event_id=event_id,
|
||||||
|
status='draft',
|
||||||
|
ai_model=ai_model,
|
||||||
|
ai_prompt_template=ai_prompt_template,
|
||||||
|
created_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(post)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(post)
|
||||||
|
logger.info(f"Created social post #{post.id} type={post_type}")
|
||||||
|
return post
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to create social post: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def update_post(self, post_id: int, **kwargs) -> Optional[SocialPost]:
|
||||||
|
"""Update post fields. Only draft/approved posts can be edited."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
return None
|
||||||
|
if post.status in ('published', 'failed'):
|
||||||
|
logger.warning(f"Cannot edit post #{post_id} with status={post.status}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
allowed_fields = {'content', 'hashtags', 'image_path', 'post_type',
|
||||||
|
'company_id', 'event_id', 'scheduled_at'}
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if key in allowed_fields:
|
||||||
|
setattr(post, key, value)
|
||||||
|
|
||||||
|
post.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(post)
|
||||||
|
return post
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to update post #{post_id}: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def delete_post(self, post_id: int) -> bool:
|
||||||
|
"""Delete a post (only draft/approved)."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
return False
|
||||||
|
if post.status == 'published':
|
||||||
|
logger.warning(f"Cannot delete published post #{post_id}")
|
||||||
|
return False
|
||||||
|
db.delete(post)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Deleted social post #{post_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to delete post #{post_id}: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# ---- Workflow ----
|
||||||
|
|
||||||
|
def approve_post(self, post_id: int, user_id: int) -> Optional[SocialPost]:
|
||||||
|
"""Approve a draft post."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
|
||||||
|
if not post or post.status != 'draft':
|
||||||
|
return None
|
||||||
|
post.status = 'approved'
|
||||||
|
post.approved_by = user_id
|
||||||
|
post.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(post)
|
||||||
|
logger.info(f"Approved post #{post_id} by user #{user_id}")
|
||||||
|
return post
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def schedule_post(self, post_id: int, scheduled_at: datetime) -> Optional[SocialPost]:
|
||||||
|
"""Schedule an approved post for future publishing."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
|
||||||
|
if not post or post.status not in ('draft', 'approved'):
|
||||||
|
return None
|
||||||
|
post.status = 'scheduled'
|
||||||
|
post.scheduled_at = scheduled_at
|
||||||
|
post.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(post)
|
||||||
|
logger.info(f"Scheduled post #{post_id} for {scheduled_at}")
|
||||||
|
return post
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def publish_post(self, post_id: int) -> Tuple[bool, str]:
|
||||||
|
"""Publish a post to Facebook immediately.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success: bool, message: str)
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
return False, "Post nie znaleziony"
|
||||||
|
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()
|
||||||
|
|
||||||
|
if not config or not config.access_token or not config.page_id:
|
||||||
|
return False, "Facebook nie jest skonfigurowany. Ustaw token w Ustawieniach."
|
||||||
|
|
||||||
|
# Build message with hashtags
|
||||||
|
message = post.content
|
||||||
|
if post.hashtags:
|
||||||
|
message += f"\n\n{post.hashtags}"
|
||||||
|
|
||||||
|
# Determine if draft mode
|
||||||
|
published = not config.debug_mode
|
||||||
|
|
||||||
|
# Publish via Facebook Graph API
|
||||||
|
from facebook_graph_service import FacebookGraphService
|
||||||
|
fb = FacebookGraphService(config.access_token)
|
||||||
|
|
||||||
|
image_path = None
|
||||||
|
if post.image_path:
|
||||||
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
full_path = os.path.join(os.path.dirname(base_dir), post.image_path)
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
image_path = full_path
|
||||||
|
|
||||||
|
result = fb.create_post(
|
||||||
|
page_id=config.page_id,
|
||||||
|
message=message,
|
||||||
|
image_path=image_path,
|
||||||
|
published=published,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result and 'id' in result:
|
||||||
|
post.status = 'published'
|
||||||
|
post.published_at = datetime.now()
|
||||||
|
post.meta_post_id = result['id']
|
||||||
|
post.meta_response = result
|
||||||
|
post.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
mode = "DRAFT (debug)" if config.debug_mode else "LIVE"
|
||||||
|
logger.info(f"Published post #{post_id} -> FB {result['id']} ({mode})")
|
||||||
|
return True, f"Post opublikowany ({mode}): {result['id']}"
|
||||||
|
else:
|
||||||
|
post.status = 'failed'
|
||||||
|
post.meta_response = result
|
||||||
|
post.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
return False, "Publikacja nie powiodła się. Sprawdź konfigurację Facebook."
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to publish post #{post_id}: {e}")
|
||||||
|
return False, f"Blad publikacji: {str(e)}"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# ---- AI Content Generation ----
|
||||||
|
|
||||||
|
def generate_content(self, post_type: str, context: dict) -> Tuple[str, str]:
|
||||||
|
"""Generate post content using AI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_type: One of POST_TYPES keys
|
||||||
|
context: Dict with template variables
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(content: str, ai_model: str)
|
||||||
|
"""
|
||||||
|
template = AI_PROMPTS.get(post_type)
|
||||||
|
if not template:
|
||||||
|
raise ValueError(f"Unknown post type: {post_type}")
|
||||||
|
|
||||||
|
# Fill template with context
|
||||||
|
try:
|
||||||
|
prompt = template.format(**context)
|
||||||
|
except KeyError as e:
|
||||||
|
raise ValueError(f"Missing context field: {e}")
|
||||||
|
|
||||||
|
# Generate with Gemini
|
||||||
|
from gemini_service import generate_text
|
||||||
|
result = generate_text(prompt)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise RuntimeError("AI nie wygenerował treści. Spróbuj ponownie.")
|
||||||
|
|
||||||
|
return result.strip(), 'gemini-3-flash'
|
||||||
|
|
||||||
|
def get_company_context(self, company_id: int) -> dict:
|
||||||
|
"""Get company data for AI prompt context."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
company = db.query(Company).filter(Company.id == company_id).first()
|
||||||
|
if not company:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
'company_name': company.name or '',
|
||||||
|
'category': company.category or '',
|
||||||
|
'city': company.city or 'Wejherowo',
|
||||||
|
'description': company.description or company.short_description or '',
|
||||||
|
'website': company.website or '',
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def get_event_context(self, event_id: int) -> dict:
|
||||||
|
"""Get event data for AI prompt context."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
|
||||||
|
if not event:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
'event_title': event.title or '',
|
||||||
|
'event_date': event.event_date.strftime('%d.%m.%Y %H:%M') if event.event_date else '',
|
||||||
|
'event_location': event.location or '',
|
||||||
|
'event_description': event.description or '',
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# ---- Facebook Config ----
|
||||||
|
|
||||||
|
def get_fb_config(self) -> Optional[SocialMediaConfig]:
|
||||||
|
"""Get Facebook configuration."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
return db.query(SocialMediaConfig).filter(
|
||||||
|
SocialMediaConfig.platform == 'facebook'
|
||||||
|
).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."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
config = db.query(SocialMediaConfig).filter(
|
||||||
|
SocialMediaConfig.platform == 'facebook'
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
config = SocialMediaConfig(platform='facebook')
|
||||||
|
db.add(config)
|
||||||
|
|
||||||
|
config.page_id = page_id
|
||||||
|
config.page_name = page_name
|
||||||
|
config.access_token = access_token
|
||||||
|
config.debug_mode = debug_mode
|
||||||
|
config.is_active = True
|
||||||
|
config.updated_by = user_id
|
||||||
|
config.updated_at = datetime.now()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(config)
|
||||||
|
logger.info(f"Saved FB config: page={page_name} debug={debug_mode}")
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to save FB config: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# ---- Engagement Tracking ----
|
||||||
|
|
||||||
|
def refresh_engagement(self, post_id: int) -> Optional[Dict]:
|
||||||
|
"""Refresh engagement metrics from Facebook for a published post."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
post = db.query(SocialPost).filter(SocialPost.id == post_id).first()
|
||||||
|
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:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from facebook_graph_service import FacebookGraphService
|
||||||
|
fb = FacebookGraphService(config.access_token)
|
||||||
|
engagement = fb.get_post_engagement(post.meta_post_id)
|
||||||
|
|
||||||
|
if engagement:
|
||||||
|
post.engagement_likes = engagement.get('likes', 0)
|
||||||
|
post.engagement_comments = engagement.get('comments', 0)
|
||||||
|
post.engagement_shares = engagement.get('shares', 0)
|
||||||
|
post.engagement_reach = engagement.get('reactions_total', 0)
|
||||||
|
post.engagement_updated_at = datetime.now()
|
||||||
|
post.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
return engagement
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# ---- Stats ----
|
||||||
|
|
||||||
|
def get_stats(self) -> Dict:
|
||||||
|
"""Get summary statistics for social publisher dashboard."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
total = db.query(func.count(SocialPost.id)).scalar() or 0
|
||||||
|
by_status = dict(
|
||||||
|
db.query(SocialPost.status, func.count(SocialPost.id))
|
||||||
|
.group_by(SocialPost.status).all()
|
||||||
|
)
|
||||||
|
by_type = dict(
|
||||||
|
db.query(SocialPost.post_type, func.count(SocialPost.id))
|
||||||
|
.group_by(SocialPost.post_type).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Avg engagement for published posts
|
||||||
|
avg_likes = db.query(func.avg(SocialPost.engagement_likes)).filter(
|
||||||
|
SocialPost.status == 'published'
|
||||||
|
).scalar() or 0
|
||||||
|
avg_comments = db.query(func.avg(SocialPost.engagement_comments)).filter(
|
||||||
|
SocialPost.status == 'published'
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total': total,
|
||||||
|
'by_status': by_status,
|
||||||
|
'by_type': by_type,
|
||||||
|
'avg_engagement': {
|
||||||
|
'likes': round(float(avg_likes), 1),
|
||||||
|
'comments': round(float(avg_comments), 1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
social_publisher = SocialPublisherService()
|
||||||
0
static/uploads/social/.gitkeep
Normal file
0
static/uploads/social/.gitkeep
Normal file
465
templates/admin/social_publisher.html
Normal file
465
templates/admin/social_publisher.html
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Social Media Publisher - Norda Biznes Partner{% 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.total { border-top: 3px solid var(--primary); }
|
||||||
|
.stat-card.draft { border-top: 3px solid var(--text-secondary); }
|
||||||
|
.stat-card.approved { border-top: 3px solid var(--info, #0ea5e9); }
|
||||||
|
.stat-card.scheduled { border-top: 3px solid var(--warning); }
|
||||||
|
.stat-card.published { border-top: 3px solid var(--success); }
|
||||||
|
.stat-card.failed { border-top: 3px solid var(--error); }
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-table th,
|
||||||
|
.posts-table td {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-table tr:hover {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-preview {
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.draft { background: var(--surface-secondary, #f1f5f9); color: var(--text-secondary); }
|
||||||
|
.status-badge.approved { background: #e0f2fe; color: #0369a1; }
|
||||||
|
.status-badge.scheduled { background: var(--warning-bg); color: var(--warning); }
|
||||||
|
.status-badge.published { background: var(--success-bg); color: var(--success); }
|
||||||
|
.status-badge.failed { background: var(--error-bg); color: var(--error); }
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--primary-bg);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-cell {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-cell span {
|
||||||
|
margin-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-2xl);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.posts-table {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
.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(6),
|
||||||
|
.posts-table td:nth-child(6) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Social Media Publisher</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="{{ url_for('admin.social_publisher_settings') }}" class="btn btn-secondary">Ustawienia</a>
|
||||||
|
<a href="{{ url_for('admin.social_publisher_new') }}" class="btn btn-primary">+ Nowy Post</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statystyki -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card total">
|
||||||
|
<div class="stat-value">{{ stats.total or 0 }}</div>
|
||||||
|
<div class="stat-label">Wszystkie</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card draft">
|
||||||
|
<div class="stat-value">{{ stats.draft or 0 }}</div>
|
||||||
|
<div class="stat-label">Szkice</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card approved">
|
||||||
|
<div class="stat-value">{{ stats.approved or 0 }}</div>
|
||||||
|
<div class="stat-label">Zatwierdzone</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card scheduled">
|
||||||
|
<div class="stat-value">{{ stats.scheduled or 0 }}</div>
|
||||||
|
<div class="stat-label">Zaplanowane</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card published">
|
||||||
|
<div class="stat-value">{{ stats.published or 0 }}</div>
|
||||||
|
<div class="stat-label">Opublikowane</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card failed">
|
||||||
|
<div class="stat-value">{{ stats.failed or 0 }}</div>
|
||||||
|
<div class="stat-label">Bledy</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtry -->
|
||||||
|
<div class="filters-row">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="status-filter">Status:</label>
|
||||||
|
<select id="status-filter" onchange="applyFilters()">
|
||||||
|
<option value="all" {% if status_filter == 'all' %}selected{% endif %}>Wszystkie</option>
|
||||||
|
<option value="draft" {% if status_filter == 'draft' %}selected{% endif %}>Szkic</option>
|
||||||
|
<option value="approved" {% if status_filter == 'approved' %}selected{% endif %}>Zatwierdzony</option>
|
||||||
|
<option value="scheduled" {% if status_filter == 'scheduled' %}selected{% endif %}>Zaplanowany</option>
|
||||||
|
<option value="published" {% if status_filter == 'published' %}selected{% endif %}>Opublikowany</option>
|
||||||
|
<option value="failed" {% if status_filter == 'failed' %}selected{% endif %}>Błąd</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="type-filter">Typ:</label>
|
||||||
|
<select id="type-filter" onchange="applyFilters()">
|
||||||
|
<option value="all" {% if type_filter == 'all' %}selected{% endif %}>Wszystkie</option>
|
||||||
|
{% for key, label in post_types.items() %}
|
||||||
|
<option value="{{ key }}" {% if type_filter == key %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabela postow -->
|
||||||
|
<div class="section">
|
||||||
|
{% if posts %}
|
||||||
|
<table class="posts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Tresc</th>
|
||||||
|
<th>Firma</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Engagement</th>
|
||||||
|
<th>Akcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for post in posts %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="type-badge">{{ post_types.get(post.post_type, post.post_type) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="content-preview">
|
||||||
|
<a href="{{ url_for('admin.social_publisher_edit', post_id=post.id) }}" style="color: var(--text-primary); text-decoration: none;">
|
||||||
|
{{ post.content[:80] }}{% if post.content|length > 80 %}...{% endif %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ post.company.name if post.company else '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge {{ post.status }}">
|
||||||
|
{% if post.status == 'draft' %}Szkic
|
||||||
|
{% elif post.status == 'approved' %}Zatwierdzony
|
||||||
|
{% elif post.status == 'scheduled' %}Zaplanowany
|
||||||
|
{% elif post.status == 'published' %}Opublikowany
|
||||||
|
{% elif post.status == 'failed' %}Błąd
|
||||||
|
{% else %}{{ post.status }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="white-space: nowrap; font-size: var(--font-size-sm); color: var(--text-secondary);">
|
||||||
|
{% if post.published_at %}
|
||||||
|
{{ post.published_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
|
{% elif post.scheduled_at %}
|
||||||
|
{{ post.scheduled_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
|
{% else %}
|
||||||
|
{{ post.created_at.strftime('%Y-%m-%d %H:%M') if post.created_at else '-' }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="engagement-cell">
|
||||||
|
{% if post.status == 'published' and (post.engagement_likes or post.engagement_comments or post.engagement_shares) %}
|
||||||
|
<span title="Polubienia">👍 {{ post.engagement_likes or 0 }}</span>
|
||||||
|
<span title="Komentarze">💬 {{ post.engagement_comments or 0 }}</span>
|
||||||
|
<span title="Udostepnienia">🔁 {{ post.engagement_shares or 0 }}</span>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<a href="{{ url_for('admin.social_publisher_edit', post_id=post.id) }}" class="btn btn-secondary btn-small">
|
||||||
|
Edytuj
|
||||||
|
</a>
|
||||||
|
{% if post.status == 'draft' %}
|
||||||
|
<form method="POST" action="{{ url_for('admin.social_publisher_approve', post_id=post.id) }}" style="display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-info btn-small" style="background: #0ea5e9; color: white; border: none;">Zatwierdz</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if post.status in ['draft', 'approved'] %}
|
||||||
|
<button class="btn btn-success btn-small" onclick="publishPost({{ post.id }})">Publikuj</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if post.status != 'published' %}
|
||||||
|
<button class="btn btn-error btn-small" onclick="deletePost({{ post.id }})">Usun</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Brak postow{% if status_filter != 'all' or type_filter != 'all' %} pasujacych do filtrow{% endif %}.</p>
|
||||||
|
<p style="margin-top: var(--spacing-md);">
|
||||||
|
<a href="{{ url_for('admin.social_publisher_new') }}" class="btn btn-primary">Utworz pierwszy post</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Modal -->
|
||||||
|
<div class="modal-overlay" id="confirmModal">
|
||||||
|
<div class="modal" style="max-width: 420px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
|
||||||
|
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
|
||||||
|
<div class="modal-icon" id="confirmModalIcon" style="font-size: 3em; margin-bottom: var(--spacing-md);">❓</div>
|
||||||
|
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
|
||||||
|
<p id="confirmModalMessage" style="color: var(--text-secondary);"></p>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: var(--spacing-sm); justify-content: center;">
|
||||||
|
<button type="button" class="btn btn-secondary" id="confirmModalCancel">Anuluj</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="confirmModalOk">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
|
||||||
|
.modal-overlay#confirmModal.active { display: flex; }
|
||||||
|
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: toastIn 0.3s ease; }
|
||||||
|
.toast.success { border-left-color: var(--success); }
|
||||||
|
.toast.error { border-left-color: var(--error); }
|
||||||
|
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||||
|
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
function applyFilters() {
|
||||||
|
const status = document.getElementById('status-filter').value;
|
||||||
|
const type = document.getElementById('type-filter').value;
|
||||||
|
let url = '{{ url_for("admin.social_publisher_list") }}?';
|
||||||
|
if (status !== 'all') url += 'status=' + status + '&';
|
||||||
|
if (type !== 'all') url += 'type=' + type + '&';
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirmResolve = null;
|
||||||
|
|
||||||
|
function showConfirm(message, options = {}) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
confirmResolve = resolve;
|
||||||
|
document.getElementById('confirmModalIcon').innerHTML = options.icon || '❓';
|
||||||
|
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
|
||||||
|
document.getElementById('confirmModalMessage').innerHTML = message;
|
||||||
|
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
|
||||||
|
document.getElementById('confirmModalOk').className = 'btn ' + (options.okClass || 'btn-primary');
|
||||||
|
document.getElementById('confirmModal').classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm(result) {
|
||||||
|
document.getElementById('confirmModal').classList.remove('active');
|
||||||
|
if (confirmResolve) { confirmResolve(result); confirmResolve = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('confirmModalOk').addEventListener('click', () => closeConfirm(true));
|
||||||
|
document.getElementById('confirmModalCancel').addEventListener('click', () => closeConfirm(false));
|
||||||
|
document.getElementById('confirmModal').addEventListener('click', e => { if (e.target.id === 'confirmModal') closeConfirm(false); });
|
||||||
|
|
||||||
|
function showToast(message, type = 'info', duration = 4000) {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
const icons = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast ' + type;
|
||||||
|
toast.innerHTML = '<span style="font-size:1.2em">' + (icons[type]||'ℹ') + '</span><span>' + message + '</span>';
|
||||||
|
container.appendChild(toast);
|
||||||
|
setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishPost(id) {
|
||||||
|
const confirmed = await showConfirm('Czy na pewno chcesz opublikować ten post na Facebook?', {
|
||||||
|
icon: '📣',
|
||||||
|
title: 'Publikacja posta',
|
||||||
|
okText: 'Publikuj',
|
||||||
|
okClass: 'btn-success'
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('{{ url_for("admin.social_publisher_publish", post_id=0) }}'.replace('/0/', '/' + id + '/'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Post został opublikowany na Facebook', 'success');
|
||||||
|
setTimeout(() => location.reload(), 1500);
|
||||||
|
} else {
|
||||||
|
showToast('Błąd: ' + (data.error || 'Nieznany błąd'), 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Błąd połączenia: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePost(id) {
|
||||||
|
const confirmed = await showConfirm('Czy na pewno chcesz usunąć ten post? Ta operacja jest nieodwracalna.', {
|
||||||
|
icon: '🗑',
|
||||||
|
title: 'Usuwanie posta',
|
||||||
|
okText: 'Usun',
|
||||||
|
okClass: 'btn-error'
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('{{ url_for("admin.social_publisher_delete", post_id=0) }}'.replace('/0/', '/' + id + '/'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Post został usunięty', 'success');
|
||||||
|
setTimeout(() => location.reload(), 1500);
|
||||||
|
} else {
|
||||||
|
showToast('Błąd: ' + (data.error || 'Nieznany błąd'), 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Błąd połączenia: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{% endblock %}
|
||||||
430
templates/admin/social_publisher_form.html
Normal file
430
templates/admin/social_publisher_form.html
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if post %}Edycja Posta #{{ post.id }}{% else %}Nowy Post{% endif %} - Social Publisher{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.admin-header {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 input[type="datetime-local"],
|
||||||
|
.form-group select,
|
||||||
|
.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: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea.content-editor {
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
padding-bottom: var(--spacing-xs);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
background: var(--background);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info strong {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-box {
|
||||||
|
background: var(--background);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-item .value {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-item .label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-none {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-counter {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: right;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-counter.warning { color: var(--warning); }
|
||||||
|
.char-counter.over { color: var(--error); }
|
||||||
|
|
||||||
|
.fb-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
color: #1877f2;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>{% if post %}Edycja Posta #{{ post.id }}{% else %}Nowy Post{% endif %}</h1>
|
||||||
|
<a href="{{ url_for('admin.social_publisher_list') }}" class="btn btn-secondary">Powrot do listy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if post %}
|
||||||
|
<div class="status-info">
|
||||||
|
<strong>Status:</strong>
|
||||||
|
{% if post.status == 'draft' %}Szkic
|
||||||
|
{% elif post.status == 'approved' %}Zatwierdzony
|
||||||
|
{% elif post.status == 'scheduled' %}Zaplanowany
|
||||||
|
{% elif post.status == 'published' %}Opublikowany
|
||||||
|
{% elif post.status == 'failed' %}Błąd
|
||||||
|
{% else %}{{ post.status }}{% endif %}
|
||||||
|
{% if post.creator %}
|
||||||
|
| <strong>Autor:</strong> {{ post.creator.name }}
|
||||||
|
{% endif %}
|
||||||
|
{% if post.ai_model %}
|
||||||
|
| <strong>Model AI:</strong> {{ post.ai_model }}
|
||||||
|
{% endif %}
|
||||||
|
{% if post.published_at %}
|
||||||
|
| <strong>Opublikowano:</strong> {{ post.published_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<form method="POST" id="postForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<!-- Typ posta -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="post_type">Typ posta</label>
|
||||||
|
<select id="post_type" name="post_type" required>
|
||||||
|
<option value="">-- Wybierz typ --</option>
|
||||||
|
{% for key, label in post_types.items() %}
|
||||||
|
<option value="{{ key }}" {% if post and post.post_type == key %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Firma (widoczna dla member_spotlight) -->
|
||||||
|
<div class="form-group" id="company-field">
|
||||||
|
<label for="company_id">Firma</label>
|
||||||
|
<select id="company_id" name="company_id">
|
||||||
|
<option value="">-- Wybierz firmę --</option>
|
||||||
|
{% for company in companies %}
|
||||||
|
<option value="{{ company.id }}" {% if post and post.company_id == company.id %}selected{% endif %}>{{ company.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="form-hint">Firma, którą chcesz zaprezentować w poście</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wydarzenie (widoczne dla event_*) -->
|
||||||
|
<div class="form-group" id="event-field">
|
||||||
|
<label for="event_id">Wydarzenie</label>
|
||||||
|
<select id="event_id" name="event_id">
|
||||||
|
<option value="">-- Wybierz wydarzenie --</option>
|
||||||
|
{% for event in events %}
|
||||||
|
<option value="{{ event.id }}" {% if post and post.event_id == event.id %}selected{% endif %}>{{ event.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="form-hint">Wydarzenie powiązane z postem</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kontekst dodatkowy (dla regional_news/chamber_news) -->
|
||||||
|
<div class="form-group" id="custom-context-field">
|
||||||
|
<h3 class="section-title">Kontekst dla AI</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="custom_topic">Temat</label>
|
||||||
|
<input type="text" id="custom_topic" name="custom_topic" placeholder="np. Nowa inwestycja w porcie Gdynia">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="custom_details">Szczegoly</label>
|
||||||
|
<textarea id="custom_details" name="custom_details" rows="3" placeholder="Dodatkowe informacje, które AI powinno uwzględnić..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="custom_facts">Fakty/dane</label>
|
||||||
|
<input type="text" id="custom_facts" name="custom_facts" placeholder="np. Wartosc: 50 mln PLN, termin: Q3 2026">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="custom_source">Zrodlo</label>
|
||||||
|
<input type="text" id="custom_source" name="custom_source" placeholder="np. Portal Morski, komunikat prasowy">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Przycisk generowania AI -->
|
||||||
|
<div class="form-group" style="text-align: right;">
|
||||||
|
<button type="button" id="btn-generate-ai" class="btn btn-generate">
|
||||||
|
Generuj AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tresc -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="content">Tresc posta</label>
|
||||||
|
<textarea id="content" name="content" class="content-editor" required
|
||||||
|
placeholder="Wpisz tresc posta lub wygeneruj za pomoca AI...">{{ post.content if post else '' }}</textarea>
|
||||||
|
<div class="char-counter" id="content-counter">0 znaków</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hashtagi -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="hashtags">Hashtagi</label>
|
||||||
|
<input type="text" id="hashtags" name="hashtags"
|
||||||
|
value="{{ post.hashtags if post else '' }}"
|
||||||
|
placeholder="#NordaBiznes #Wejherowo #Pomorze">
|
||||||
|
<p class="form-hint">Hashtagi oddzielone spacjami</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Akcje -->
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="{{ url_for('admin.social_publisher_list') }}" class="btn btn-secondary">Anuluj</a>
|
||||||
|
|
||||||
|
{% if post %}
|
||||||
|
<button type="submit" name="action" value="save" class="btn btn-primary">Zapisz zmiany</button>
|
||||||
|
{% if post.status == 'draft' %}
|
||||||
|
<button type="submit" name="action" value="approve" class="btn btn-info" style="background: #0ea5e9; color: white; border: none;">Zatwierdź</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if post.status in ['draft', 'approved'] %}
|
||||||
|
<button type="submit" name="action" value="publish" class="btn btn-success">Publikuj teraz</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if post.status != 'published' %}
|
||||||
|
<button type="submit" name="action" value="delete" class="btn btn-error"
|
||||||
|
onclick="return confirm('Czy na pewno chcesz usunąć ten post?');">Usuń</button>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" name="action" value="draft" class="btn btn-secondary">Zapisz szkic</button>
|
||||||
|
<button type="submit" name="action" value="publish" class="btn btn-success">Publikuj teraz</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if post and post.status == 'published' %}
|
||||||
|
<!-- Engagement metrics -->
|
||||||
|
<div class="engagement-box">
|
||||||
|
<h3 class="section-title" style="margin-top: 0;">Engagement</h3>
|
||||||
|
<div class="engagement-grid">
|
||||||
|
<div class="engagement-item">
|
||||||
|
<div class="value">{{ post.engagement_likes or 0 }}</div>
|
||||||
|
<div class="label">Polubienia</div>
|
||||||
|
</div>
|
||||||
|
<div class="engagement-item">
|
||||||
|
<div class="value">{{ post.engagement_comments or 0 }}</div>
|
||||||
|
<div class="label">Komentarze</div>
|
||||||
|
</div>
|
||||||
|
<div class="engagement-item">
|
||||||
|
<div class="value">{{ post.engagement_shares or 0 }}</div>
|
||||||
|
<div class="label">Udostepnienia</div>
|
||||||
|
</div>
|
||||||
|
<div class="engagement-item">
|
||||||
|
<div class="value">{{ post.engagement_reach or 0 }}</div>
|
||||||
|
<div class="label">Zasieg</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: var(--spacing-md); align-items: center; flex-wrap: wrap;">
|
||||||
|
<form method="POST" action="{{ url_for('admin.social_publisher_refresh_engagement', post_id=post.id) }}" style="display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-secondary btn-small">Odswiez engagement</button>
|
||||||
|
</form>
|
||||||
|
{% if post.meta_post_id %}
|
||||||
|
<a href="https://www.facebook.com/{{ post.meta_post_id }}" target="_blank" class="fb-link">
|
||||||
|
Zobacz post na Facebook ↗
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
// AI generation
|
||||||
|
document.getElementById('btn-generate-ai')?.addEventListener('click', async function() {
|
||||||
|
const postType = document.getElementById('post_type').value;
|
||||||
|
const companyId = document.getElementById('company_id')?.value;
|
||||||
|
const eventId = document.getElementById('event_id')?.value;
|
||||||
|
|
||||||
|
if (!postType) {
|
||||||
|
alert('Wybierz typ posta przed generowaniem.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customContext = {};
|
||||||
|
const topicEl = document.getElementById('custom_topic');
|
||||||
|
if (topicEl) customContext.topic = topicEl.value;
|
||||||
|
const detailsEl = document.getElementById('custom_details');
|
||||||
|
if (detailsEl) customContext.details = detailsEl.value;
|
||||||
|
const factsEl = document.getElementById('custom_facts');
|
||||||
|
if (factsEl) customContext.facts = factsEl.value;
|
||||||
|
const sourceEl = document.getElementById('custom_source');
|
||||||
|
if (sourceEl) customContext.source = sourceEl.value;
|
||||||
|
|
||||||
|
this.disabled = true;
|
||||||
|
this.textContent = 'Generowanie...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch("{{ url_for('admin.social_publisher_generate') }}", {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrf_token]')?.value || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
post_type: postType,
|
||||||
|
company_id: companyId || null,
|
||||||
|
event_id: eventId || null,
|
||||||
|
custom_context: customContext
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('content').value = data.content;
|
||||||
|
if (data.hashtags) {
|
||||||
|
document.getElementById('hashtags').value = data.hashtags;
|
||||||
|
}
|
||||||
|
updateContentCounter();
|
||||||
|
} else {
|
||||||
|
alert('Błąd generowania: ' + (data.error || 'Nieznany błąd'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Błąd połączenia: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
this.disabled = false;
|
||||||
|
this.textContent = 'Generuj AI';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/hide context fields based on post type
|
||||||
|
document.getElementById('post_type')?.addEventListener('change', function() {
|
||||||
|
const type = this.value;
|
||||||
|
document.getElementById('company-field')?.classList.toggle('d-none', type !== 'member_spotlight');
|
||||||
|
document.getElementById('event-field')?.classList.toggle('d-none', !type.startsWith('event_'));
|
||||||
|
document.getElementById('custom-context-field')?.classList.toggle('d-none',
|
||||||
|
!['regional_news', 'chamber_news'].includes(type));
|
||||||
|
});
|
||||||
|
// Trigger on load
|
||||||
|
document.getElementById('post_type')?.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
// Character counter
|
||||||
|
function updateContentCounter() {
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
const counter = document.getElementById('content-counter');
|
||||||
|
if (content && counter) {
|
||||||
|
const len = content.value.length;
|
||||||
|
counter.textContent = len + ' znaków';
|
||||||
|
counter.classList.remove('warning', 'over');
|
||||||
|
if (len > 2000) counter.classList.add('over');
|
||||||
|
else if (len > 1500) counter.classList.add('warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('content')?.addEventListener('input', updateContentCounter);
|
||||||
|
updateContentCounter();
|
||||||
|
{% endblock %}
|
||||||
214
templates/admin/social_publisher_settings.html
Normal file
214
templates/admin/social_publisher_settings.html
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Ustawienia Social Publisher - Norda Biznes Partner{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.admin-header {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box ol li {
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Ustawienia Social Publisher</h1>
|
||||||
|
<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>
|
||||||
|
{% 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