| |
| import os |
| import io |
| import uuid |
| import re |
| import json |
| import traceback |
| from datetime import datetime, timedelta |
|
|
| from flask import Flask, request, jsonify |
| from flask_cors import CORS |
| import firebase_admin |
| from firebase_admin import credentials, db, storage, auth |
| from pathlib import Path |
| |
| from sozo_gen import ( |
| generate_report_draft, |
| generate_single_chart, |
| generate_video_from_project, |
| load_dataframe_safely, |
| deepgram_tts |
| ) |
| import logging |
| |
| |
| |
|
|
| app = Flask(__name__) |
| CORS(app) |
|
|
| try: |
| credentials_json_string = os.environ.get("FIREBASE") |
| if not credentials_json_string: raise ValueError("FIREBASE env var not set.") |
| credentials_json = json.loads(credentials_json_string) |
| firebase_db_url = os.environ.get("Firebase_DB") |
| firebase_storage_bucket = os.environ.get("Firebase_Storage") |
| if not firebase_db_url or not firebase_storage_bucket: raise ValueError("Firebase DB/Storage env vars must be set.") |
| cred = credentials.Certificate(credentials_json) |
| firebase_admin.initialize_app(cred, {'databaseURL': firebase_db_url, 'storageBucket': firebase_storage_bucket}) |
| print("Firebase Admin SDK initialized successfully.") |
| except Exception as e: |
| print(f"FATAL: Error initializing Firebase: {e}") |
|
|
| bucket = storage.bucket() |
|
|
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
|
|
| def verify_token(token): |
| try: return auth.verify_id_token(token)['uid'] |
| except Exception: return None |
|
|
| def verify_admin(auth_header): |
| if not auth_header or not auth_header.startswith('Bearer '): raise ValueError('Invalid token') |
| token = auth_header.split(' ')[1] |
| uid = verify_token(token) |
| if not uid: raise PermissionError('Invalid user') |
| user_data = db.reference(f'users/{uid}').get() |
| if not user_data or not user_data.get('is_admin', False): raise PermissionError('Admin access required') |
| return uid |
|
|
| def is_valid_email(email): |
| """Simple regex for basic email validation.""" |
| regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' |
| return re.match(regex, email) is not None |
|
|
| |
| |
| |
|
|
| @app.route('/api/auth/signup', methods=['POST']) |
| def signup(): |
| try: |
| data = request.get_json() |
| email = data.get('email') |
| password = data.get('password') |
| if not email or not password: return jsonify({'error': 'Email and password are required'}), 400 |
| user = auth.create_user(email=email, password=password) |
| user_ref = db.reference(f'users/{user.uid}') |
| user_data = {'email': email, 'credits': 15, 'is_admin': False, 'created_at': datetime.utcnow().isoformat()} |
| user_ref.set(user_data) |
| return jsonify({'success': True, 'user': {'uid': user.uid, **user_data}}), 201 |
| except Exception as e: return jsonify({'error': str(e)}), 400 |
|
|
| @app.route('/api/user/profile', methods=['GET']) |
| def get_user_profile(): |
| try: |
| auth_header = request.headers.get('Authorization', '') |
| if not auth_header.startswith('Bearer '): return jsonify({'error': 'Missing or invalid token'}), 401 |
| token = auth_header.split(' ')[1] |
| uid = verify_token(token) |
| if not uid: return jsonify({'error': 'Invalid or expired token'}), 401 |
| user_data = db.reference(f'users/{uid}').get() |
| if not user_data: return jsonify({'error': 'User not found'}), 404 |
| return jsonify({'uid': uid, **user_data}) |
| except Exception as e: return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/auth/google-signin', methods=['POST']) |
| def google_signin(): |
| try: |
| auth_header = request.headers.get('Authorization', '') |
| if not auth_header.startswith('Bearer '): return jsonify({'error': 'Missing or invalid token'}), 401 |
| token = auth_header.split(' ')[1] |
| decoded_token = auth.verify_id_token(token) |
| uid = decoded_token['uid'] |
| email = decoded_token.get('email') |
| user_ref = db.reference(f'users/{uid}') |
| user_data = user_ref.get() |
| if not user_data: |
| user_data = {'email': email, 'credits': 15, 'is_admin': False, 'created_at': datetime.utcnow().isoformat()} |
| user_ref.set(user_data) |
| return jsonify({'success': True, 'user': {'uid': uid, **user_data}}), 200 |
| except Exception as e: return jsonify({'error': str(e)}), 400 |
|
|
|
|
| |
| |
| |
|
|
| @app.route('/api/sozo/projects', methods=['POST']) |
| def create_sozo_project(): |
| logger.info("Endpoint /api/sozo/projects POST: Received request to create new project.") |
| try: |
| auth_header = request.headers.get('Authorization', '') |
| if not auth_header.startswith('Bearer '): |
| logger.warning("Create project failed: Missing or invalid auth header.") |
| return jsonify({'error': 'Missing or invalid token'}), 401 |
| |
| token = auth_header.split(' ')[1] |
| uid = verify_token(token) |
| if not uid: |
| logger.warning("Create project failed: Invalid token.") |
| return jsonify({'error': 'Unauthorized'}), 401 |
| logger.info(f"Token verified for user UID: {uid}") |
|
|
| if 'file' not in request.files: |
| logger.warning(f"User {uid}: Create project failed: No file part in request.") |
| return jsonify({'error': 'No file part'}), 400 |
| |
| file = request.files['file'] |
| context = request.form.get('context', '') |
| project_id = uuid.uuid4().hex |
| logger.info(f"User {uid}: Generated new project ID: {project_id}") |
| |
| file_bytes = file.read() |
| ext = Path(file.filename).suffix |
| |
| blob_name = f"sozo_projects/{uid}/{project_id}/data{ext}" |
| logger.info(f"User {uid}: Uploading raw data to storage at {blob_name}") |
| blob = bucket.blob(blob_name) |
| blob.upload_from_string(file_bytes, content_type=file.content_type) |
| logger.info(f"User {uid}: Successfully uploaded raw data for project {project_id}") |
| |
| project_ref = db.reference(f'sozo_projects/{project_id}') |
| project_data = { |
| 'uid': uid, |
| 'id': project_id, |
| 'status': 'uploaded', |
| 'createdAt': datetime.utcnow().isoformat(), |
| 'updatedAt': datetime.utcnow().isoformat(), |
| 'userContext': context, |
| 'originalDataUrl': blob.public_url, |
| 'originalFilename': file.filename |
| } |
| logger.info(f"User {uid}: Saving project metadata to database for project {project_id}") |
| project_ref.set(project_data) |
| |
| df = load_dataframe_safely(io.BytesIO(file_bytes), file.filename) |
| preview_json = df.head().to_json(orient='records') |
| |
| logger.info(f"User {uid}: Project {project_id} created successfully.") |
| return jsonify({ |
| 'success': True, |
| 'project': project_data, |
| 'preview': json.loads(preview_json) |
| }), 201 |
| |
| except Exception as e: |
| logger.error(f"CRITICAL ERROR during project creation: {traceback.format_exc()}") |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/sozo/projects', methods=['GET']) |
| def get_sozo_projects(): |
| logger.info("Endpoint /api/sozo/projects GET: Received request to list projects.") |
| try: |
| auth_header = request.headers.get('Authorization', '') |
| if not auth_header.startswith('Bearer '): |
| logger.warning("List projects failed: Missing or invalid auth header.") |
| return jsonify({'error': 'Missing or invalid token'}), 401 |
| |
| token = auth_header.split(' ')[1] |
| uid = verify_token(token) |
| if not uid: |
| logger.warning("List projects failed: Invalid token.") |
| return jsonify({'error': 'Unauthorized'}), 401 |
| logger.info(f"Token verified for user UID: {uid}. Fetching projects.") |
|
|
| projects_ref = db.reference('sozo_projects') |
| user_projects = projects_ref.order_by_child('uid').equal_to(uid).get() |
| |
| if not user_projects: |
| logger.info(f"User {uid}: No projects found.") |
| return jsonify([]), 200 |
|
|
| |
| projects_list = [project for project in user_projects.values()] |
| logger.info(f"User {uid}: Found and returning {len(projects_list)} projects.") |
| return jsonify(projects_list), 200 |
|
|
| except Exception as e: |
| logger.error(f"CRITICAL ERROR during project list: {traceback.format_exc()}") |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/sozo/projects/<string:project_id>', methods=['GET']) |
| def get_sozo_project(project_id): |
| logger.info(f"Endpoint /api/sozo/projects/{project_id} GET: Received request for single project.") |
| try: |
| auth_header = request.headers.get('Authorization', '') |
| if not auth_header.startswith('Bearer '): |
| logger.warning(f"Get project {project_id} failed: Missing or invalid auth header.") |
| return jsonify({'error': 'Missing or invalid token'}), 401 |
|
|
| token = auth_header.split(' ')[1] |
| uid = verify_token(token) |
| if not uid: |
| logger.warning(f"Get project {project_id} failed: Invalid token.") |
| return jsonify({'error': 'Unauthorized'}), 401 |
| logger.info(f"Token verified for user UID: {uid}. Fetching project {project_id}.") |
|
|
| project_ref = db.reference(f'sozo_projects/{project_id}') |
| project_data = project_ref.get() |
|
|
| if not project_data: |
| logger.warning(f"User {uid}: Attempted to access non-existent project {project_id}.") |
| return jsonify({'error': 'Project not found'}), 404 |
|
|
| if project_data.get('uid') != uid: |
| logger.error(f"User {uid}: UNAUTHORIZED attempt to access project {project_id} owned by {project_data.get('uid')}.") |
| return jsonify({'error': 'Unauthorized to access this project'}), 403 |
|
|
| logger.info(f"User {uid}: Successfully fetched project {project_id}.") |
| return jsonify(project_data), 200 |
|
|
| except Exception as e: |
| logger.error(f"CRITICAL ERROR during get project {project_id}: {traceback.format_exc()}") |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/sozo/projects/<string:project_id>', methods=['PUT']) |
| def update_sozo_project(project_id): |
| logger.info(f"Endpoint /api/sozo/projects/{project_id} PUT: Received request to update project.") |
| try: |
| auth_header = request.headers.get('Authorization', '') |
| if not auth_header.startswith('Bearer '): |
| logger.warning(f"Update project {project_id} failed: Missing or invalid auth header.") |
| return jsonify({'error': 'Missing or invalid token'}), 401 |
|
|
| token = auth_header.split(' ')[1] |
| uid = verify_token(token) |
| if not uid: |
| logger.warning(f"Update project {project_id} failed: Invalid token.") |
| return jsonify({'error': 'Unauthorized'}), 401 |
| logger.info(f"Token verified for user UID: {uid}. Updating project {project_id}.") |
|
|
| project_ref = db.reference(f'sozo_projects/{project_id}') |
| project_data = project_ref.get() |
|
|
| if not project_data: |
| logger.warning(f"User {uid}: Attempted to update non-existent project {project_id}.") |
| return jsonify({'error': 'Project not found'}), 404 |
|
|
| if project_data.get('uid') != uid: |
| logger.error(f"User {uid}: UNAUTHORIZED attempt to update project {project_id} owned by {project_data.get('uid')}.") |
| return jsonify({'error': 'Unauthorized to update this project'}), 403 |
| |
| logger.info(f"User {uid}: Ownership of project {project_id} verified.") |
| |
| update_data = request.get_json() |
| if not update_data: |
| return jsonify({'error': 'No update data provided'}), 400 |
|
|
| |
| allowed_updates = ['userContext', 'originalFilename'] |
| final_updates = {key: update_data[key] for key in update_data if key in allowed_updates} |
| |
| if not final_updates: |
| logger.warning(f"User {uid}: Update for project {project_id} contained no valid fields.") |
| return jsonify({'error': 'No valid fields to update'}), 400 |
|
|
| final_updates['updatedAt'] = datetime.utcnow().isoformat() |
| |
| logger.info(f"User {uid}: Applying updates to project {project_id}: {final_updates}") |
| project_ref.update(final_updates) |
| |
| updated_project = project_ref.get() |
| logger.info(f"User {uid}: Successfully updated project {project_id}.") |
| return jsonify(updated_project), 200 |
|
|
| except Exception as e: |
| logger.error(f"CRITICAL ERROR during update project {project_id}: {traceback.format_exc()}") |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/sozo/projects/<string:project_id>', methods=['DELETE']) |
| def delete_sozo_project(project_id): |
| logger.info(f"Endpoint /api/sozo/projects/{project_id} DELETE: Received request to delete project.") |
| try: |
| auth_header = request.headers.get('Authorization', '') |
| if not auth_header.startswith('Bearer '): |
| logger.warning(f"Delete project {project_id} failed: Missing or invalid auth header.") |
| return jsonify({'error': 'Missing or invalid token'}), 401 |
|
|
| token = auth_header.split(' ')[1] |
| uid = verify_token(token) |
| if not uid: |
| logger.warning(f"Delete project {project_id} failed: Invalid token.") |
| return jsonify({'error': 'Unauthorized'}), 401 |
| logger.info(f"Token verified for user UID: {uid}. Deleting project {project_id}.") |
|
|
| project_ref = db.reference(f'sozo_projects/{project_id}') |
| project_data = project_ref.get() |
|
|
| if not project_data: |
| logger.warning(f"User {uid}: Attempted to delete non-existent project {project_id}.") |
| return jsonify({'error': 'Project not found'}), 404 |
|
|
| if project_data.get('uid') != uid: |
| logger.error(f"User {uid}: UNAUTHORIZED attempt to delete project {project_id} owned by {project_data.get('uid')}.") |
| return jsonify({'error': 'Unauthorized to delete this project'}), 403 |
| |
| logger.info(f"User {uid}: Ownership of project {project_id} verified. Proceeding with deletion.") |
|
|
| |
| project_folder_prefix = f"sozo_projects/{uid}/{project_id}/" |
| logger.info(f"User {uid}: Deleting all files from storage folder: {project_folder_prefix}") |
| blobs_to_delete = bucket.list_blobs(prefix=project_folder_prefix) |
| deleted_files_count = 0 |
| for blob in blobs_to_delete: |
| logger.info(f"User {uid}: Deleting file {blob.name} from storage.") |
| blob.delete() |
| deleted_files_count += 1 |
| logger.info(f"User {uid}: Deleted {deleted_files_count} files from storage for project {project_id}.") |
|
|
| |
| logger.info(f"User {uid}: Deleting project {project_id} from database.") |
| project_ref.delete() |
| |
| logger.info(f"User {uid}: Successfully deleted project {project_id}.") |
| return jsonify({'success': True, 'message': f'Project {project_id} and all associated files deleted.'}), 200 |
|
|
| except Exception as e: |
| logger.error(f"CRITICAL ERROR during delete project {project_id}: {traceback.format_exc()}") |
| return jsonify({'error': str(e)}), 500 |
|
|
| |
|
|
| @app.route('/api/sozo/projects/<string:project_id>/generate-report', methods=['POST']) |
| def generate_sozo_report(project_id): |
| logger.info(f"POST /api/sozo/projects/{project_id}/generate-report - Received request") |
| |
| try: |
| token = request.headers.get('Authorization', '').split(' ')[1] |
| uid = verify_token(token) |
| if not uid: |
| return jsonify({'error': 'Unauthorized'}), 401 |
| |
| project_ref = db.reference(f'sozo_projects/{project_id}') |
| project_data = project_ref.get() |
| |
| if not project_data or project_data.get('uid') != uid: |
| return jsonify({'error': 'Project not found or unauthorized'}), 404 |
| |
| |
| current_status = project_data.get('status') |
| if current_status in ['generating_report', 'generating_video']: |
| logger.warning(f"User {uid} attempted to generate a report for project {project_id} which is already in progress (status: {current_status}).") |
| return jsonify({'error': 'Report generation is already in progress for this project.'}), 409 |
|
|
| |
| project_ref.update({'status': 'generating_report'}) |
| logger.info(f"Project {project_id} status locked to 'generating_report'.") |
| |
| blob_path = f"sozo_projects/{uid}/{project_id}/data{Path(project_data['originalFilename']).suffix}" |
| blob = bucket.blob(blob_path) |
| file_bytes = blob.download_as_bytes() |
| |
| draft_data = generate_report_draft( |
| io.BytesIO(file_bytes), |
| project_data['originalFilename'], |
| project_data['userContext'], |
| uid, |
| project_id, |
| bucket |
| ) |
| |
| update_data = { |
| 'status': 'draft', |
| 'rawMarkdown': draft_data.get('raw_md'), |
| 'chartUrls': draft_data.get('chartUrls'), |
| 'dataContext': draft_data.get('data_context') |
| } |
| |
| project_ref.update(update_data) |
| |
| logger.info(f"Project {project_id} successfully updated with draft data.") |
| |
| return jsonify({ |
| 'success': True, |
| 'project': {**project_data, **update_data} |
| }), 200 |
| |
| except Exception as e: |
| logger.error(f"CRITICAL error generating report for project {project_id}: {traceback.format_exc()}") |
| |
| db.reference(f'sozo_projects/{project_id}').update({ |
| 'status': 'failed', |
| 'error': str(e) |
| }) |
| return jsonify({'error': str(e)}), 500 |
|
|
|
|
| @app.route('/api/sozo/projects/<string:project_id>/generate-video', methods=['POST']) |
| def generate_sozo_video(project_id): |
| try: |
| token = request.headers.get('Authorization', '').split(' ')[1] |
| uid = verify_token(token) |
| if not uid: return jsonify({'error': 'Unauthorized'}), 401 |
| |
| project_ref = db.reference(f'sozo_projects/{project_id}') |
| project_data = project_ref.get() |
| if not project_data or project_data.get('uid') != uid: return jsonify({'error': 'Project not found or unauthorized'}), 404 |
| |
| data = request.get_json() |
| voice_model = data.get('voice_model', 'aura-2-andromeda-en') |
| |
| project_ref.update({'status': 'generating_video'}) |
| |
| blob_path = f"sozo_projects/{uid}/{project_id}/data{Path(project_data['originalFilename']).suffix}" |
| blob = bucket.blob(blob_path) |
| file_bytes = blob.download_as_bytes() |
| df = load_dataframe_safely(io.BytesIO(file_bytes), project_data['originalFilename']) |
| |
| |
| video_url = generate_video_from_project( |
| df, |
| project_data.get('rawMarkdown', ''), |
| project_data.get('dataContext', {}), |
| uid, |
| project_id, |
| voice_model, |
| bucket |
| ) |
| |
| if not video_url: raise Exception("Video generation failed in core function.") |
| |
| project_ref.update({'status': 'video_complete', 'videoUrl': video_url}) |
| return jsonify({'success': True, 'video_url': video_url}), 200 |
| |
| except Exception as e: |
| db.reference(f'sozo_projects/{project_id}').update({'status': 'failed', 'error': str(e)}) |
| traceback.print_exc() |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/sozo/projects/<string:project_id>/charts', methods=['POST']) |
| def regenerate_sozo_chart(project_id): |
| logger.info(f"Endpoint /charts POST for project {project_id}") |
| try: |
| token = request.headers.get('Authorization', '').split(' ')[1] |
| uid = verify_token(token) |
| if not uid: return jsonify({'error': 'Unauthorized'}), 401 |
| logger.info(f"Token verified for user {uid} for chart regeneration.") |
|
|
| project_ref = db.reference(f'sozo_projects/{project_id}') |
| project_data = project_ref.get() |
| if not project_data or project_data.get('uid') != uid: |
| logger.warning(f"User {uid} failed to regenerate chart: Project {project_id} not found or not owned.") |
| return jsonify({'error': 'Project not found or unauthorized'}), 404 |
|
|
| data = request.get_json() |
| description = data.get('description') |
| chart_id_to_replace = data.get('chart_id') |
| if not description or not chart_id_to_replace: |
| return jsonify({'error': 'Chart description and chart_id are required'}), 400 |
| |
| logger.info(f"User {uid}: Regenerating chart '{chart_id_to_replace}' for project {project_id} with new description: '{description}'") |
| blob_path = f"sozo_projects/{uid}/{project_id}/data{Path(project_data['originalFilename']).suffix}" |
| blob = bucket.blob(blob_path) |
| file_bytes = blob.download_as_bytes() |
| df = load_dataframe_safely(io.BytesIO(file_bytes), project_data['originalFilename']) |
| |
| new_chart_spec = generate_single_chart(df, description) |
| |
| logger.info(f"User {uid}: Updating chart spec in database for project {project_id}.") |
| report_content_ref = project_ref.child('report_content') |
| report_content = report_content_ref.get() |
| chart_specs = report_content.get('chart_specs', []) |
| |
| chart_found = False |
| for i, spec in enumerate(chart_specs): |
| if spec.get('id') == chart_id_to_replace: |
| chart_specs[i] = new_chart_spec |
| chart_found = True |
| break |
| |
| if not chart_found: |
| logger.warning(f"User {uid}: Chart with id {chart_id_to_replace} not found in project {project_id}.") |
| return jsonify({'error': f'Chart with id {chart_id_to_replace} not found'}), 404 |
| |
| report_content_ref.child('chart_specs').set(chart_specs) |
| project_ref.update({'updatedAt': datetime.utcnow().isoformat()}) |
| |
| logger.info(f"User {uid}: Successfully regenerated chart {chart_id_to_replace} for project {project_id}.") |
| return jsonify({'success': True, 'new_chart_spec': new_chart_spec}), 200 |
| except Exception as e: |
| logger.error(f"CRITICAL ERROR regenerating chart for {project_id}: {traceback.format_exc()}") |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/sozo/projects/<string:project_id>/update-narration-audio', methods=['POST']) |
| def update_narration_audio(project_id): |
| logger.info(f"Endpoint /update-narration-audio POST for project {project_id}") |
| try: |
| token = request.headers.get('Authorization', '').split(' ')[1] |
| uid = verify_token(token) |
| if not uid: return jsonify({'error': 'Unauthorized'}), 401 |
| logger.info(f"Token verified for user {uid} for narration update.") |
|
|
| data = request.get_json() |
| scene_id = data.get('scene_id') |
| narration_text = data.get('narration_text') |
| voice_model = data.get('voice_model', 'aura-2-andromeda-en') |
| if not scene_id or narration_text is None: |
| return jsonify({'error': 'scene_id and narration_text are required'}), 400 |
|
|
| logger.info(f"User {uid}: Updating narration for scene {scene_id} in project {project_id}.") |
| audio_bytes = deepgram_tts(narration_text, voice_model) |
| if not audio_bytes: |
| logger.error(f"User {uid}: Deepgram TTS failed for project {project_id}, scene {scene_id}.") |
| return jsonify({'error': 'Failed to generate audio'}), 500 |
|
|
| audio_blob_name = f"sozo_projects/{uid}/{project_id}/audio/{scene_id}.mp3" |
| logger.info(f"User {uid}: Uploading new audio to {audio_blob_name}.") |
| audio_blob = bucket.blob(audio_blob_name) |
| audio_blob.upload_from_string(audio_bytes, content_type="audio/mpeg") |
| new_audio_url = audio_blob.public_url |
|
|
| logger.info(f"User {uid}: Updating database with new narration and audio URL for project {project_id}.") |
| scene_ref = db.reference(f'sozo_projects/{project_id}/video_script/scenes') |
| scenes = scene_ref.get() |
| scene_found = False |
| if scenes: |
| for i, scene in enumerate(scenes): |
| if scene.get('scene_id') == scene_id: |
| scene_ref.child(str(i)).update({ |
| 'narration': narration_text, |
| 'audio_storage_path': new_audio_url |
| }) |
| scene_found = True |
| break |
| |
| if not scene_found: |
| logger.warning(f"User {uid}: Scene {scene_id} not found in database for project {project_id} during narration update.") |
| return jsonify({'error': 'Scene not found in database'}), 404 |
|
|
| project_ref = db.reference(f'sozo_projects/{project_id}') |
| project_ref.update({'updatedAt': datetime.utcnow().isoformat()}) |
|
|
| logger.info(f"User {uid}: Successfully updated narration for scene {scene_id} in project {project_id}.") |
| return jsonify({'success': True, 'new_audio_url': new_audio_url}), 200 |
| except Exception as e: |
| logger.error(f"CRITICAL ERROR updating narration for {project_id}: {traceback.format_exc()}") |
| return jsonify({'error': str(e)}), 500 |
|
|
| |
|
|
| @app.route('/api/sozo/projects/<string:project_id>/generate-slides', methods=['POST']) |
| def generate_sozo_slides(project_id): |
| logger.info(f"POST /api/sozo/projects/{project_id}/generate-slides - Generating slides") |
| try: |
| token = request.headers.get('Authorization', '').split(' ') |
| uid = verify_token(token) |
| if not uid: |
| return jsonify({'error': 'Unauthorized'}), 401 |
| |
| project_ref = db.reference(f'sozo_projects/{project_id}') |
| project_data = project_ref.get() |
| |
| if not project_data or project_data.get('uid') != uid: |
| return jsonify({'error': 'Project not found or unauthorized'}), 404 |
| |
| raw_md = project_data.get('rawMarkdown') |
| chart_urls = project_data.get('chartUrls', {}) |
| |
| if not raw_md: |
| return jsonify({'error': 'Report must be generated before slides can be created.'}), 400 |
| |
| |
| llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", google_api_key=os.getenv("GOOGLE_API_KEY"), temperature=0.2) |
|
|
| slides_data = generate_slides_from_report( |
| raw_md, |
| chart_urls, |
| uid, |
| project_id, |
| bucket, |
| llm |
| ) |
| |
| if not slides_data: |
| raise Exception("Slide generation failed in core function.") |
| |
| |
| project_ref.update({'slides': slides_data}) |
| |
| logger.info(f"Project {project_id} successfully updated with {len(slides_data)} slides.") |
| |
| return jsonify({ |
| 'success': True, |
| 'slides': slides_data |
| }), 200 |
| |
| except Exception as e: |
| logger.error(f"CRITICAL error generating slides for project {project_id}: {traceback.format_exc()}") |
| db.reference(f'sozo_projects/{project_id}').update({'status': 'failed_slides', 'error': str(e)}) |
| return jsonify({'error': str(e)}), 500 |
| |
| |
| |
|
|
| @app.route('/join-waitlist', methods=['POST']) |
| def join_waitlist(): |
| try: |
| data = request.get_json() |
| if not data: return jsonify({"status": "error", "message": "Invalid request. JSON payload expected."}), 400 |
| email = data.get('email') |
| if not email or not is_valid_email(email): return jsonify({"status": "error", "message": "A valid email is required."}), 400 |
| email = email.lower() |
| waitlist_ref = db.reference('sozo_waitlist') |
| if waitlist_ref.order_by_child('email').equal_to(email).get(): |
| return jsonify({"status": "success", "message": "You are already on the waitlist!"}), 200 |
| new_entry_ref = waitlist_ref.push() |
| new_entry_ref.set({'email': email, 'timestamp': datetime.utcnow().isoformat() + 'Z'}) |
| return jsonify({"status": "success", "message": "Thank you for joining the waitlist!"}), 201 |
| except Exception as e: |
| traceback.print_exc() |
| return jsonify({"status": "error", "message": "An internal server error occurred."}), 500 |
|
|
| @app.route('/api/feedback', methods=['POST']) |
| def submit_feedback(): |
| try: |
| token = request.headers.get('Authorization', '').split(' ')[1] |
| uid = verify_token(token) |
| if not uid: return jsonify({'error': 'Invalid or expired token'}), 401 |
| data = request.get_json() |
| message = data.get('message') |
| if not message: return jsonify({'error': 'message is required'}), 400 |
| user_email = (db.reference(f'users/{uid}').get() or {}).get('email', 'unknown') |
| feedback_ref = db.reference('feedback').push() |
| feedback_ref.set({"user_id": uid, "user_email": user_email, "type": data.get('type', 'general'), "message": message, "created_at": datetime.utcnow().isoformat(), "status": "open"}) |
| return jsonify({"success": True, "feedback_id": feedback_ref.key}), 201 |
| except Exception as e: return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/user/request-credits', methods=['POST']) |
| def request_credits(): |
| try: |
| token = request.headers.get('Authorization', '').split(' ')[1] |
| uid = verify_token(token) |
| if not uid: return jsonify({'error': 'Invalid token'}), 401 |
| requested_credits = request.get_json().get('requested_credits') |
| if requested_credits is None: return jsonify({'error': 'requested_credits is required'}), 400 |
| credit_request_ref = db.reference('credit_requests').push() |
| credit_request_ref.set({'user_id': uid, 'requested_credits': requested_credits, 'status': 'pending', 'requested_at': datetime.utcnow().isoformat()}) |
| return jsonify({'success': True, 'request_id': credit_request_ref.key}) |
| except Exception as e: return jsonify({'error': str(e)}), 500 |
|
|
| |
| |
| |
|
|
| @app.route('/api/admin/dashboard-stats', methods=['GET']) |
| def get_admin_dashboard_stats(): |
| """A singular endpoint to fetch all key metrics for the admin dashboard.""" |
| try: |
| verify_admin(request.headers.get('Authorization', '')) |
|
|
| |
| all_users = db.reference('users').get() or {} |
| all_projects = db.reference('sozo_projects').get() or {} |
| all_feedback = db.reference('feedback').get() or {} |
| all_credit_requests = db.reference('credit_requests').get() or {} |
| waitlist = db.reference('sozo_waitlist').get() or {} |
|
|
| |
| stats = { |
| "user_stats": {"total": 0, "new_24h": 0, "new_7d": 0}, |
| "project_stats": {"total": 0, "new_24h": 0, "failed": 0, "videos_generated": 0}, |
| "action_items": {"open_feedback": 0, "pending_credit_requests": 0}, |
| "growth_stats": {"waitlist_total": 0} |
| } |
|
|
| now = datetime.utcnow() |
| one_day_ago = now - timedelta(days=1) |
| seven_days_ago = now - timedelta(days=7) |
|
|
| |
| stats["user_stats"]["total"] = len(all_users) |
| for user_data in all_users.values(): |
| created_at_str = user_data.get('created_at') |
| if created_at_str: |
| created_at_dt = datetime.fromisoformat(created_at_str) |
| if created_at_dt > one_day_ago: |
| stats["user_stats"]["new_24h"] += 1 |
| if created_at_dt > seven_days_ago: |
| stats["user_stats"]["new_7d"] += 1 |
| |
| |
| stats["project_stats"]["total"] = len(all_projects) |
| for project_data in all_projects.values(): |
| created_at_str = project_data.get('createdAt') |
| if created_at_str: |
| created_at_dt = datetime.fromisoformat(created_at_str) |
| if created_at_dt > one_day_ago: |
| stats["project_stats"]["new_24h"] += 1 |
| if project_data.get('status') == 'failed': |
| stats["project_stats"]["failed"] += 1 |
| if project_data.get('status') == 'video_complete': |
| stats["project_stats"]["videos_generated"] += 1 |
|
|
| |
| stats["action_items"]["open_feedback"] = sum(1 for fb in all_feedback.values() if fb.get('status') == 'open') |
| stats["action_items"]["pending_credit_requests"] = sum(1 for cr in all_credit_requests.values() if cr.get('status') == 'pending') |
|
|
| |
| stats["growth_stats"]["waitlist_total"] = len(waitlist) |
|
|
| return jsonify(stats), 200 |
|
|
| except Exception as e: |
| traceback.print_exc() |
| return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/credit_requests', methods=['GET']) |
| def list_credit_requests(): |
| try: |
| verify_admin(request.headers.get('Authorization', '')) |
| requests_list = [{'id': req_id, **data} for req_id, data in (db.reference('credit_requests').get() or {}).items()] |
| return jsonify({'credit_requests': requests_list}) |
| except Exception as e: return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/credit_requests/<string:request_id>', methods=['PUT']) |
| def process_credit_request(request_id): |
| try: |
| admin_uid = verify_admin(request.headers.get('Authorization', '')) |
| req_ref = db.reference(f'credit_requests/{request_id}') |
| req_data = req_ref.get() |
| if not req_data: return jsonify({'error': 'Credit request not found'}), 404 |
| decision = request.get_json().get('decision') |
| if decision not in ['approved', 'declined']: return jsonify({'error': 'decision must be "approved" or "declined"'}), 400 |
| if decision == 'approved': |
| user_ref = db.reference(f'users/{req_data["user_id"]}') |
| new_total = (user_ref.get() or {}).get('credits', 0) + float(req_data.get('requested_credits', 0)) |
| user_ref.update({'credits': new_total}) |
| req_ref.update({'status': 'approved', 'processed_by': admin_uid, 'processed_at': datetime.utcnow().isoformat()}) |
| return jsonify({'success': True, 'new_user_credits': new_total}) |
| else: |
| req_ref.update({'status': 'declined', 'processed_by': admin_uid, 'processed_at': datetime.utcnow().isoformat()}) |
| return jsonify({'success': True, 'message': 'Credit request declined'}) |
| except Exception as e: return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/users', methods=['GET']) |
| def admin_list_users(): |
| try: |
| verify_admin(request.headers.get('Authorization', '')) |
| all_users = db.reference('users').get() or {} |
| user_list = [{'uid': uid, **data} for uid, data in all_users.items()] |
| return jsonify({'users': user_list}), 200 |
| except Exception as e: return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/users/<string:uid>/credits', methods=['PUT']) |
| def admin_update_credits(uid): |
| try: |
| verify_admin(request.headers.get('Authorization', '')) |
| add_credits = request.get_json().get('add_credits') |
| if add_credits is None: return jsonify({'error': 'add_credits is required'}), 400 |
| user_ref = db.reference(f'users/{uid}') |
| if not user_ref.get(): return jsonify({'error': 'User not found'}), 404 |
| new_total = user_ref.get().get('credits', 0) + float(add_credits) |
| user_ref.update({'credits': new_total}) |
| return jsonify({'success': True, 'new_total_credits': new_total}) |
| except Exception as e: return jsonify({'error': str(e)}), 500 |
|
|
| @app.route('/api/admin/feedback', methods=['GET']) |
| def list_feedback(): |
| """List all feedback submissions for admin review.""" |
| try: |
| verify_admin(request.headers.get('Authorization', '')) |
| |
| |
| all_feedback = db.reference('feedback').get() or {} |
| |
| |
| feedback_list = [] |
| for feedback_id, feedback_data in all_feedback.items(): |
| feedback_item = { |
| 'id': feedback_id, |
| **feedback_data |
| } |
| feedback_list.append(feedback_item) |
| |
| |
| feedback_list.sort(key=lambda x: x.get('created_at', ''), reverse=True) |
| |
| return jsonify({'feedback': feedback_list}), 200 |
| |
| except Exception as e: |
| traceback.print_exc() |
| return jsonify({'error': str(e)}), 500 |
|
|
|
|
| @app.route('/api/admin/feedback/<string:feedback_id>', methods=['PUT']) |
| def update_feedback_status(feedback_id): |
| """Update feedback status (e.g., mark as reviewed, resolved, etc.)""" |
| try: |
| admin_uid = verify_admin(request.headers.get('Authorization', '')) |
| |
| |
| feedback_ref = db.reference(f'feedback/{feedback_id}') |
| feedback_data = feedback_ref.get() |
| |
| if not feedback_data: |
| return jsonify({'error': 'Feedback not found'}), 404 |
| |
| |
| request_data = request.get_json() |
| new_status = request_data.get('status') |
| |
| |
| valid_statuses = ['open', 'reviewed', 'resolved', 'closed'] |
| if new_status not in valid_statuses: |
| return jsonify({'error': f'Status must be one of: {", ".join(valid_statuses)}'}), 400 |
| |
| |
| update_data = { |
| 'status': new_status, |
| 'processed_by': admin_uid, |
| 'processed_at': datetime.utcnow().isoformat() |
| } |
| |
| |
| admin_notes = request_data.get('admin_notes') |
| if admin_notes: |
| update_data['admin_notes'] = admin_notes |
| |
| feedback_ref.update(update_data) |
| |
| return jsonify({ |
| 'success': True, |
| 'message': f'Feedback status updated to {new_status}', |
| 'feedback_id': feedback_id |
| }), 200 |
| |
| except Exception as e: |
| traceback.print_exc() |
| return jsonify({'error': str(e)}), 500 |
|
|
|
|
| @app.route('/api/admin/feedback/<string:feedback_id>', methods=['GET']) |
| def get_feedback_details(feedback_id): |
| """Get detailed view of a specific feedback item.""" |
| try: |
| verify_admin(request.headers.get('Authorization', '')) |
| |
| |
| feedback_ref = db.reference(f'feedback/{feedback_id}') |
| feedback_data = feedback_ref.get() |
| |
| if not feedback_data: |
| return jsonify({'error': 'Feedback not found'}), 404 |
| |
| |
| feedback_details = { |
| 'id': feedback_id, |
| **feedback_data |
| } |
| |
| |
| if 'user_id' in feedback_data: |
| user_ref = db.reference(f'users/{feedback_data["user_id"]}') |
| user_data = user_ref.get() |
| if user_data: |
| feedback_details['user_details'] = { |
| 'email': user_data.get('email'), |
| 'name': user_data.get('name'), |
| 'created_at': user_data.get('created_at') |
| } |
| |
| return jsonify({'feedback': feedback_details}), 200 |
| |
| except Exception as e: |
| traceback.print_exc() |
| return jsonify({'error': str(e)}), 500 |
| |
| |
| |
| if __name__ == '__main__': |
| app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 7860))) |