From 4a033f0d8138549d98fc57151794e19278078342 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Wed, 18 Feb 2026 12:08:29 +0100 Subject: [PATCH] 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 --- blueprints/admin/__init__.py | 1 + blueprints/admin/routes_social_publisher.py | 325 +++++++++++ database.py | 83 +++ database/migrations/070_social_publisher.sql | 75 +++ facebook_graph_service.py | 152 +++++ services/social_publisher_service.py | 520 ++++++++++++++++++ static/uploads/social/.gitkeep | 0 templates/admin/social_publisher.html | 465 ++++++++++++++++ templates/admin/social_publisher_form.html | 430 +++++++++++++++ .../admin/social_publisher_settings.html | 214 +++++++ 10 files changed, 2265 insertions(+) create mode 100644 blueprints/admin/routes_social_publisher.py create mode 100644 database/migrations/070_social_publisher.sql create mode 100644 services/social_publisher_service.py create mode 100644 static/uploads/social/.gitkeep create mode 100644 templates/admin/social_publisher.html create mode 100644 templates/admin/social_publisher_form.html create mode 100644 templates/admin/social_publisher_settings.html diff --git a/blueprints/admin/__init__.py b/blueprints/admin/__init__.py index 1bb647d..3bdc3a6 100644 --- a/blueprints/admin/__init__.py +++ b/blueprints/admin/__init__.py @@ -28,3 +28,4 @@ from . import routes_people # noqa: E402, F401 from . import routes_membership # noqa: E402, F401 from . import routes_benefits # noqa: E402, F401 from . import routes_competitors # noqa: E402, F401 +from . import routes_social_publisher # noqa: E402, F401 diff --git a/blueprints/admin/routes_social_publisher.py b/blueprints/admin/routes_social_publisher.py new file mode 100644 index 0000000..7859364 --- /dev/null +++ b/blueprints/admin/routes_social_publisher.py @@ -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//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//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//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//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//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) diff --git a/database.py b/database.py index b820116..15b8126 100644 --- a/database.py +++ b/database.py @@ -5322,6 +5322,89 @@ class OAuthToken(Base): return f'' +# ============================================================ +# 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'' + + +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'' + + # ============================================================ # DATABASE INITIALIZATION # ============================================================ diff --git a/database/migrations/070_social_publisher.sql b/database/migrations/070_social_publisher.sql new file mode 100644 index 0000000..ff0dbf5 --- /dev/null +++ b/database/migrations/070_social_publisher.sql @@ -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; diff --git a/facebook_graph_service.py b/facebook_graph_service.py index f2737c8..53eff0b 100644 --- a/facebook_graph_service.py +++ b/facebook_graph_service.py @@ -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/ """ +import json import logging from datetime import datetime, timedelta from typing import Dict, List, Optional @@ -121,3 +122,154 @@ class FacebookGraphService: result[f'ig_{name}_total'] = total 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')) diff --git a/services/social_publisher_service.py b/services/social_publisher_service.py new file mode 100644 index 0000000..567652a --- /dev/null +++ b/services/social_publisher_service.py @@ -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() diff --git a/static/uploads/social/.gitkeep b/static/uploads/social/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/admin/social_publisher.html b/templates/admin/social_publisher.html new file mode 100644 index 0000000..61a8181 --- /dev/null +++ b/templates/admin/social_publisher.html @@ -0,0 +1,465 @@ +{% extends "base.html" %} + +{% block title %}Social Media Publisher - Norda Biznes Partner{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Social Media Publisher

+ +
+ + +
+
+
{{ stats.total or 0 }}
+
Wszystkie
+
+
+
{{ stats.draft or 0 }}
+
Szkice
+
+
+
{{ stats.approved or 0 }}
+
Zatwierdzone
+
+
+
{{ stats.scheduled or 0 }}
+
Zaplanowane
+
+
+
{{ stats.published or 0 }}
+
Opublikowane
+
+
+
{{ stats.failed or 0 }}
+
Bledy
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if posts %} + + + + + + + + + + + + + + {% for post in posts %} + + + + + + + + + + {% endfor %} + +
TypTrescFirmaStatusDataEngagementAkcje
+ {{ post_types.get(post.post_type, post.post_type) }} + + + {{ post.content[:80] }}{% if post.content|length > 80 %}...{% endif %} + + {{ post.company.name if post.company else '-' }} + + {% 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.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 %} + + + Edytuj + + {% if post.status == 'draft' %} +
+ + +
+ {% endif %} + {% if post.status in ['draft', 'approved'] %} + + {% endif %} + {% if post.status != 'published' %} + + {% endif %} +
+ {% else %} +
+

Brak postow{% if status_filter != 'all' or type_filter != 'all' %} pasujacych do filtrow{% endif %}.

+

+ Utworz pierwszy post +

+
+ {% endif %} +
+
+ + + + +
+ + +{% 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 = '' + (icons[type]||'ℹ') + '' + message + ''; + 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 %} diff --git a/templates/admin/social_publisher_form.html b/templates/admin/social_publisher_form.html new file mode 100644 index 0000000..49c800e --- /dev/null +++ b/templates/admin/social_publisher_form.html @@ -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 %} + +{% endblock %} + +{% block content %} +
+
+

{% if post %}Edycja Posta #{{ post.id }}{% else %}Nowy Post{% endif %}

+ Powrot do listy +
+ + {% if 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 %} + {% if post.creator %} + | Autor: {{ post.creator.name }} + {% endif %} + {% if post.ai_model %} + | Model AI: {{ post.ai_model }} + {% endif %} + {% if post.published_at %} + | Opublikowano: {{ post.published_at.strftime('%Y-%m-%d %H:%M') }} + {% endif %} +
+ {% endif %} + +
+
+ + + +
+ + +
+ + +
+ + +

Firma, którą chcesz zaprezentować w poście

+
+ + +
+ + +

Wydarzenie powiązane z postem

+
+ + +
+

Kontekst dla AI

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + +
0 znaków
+
+ + +
+ + +

Hashtagi oddzielone spacjami

+
+ + +
+ Anuluj + + {% if post %} + + {% if post.status == 'draft' %} + + {% endif %} + {% if post.status in ['draft', 'approved'] %} + + {% endif %} + {% if post.status != 'published' %} + + {% endif %} + {% else %} + + + {% endif %} +
+
+ + {% if post and post.status == 'published' %} + + + {% endif %} +
+
+{% 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 %} diff --git a/templates/admin/social_publisher_settings.html b/templates/admin/social_publisher_settings.html new file mode 100644 index 0000000..9393c00 --- /dev/null +++ b/templates/admin/social_publisher_settings.html @@ -0,0 +1,214 @@ +{% extends "base.html" %} + +{% block title %}Ustawienia Social Publisher - Norda Biznes Partner{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Ustawienia Social Publisher

+ Powrot do listy +
+ + {% if config %} +
+ Obecna konfiguracja: + Strona: {{ config.page_name or 'Nie ustawiona' }} + | Status: {% if config.is_active %}Aktywna{% else %}Nieaktywna{% endif %} + | Debug: {% if config.debug_mode %}Wlaczony{% else %}Wylaczony{% endif %} + {% if config.updated_at %} + | Ostatnia aktualizacja: {{ config.updated_at.strftime('%Y-%m-%d %H:%M') }} + {% endif %} +
+ {% endif %} + +
+

Konfiguracja Facebook

+
+ + +
+ + +

Numeryczny identyfikator strony na Facebooku

+
+ +
+ + +
+ +
+ + +

Token dostepu do strony FB (nie udostepniaj nikomu)

+
+ +
+ +

W trybie debug posty sa widoczne tylko dla adminow strony FB (nie sa publiczne)

+
+ +
+ Anuluj + +
+
+
+ +
+

Jak uzyskac Page Access Token?

+
    +
  1. Przejdz do Facebook Developers i zaloguj sie
  2. +
  3. Utworz aplikacje (typ: Business) lub wybierz istniejaca
  4. +
  5. Dodaj produkt "Facebook Login for Business"
  6. +
  7. Przejdz do Graph API Explorer
  8. +
  9. Wybierz swoja aplikacje i wygeneruj User Token z uprawnieniami: + pages_manage_posts, pages_read_engagement, pages_show_list
  10. +
  11. Zamien User Token na Page Token: GET /me/accounts - skopiuj access_token dla wlasciwej strony
  12. +
  13. Opcjonalnie: przedluz token na dlugotrwaly (60 dni) przez endpoint /oauth/access_token
  14. +
+
+
+{% endblock %}