| | import os |
| | import codecs |
| | import uuid |
| | import base64 |
| | from flask import Blueprint, request, jsonify, current_app, send_file |
| | from flask_jwt_extended import jwt_required, get_jwt_identity |
| | from backend.services.content_service import ContentService |
| | from backend.services.linkedin_service import LinkedInService |
| | from backend.utils.image_utils import ensure_bytes_format |
| | from backend.utils.redis_job_store import get_redis_job_store |
| |
|
| | posts_bp = Blueprint('posts', __name__) |
| |
|
| | def safe_log_message(message): |
| | """Safely log messages containing Unicode characters.""" |
| | try: |
| | |
| | if isinstance(message, str): |
| | |
| | encoded = message.encode('utf-8', errors='replace') |
| | safe_message = encoded.decode('utf-8', errors='replace') |
| | else: |
| | |
| | safe_message = str(message) |
| |
|
| | |
| | current_app.logger.debug(safe_message) |
| | except Exception as e: |
| | |
| | current_app.logger.error(f"Failed to log message: {str(e)}") |
| |
|
| | @posts_bp.route('/', methods=['OPTIONS']) |
| | def handle_options(): |
| | """Handle OPTIONS requests for preflight CORS checks.""" |
| | return '', 200 |
| |
|
| | @posts_bp.route('/', methods=['GET']) |
| | @jwt_required() |
| | def get_posts(): |
| | """ |
| | Get all posts for the current user. |
| | |
| | Query Parameters: |
| | published (bool): Filter by published status |
| | |
| | Returns: |
| | JSON: List of posts |
| | """ |
| | try: |
| | user_id = get_jwt_identity() |
| | published = request.args.get('published', type=bool) |
| |
|
| | |
| | if not hasattr(current_app, 'supabase') or current_app.supabase is None: |
| | |
| | response_data = jsonify({ |
| | 'success': False, |
| | 'message': 'Database connection not initialized' |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 500 |
| |
|
| | |
| | query = ( |
| | current_app.supabase |
| | .table("Post_content") |
| | .select("*, Social_network(id_utilisateur)") |
| | ) |
| |
|
| | |
| | if published is not None: |
| | query = query.eq("is_published", published) |
| |
|
| | response = query.execute() |
| |
|
| | |
| | user_posts = [ |
| | post for post in response.data |
| | if post.get('Social_network', {}).get('id_utilisateur') == user_id |
| | ] if response.data else [] |
| |
|
| | |
| | response_data = jsonify({ |
| | 'success': True, |
| | 'posts': user_posts |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 200 |
| |
|
| | except Exception as e: |
| | error_message = str(e) |
| | safe_log_message(f"Get posts error: {error_message}") |
| | |
| | response_data = jsonify({ |
| | 'success': False, |
| | 'message': 'An error occurred while fetching posts' |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 500 |
| |
|
| | def _generate_post_task(user_id, job_id, job_store, hugging_key): |
| | """ |
| | Background task to generate post content. |
| | |
| | Args: |
| | user_id (str): User ID for personalization |
| | job_id (str): Job ID to update status in job store |
| | job_store (dict): Job store dictionary (kept for backward compatibility, but now using Redis) |
| | hugging_key (str): Hugging Face API key |
| | """ |
| | try: |
| | |
| | redis_job_store = get_redis_job_store() |
| |
|
| | |
| | redis_job_store.update_job(job_id, status='processing') |
| |
|
| | |
| | |
| | content_service = ContentService(hugging_key=hugging_key) |
| | generated_result = content_service.generate_post_content(user_id) |
| |
|
| | |
| | |
| | if isinstance(generated_result, (tuple, list)) and len(generated_result) >= 2: |
| | generated_content = generated_result[0] if generated_result[0] is not None else "Generated content will appear here..." |
| | image_data = generated_result[1] if generated_result[1] is not None else None |
| | elif isinstance(generated_result, (tuple, list)) and len(generated_result) == 1: |
| | generated_content = generated_result[0] if generated_result[0] is not None else "Generated content will appear here..." |
| | image_data = None |
| | else: |
| | generated_content = generated_result |
| | image_data = None |
| |
|
| | |
| | redis_job_store.update_job(job_id, |
| | status='completed', |
| | result={ |
| | 'content': generated_content, |
| | 'image_data': image_data |
| | } |
| | ) |
| |
|
| | except Exception as e: |
| | error_message = str(e) |
| | safe_log_message(f"Generate post background task error: {error_message}") |
| | |
| | redis_job_store = get_redis_job_store() |
| | redis_job_store.update_job(job_id, |
| | status='failed', |
| | error=error_message |
| | ) |
| |
|
| | @posts_bp.route('/generate', methods=['POST']) |
| | @jwt_required() |
| | def generate_post(): |
| | """ |
| | Generate a new post using AI asynchronously. |
| | |
| | Request Body: |
| | user_id (str): User ID (optional, defaults to current user) |
| | |
| | Returns: |
| | JSON: Job ID for polling |
| | """ |
| | try: |
| | current_user_id = get_jwt_identity() |
| | data = request.get_json() |
| |
|
| | |
| | user_id = data.get('user_id', current_user_id) |
| |
|
| | |
| | if user_id != current_user_id: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Unauthorized to generate posts for other users' |
| | }), 403 |
| |
|
| | |
| | job_id = str(uuid.uuid4()) |
| |
|
| | |
| | from backend.utils.redis_job_store import get_redis_job_store |
| | redis_job_store = get_redis_job_store() |
| | redis_job_store.create_job(job_id, 'pending') |
| |
|
| | |
| | hugging_key = current_app.config['HUGGING_KEY'] |
| | current_app.logger.info(f"About to submit background task for user_id: {user_id}, job_id: {job_id}, hugging_key present: {bool(hugging_key)}") |
| |
|
| | |
| | future = current_app.executor.submit(_generate_post_task, user_id, job_id, current_app.job_store, hugging_key) |
| | current_app.logger.info(f"Background task submitted successfully, future object: {future}") |
| |
|
| | |
| | return jsonify({ |
| | 'success': True, |
| | 'job_id': job_id, |
| | 'message': 'Post generation started' |
| | }), 202 |
| |
|
| | except Exception as e: |
| | error_message = str(e) |
| | safe_log_message(f"Generate post error: {error_message}") |
| | return jsonify({ |
| | 'success': False, |
| | 'message': f'An error occurred while starting post generation: {error_message}' |
| | }), 500 |
| |
|
| | @posts_bp.route('/jobs/<job_id>', methods=['GET']) |
| | @jwt_required() |
| | def get_job_status(job_id): |
| | """ |
| | Get the status of a post generation job. |
| | |
| | Path Parameters: |
| | job_id (str): Job ID |
| | |
| | Returns: |
| | JSON: Job status and result if completed |
| | """ |
| | try: |
| | |
| | from backend.utils.redis_job_store import get_redis_job_store |
| | redis_job_store = get_redis_job_store() |
| | job = redis_job_store.get_job(job_id) |
| |
|
| | if not job: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Job not found' |
| | }), 404 |
| |
|
| | |
| | response_data = { |
| | 'success': True, |
| | 'job_id': job_id, |
| | 'status': job['status'] |
| | } |
| |
|
| | |
| | if job['status'] == 'completed': |
| | |
| | if isinstance(job['result'], dict) and 'content' in job['result']: |
| | response_data['content'] = job['result']['content'] |
| | |
| | image_data = job['result'].get('image_data') |
| | if isinstance(image_data, bytes): |
| | |
| | try: |
| | |
| | base64_image = base64.b64encode(image_data).decode('utf-8') |
| | |
| | response_data['image_url'] = f"data:image/png;base64,{base64_image}" |
| | response_data['has_image_data'] = True |
| | |
| | response_data['image_data'] = response_data['image_url'] |
| | except Exception as e: |
| | current_app.logger.error(f"Error encoding image to base64: {str(e)}") |
| | response_data['image_url'] = None |
| | response_data['has_image_data'] = True |
| | elif isinstance(image_data, dict): |
| | |
| | |
| | if image_data.get('url'): |
| | response_data['image_url'] = image_data['url'] |
| | response_data['has_image_data'] = True |
| | response_data['image_data'] = image_data['url'] |
| | elif image_data.get('path'): |
| | |
| | |
| | response_data['image_url'] = None |
| | response_data['has_image_data'] = True |
| | response_data['image_data'] = image_data['path'] |
| | else: |
| | response_data['image_url'] = None |
| | response_data['has_image_data'] = image_data is not None |
| | response_data['image_data'] = image_data |
| | elif isinstance(image_data, str): |
| | |
| | if os.path.exists(image_data): |
| | |
| | redis_job_store.update_job(job_id, result={ |
| | 'content': job['result']['content'], |
| | 'image_data': image_data, |
| | 'image_file_path': image_data |
| | }) |
| | |
| | base_url = request.host_url.rstrip('/') |
| | response_data['image_url'] = f"{base_url}/api/posts/image/{job_id}" |
| | response_data['has_image_data'] = True |
| | response_data['image_data'] = image_data |
| | else: |
| | |
| | response_data['image_url'] = image_data |
| | response_data['has_image_data'] = True |
| | response_data['image_data'] = image_data |
| | else: |
| | |
| | response_data['image_url'] = None |
| | response_data['has_image_data'] = image_data is not None |
| | response_data['image_data'] = image_data |
| | else: |
| | response_data['content'] = job['result'] |
| | response_data['image_url'] = None |
| | response_data['has_image_data'] = False |
| | elif job['status'] == 'failed': |
| | response_data['error'] = job['error'] |
| |
|
| | return jsonify(response_data), 200 |
| |
|
| | except Exception as e: |
| | error_message = str(e) |
| | safe_log_message(f"Get job status error: {error_message}") |
| | return jsonify({ |
| | 'success': False, |
| | 'message': f'An error occurred while fetching job status: {error_message}' |
| | }), 500 |
| |
|
| | @posts_bp.route('/image/<job_id>', methods=['GET']) |
| | def get_job_image(job_id): |
| | """ |
| | Serve image file for a completed job. |
| | |
| | Path Parameters: |
| | job_id (str): Job ID |
| | |
| | Returns: |
| | Image file |
| | """ |
| | try: |
| | |
| | from backend.utils.redis_job_store import get_redis_job_store |
| | redis_job_store = get_redis_job_store() |
| | job = redis_job_store.get_job(job_id) |
| |
|
| | if not job: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Job not found' |
| | }), 404 |
| |
|
| | |
| | image_file_path = job.get('image_file_path') if job else None |
| | if not image_file_path or not os.path.exists(image_file_path): |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Image not found' |
| | }), 404 |
| |
|
| | |
| | return send_file(image_file_path) |
| |
|
| | except Exception as e: |
| | error_message = str(e) |
| | safe_log_message(f"Get job image error: {error_message}") |
| | return jsonify({ |
| | 'success': False, |
| | 'message': f'An error occurred while fetching image: {error_message}' |
| | }), 500 |
| |
|
| | @posts_bp.route('/publish-direct', methods=['OPTIONS']) |
| | def handle_publish_direct_options(): |
| | """Handle OPTIONS requests for preflight CORS checks for publish direct route.""" |
| | return '', 200 |
| |
|
| | @posts_bp.route('/publish-direct', methods=['POST']) |
| | @jwt_required() |
| | def publish_post_direct(): |
| | """ |
| | Publish a post directly to social media and save to database. |
| | |
| | Request Body: |
| | social_account_id (str): Social account ID |
| | text_content (str): Post text content |
| | image_content_url (str, optional): Image URL |
| | scheduled_at (str, optional): Scheduled time in ISO format |
| | |
| | Returns: |
| | JSON: Publish post result |
| | """ |
| | try: |
| | user_id = get_jwt_identity() |
| | data = request.get_json() |
| |
|
| | |
| | social_account_id = data.get('social_account_id') |
| | text_content = data.get('text_content') |
| |
|
| | if not social_account_id or not text_content: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'social_account_id and text_content are required' |
| | }), 400 |
| |
|
| | |
| | account_response = ( |
| | current_app.supabase |
| | .table("Social_network") |
| | .select("id_utilisateur, token, sub") |
| | .eq("id", social_account_id) |
| | .execute() |
| | ) |
| |
|
| | if not account_response.data: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Social account not found' |
| | }), 404 |
| |
|
| | account = account_response.data[0] |
| | if account.get('id_utilisateur') != user_id: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Unauthorized to use this social account' |
| | }), 403 |
| |
|
| | |
| | access_token = account.get('token') |
| | user_sub = account.get('sub') |
| |
|
| | if not access_token or not user_sub: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Social account not properly configured' |
| | }), 400 |
| |
|
| | |
| | image_data = data.get('image_content_url') |
| |
|
| | |
| | image_url_for_linkedin = None |
| | if image_data: |
| | if isinstance(image_data, bytes): |
| | |
| | |
| | |
| | current_app.logger.warning("Image data is in bytes format, skipping LinkedIn upload for now") |
| | else: |
| | |
| | image_url_for_linkedin = image_data |
| |
|
| | |
| | linkedin_service = LinkedInService() |
| | publish_response = linkedin_service.publish_post( |
| | access_token, user_sub, text_content, image_url_for_linkedin |
| | ) |
| |
|
| | |
| | post_data = { |
| | 'id_social': social_account_id, |
| | 'Text_content': text_content, |
| | 'is_published': True |
| | } |
| |
|
| | |
| | if image_data: |
| | post_data['image_content_url'] = ensure_bytes_format(image_data) |
| |
|
| | if 'scheduled_at' in data: |
| | post_data['scheduled_at'] = data['scheduled_at'] |
| |
|
| | |
| | response = ( |
| | current_app.supabase |
| | .table("Post_content") |
| | .insert(post_data) |
| | .execute() |
| | ) |
| |
|
| | if response.data: |
| | |
| | response_data = jsonify({ |
| | 'success': True, |
| | 'message': 'Post published and saved successfully', |
| | 'post': response.data[0], |
| | 'linkedin_response': publish_response |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 201 |
| | else: |
| | |
| | response_data = jsonify({ |
| | 'success': False, |
| | 'message': 'Failed to save post to database' |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 500 |
| |
|
| | except Exception as e: |
| | error_message = str(e) |
| | safe_log_message(f"[Post] Publish post directly error: {error_message}") |
| | |
| | response_data = jsonify({ |
| | 'success': False, |
| | 'message': f'An error occurred while publishing post: {error_message}' |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 500 |
| |
|
| | @posts_bp.route('/<post_id>', methods=['OPTIONS']) |
| | def handle_post_options(post_id): |
| | """Handle OPTIONS requests for preflight CORS checks for specific post.""" |
| | return '', 200 |
| |
|
| | @posts_bp.route('/', methods=['POST']) |
| | @jwt_required() |
| | def create_post(): |
| | """ |
| | Create a new post. |
| | |
| | Request Body: |
| | social_account_id (str): Social account ID |
| | text_content (str): Post text content |
| | image_content_url (str, optional): Image URL |
| | scheduled_at (str, optional): Scheduled time in ISO format |
| | is_published (bool, optional): Whether the post is published (defaults to True) |
| | |
| | Returns: |
| | JSON: Created post data |
| | """ |
| | try: |
| | user_id = get_jwt_identity() |
| | data = request.get_json() |
| |
|
| | |
| | social_account_id = data.get('social_account_id') |
| | text_content = data.get('text_content') |
| |
|
| | if not social_account_id or not text_content: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'social_account_id and text_content are required' |
| | }), 400 |
| |
|
| | |
| | account_response = ( |
| | current_app.supabase |
| | .table("Social_network") |
| | .select("id_utilisateur") |
| | .eq("id", social_account_id) |
| | .execute() |
| | ) |
| |
|
| | if not account_response.data: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Social account not found' |
| | }), 404 |
| |
|
| | if account_response.data[0].get('id_utilisateur') != user_id: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Unauthorized to use this social account' |
| | }), 403 |
| |
|
| | |
| | post_data = { |
| | 'id_social': social_account_id, |
| | 'Text_content': text_content, |
| | 'is_published': data.get('is_published', True) |
| | } |
| |
|
| | |
| | image_data = data.get('image_content_url') |
| |
|
| | |
| | if image_data is not None: |
| | post_data['image_content_url'] = ensure_bytes_format(image_data) |
| |
|
| | if 'scheduled_at' in data: |
| | post_data['scheduled_at'] = data['scheduled_at'] |
| |
|
| | |
| | response = ( |
| | current_app.supabase |
| | .table("Post_content") |
| | .insert(post_data) |
| | .execute() |
| | ) |
| |
|
| | if response.data: |
| | |
| | response_data = jsonify({ |
| | 'success': True, |
| | 'post': response.data[0] |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 201 |
| | else: |
| | |
| | response_data = jsonify({ |
| | 'success': False, |
| | 'message': 'Failed to create post' |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 500 |
| |
|
| | except Exception as e: |
| | error_message = str(e) |
| | safe_log_message(f"[Post] Create post error: {error_message}") |
| | |
| | response_data = jsonify({ |
| | 'success': False, |
| | 'message': f'An error occurred while creating post: {error_message}' |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 500 |
| |
|
| | @posts_bp.route('/<post_id>', methods=['DELETE']) |
| | @jwt_required() |
| | def delete_post(post_id): |
| | """ |
| | Delete a post. |
| | |
| | Path Parameters: |
| | post_id (str): Post ID |
| | |
| | Returns: |
| | JSON: Delete post result |
| | """ |
| | try: |
| | user_id = get_jwt_identity() |
| |
|
| | |
| | response = ( |
| | current_app.supabase |
| | .table("Post_content") |
| | .select("Social_network(id_utilisateur)") |
| | .eq("id", post_id) |
| | .execute() |
| | ) |
| |
|
| | if not response.data: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Post not found' |
| | }), 404 |
| |
|
| | post = response.data[0] |
| | if post.get('Social_network', {}).get('id_utilisateur') != user_id: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Unauthorized to delete this post' |
| | }), 403 |
| |
|
| | |
| | delete_response = ( |
| | current_app.supabase |
| | .table("Post_content") |
| | .delete() |
| | .eq("id", post_id) |
| | .execute() |
| | ) |
| |
|
| | if delete_response.data: |
| | return jsonify({ |
| | 'success': True, |
| | 'message': 'Post deleted successfully' |
| | }), 200 |
| | else: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Failed to delete post' |
| | }), 500 |
| |
|
| | except Exception as e: |
| | error_message = str(e) |
| | safe_log_message(f"Delete post error: {error_message}") |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'An error occurred while deleting post' |
| | }), 500 |
| |
|
| | @posts_bp.route('/keyword-analysis', methods=['POST']) |
| | @jwt_required() |
| | def keyword_analysis(): |
| | """ |
| | Analyze keyword frequency in RSS feeds and posts. |
| | |
| | Request Body: |
| | keyword (str): The keyword to analyze |
| | date_range (str, optional): Date range for analysis (daily, weekly, monthly) |
| | |
| | Returns: |
| | JSON: Keyword frequency analysis data |
| | """ |
| | try: |
| | user_id = get_jwt_identity() |
| | data = request.get_json() |
| |
|
| | |
| | keyword = data.get('keyword') |
| | if not keyword: |
| | return jsonify({ |
| | 'success': False, |
| | 'message': 'Keyword is required' |
| | }), 400 |
| |
|
| | |
| | date_range = data.get('date_range', 'monthly') |
| |
|
| | |
| | content_service = current_app.content_service |
| | analysis_data = content_service.analyze_keyword_frequency(keyword, user_id, date_range) |
| |
|
| | |
| | response_data = jsonify({ |
| | 'success': True, |
| | 'keyword': keyword, |
| | 'date_range': date_range, |
| | 'analysis': analysis_data |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 200 |
| |
|
| | except Exception as e: |
| | error_message = str(e) |
| | safe_log_message(f"Keyword analysis error: {error_message}") |
| | |
| | response_data = jsonify({ |
| | 'success': False, |
| | 'message': f'An error occurred during keyword analysis: {error_message}' |
| | }) |
| | response_data.headers.add('Access-Control-Allow-Origin', 'http://localhost:3000') |
| | response_data.headers.add('Access-Control-Allow-Credentials', 'true') |
| | return response_data, 500 |