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:
Maciej Pienczyn 2026-02-18 12:08:29 +01:00
parent a0c85b6182
commit 4a033f0d81
10 changed files with 2265 additions and 0 deletions

View File

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

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

View File

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

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

View File

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

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

View File

View 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">&#128077; {{ post.engagement_likes or 0 }}</span>
<span title="Komentarze">&#128172; {{ post.engagement_comments or 0 }}</span>
<span title="Udostepnienia">&#128257; {{ 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);">&#10067;</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 || '&#10067;';
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: '&#10003;', error: '&#10007;', warning: '&#9888;', info: '&#8505;' };
const toast = document.createElement('div');
toast.className = 'toast ' + type;
toast.innerHTML = '<span style="font-size:1.2em">' + (icons[type]||'&#8505;') + '</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: '&#128227;',
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: '&#128465;',
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 %}

View 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 &#8599;
</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 %}

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