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