import os import uuid from flask import request, jsonify, send_from_directory, Blueprint, current_app from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity, get_jwt from werkzeug.utils import secure_filename from datetime import datetime from models import db, User, Meeting, ActionItem from tasks import ( celery, extract_audio_task, transcribe_audio_task, translate_text_task, summarize_text_task, preview_action_items_task ) api_bp = Blueprint("api_bp", __name__, url_prefix="/api") def save_uploaded_file(upload_folder, file_key='file'): if file_key not in request.files: return None, (jsonify({'error': 'Request does not contain a file part'}), 400) file = request.files[file_key] if file.filename == '': return None, (jsonify({'error': 'No file selected'}), 400) if file: original_filename = secure_filename(file.filename) file_extension = os.path.splitext(original_filename)[1] unique_filename = f"{uuid.uuid4()}{file_extension}" file_path = os.path.join(upload_folder, unique_filename) file.save(file_path) return file_path, None return None, (jsonify({'error': 'Unknown file error'}), 500) # --- User Authentication & Admin Routes --- @api_bp.route('/login', methods=['POST']) def login(): data = request.get_json() user = User.query.filter_by(username=data.get('username')).first() if user and user.check_password(data.get('password')): access_token = create_access_token(identity=str(user.id), additional_claims={'role': user.role, 'username': user.username}) return jsonify(access_token=access_token) return jsonify({"msg": "Bad username or password"}), 401 @api_bp.route('/register', methods=['POST']) def register(): """Public endpoint for new user registration.""" data = request.get_json() username = data.get('username') password = data.get('password') if not username or not password: return jsonify({"error": "Username and password are required"}), 400 if User.query.filter_by(username=username).first(): return jsonify({"error": "Username already exists"}), 409 # HTTP 409 Conflict try: new_user = User(username=username, role='user') # Default role is 'user' new_user.set_password(password) db.session.add(new_user) db.session.commit() return jsonify({"message": "User created successfully"}), 201 # HTTP 201 Created except Exception as e: db.session.rollback() current_app.logger.error(f"Error creating user: {e}") return jsonify({"error": "An internal error occurred"}), 500 @api_bp.route('/admin/users', methods=['GET']) @jwt_required() def get_all_users(): if get_jwt().get('role') != 'admin': return jsonify({"msg": "Administration rights required"}), 403 users = User.query.all() return jsonify([user.to_dict() for user in users]) @api_bp.route('/users', methods=['GET']) @jwt_required() def get_all_users_for_dropdown(): """A public endpoint for all logged-in users to fetch a list of users for UI selectors.""" users = User.query.all() return jsonify([user.to_dict() for user in users]) @api_bp.route('/admin/users', methods=['POST']) @jwt_required() def create_user(): if get_jwt().get('role') != 'admin': return jsonify({"msg": "Administration rights required"}), 403 data = request.get_json() username = data.get('username') password = data.get('password') role = data.get('role', 'user') if not username or not password: return jsonify({"error": "Username and password are required"}), 400 if User.query.filter_by(username=username).first(): return jsonify({"error": "Username already exists"}), 409 new_user = User(username=username, role=role) new_user.set_password(password) db.session.add(new_user) db.session.commit() return jsonify(new_user.to_dict()), 201 @api_bp.route('/admin/users/', methods=['DELETE']) @jwt_required() def delete_user(user_id): if get_jwt().get('role') != 'admin': return jsonify({"msg": "Administration rights required"}), 403 # Prevent admin from deleting themselves if str(user_id) == get_jwt_identity(): return jsonify({"error": "Admin users cannot delete their own account"}), 400 user_to_delete = User.query.get(user_id) if not user_to_delete: return jsonify({"error": "User not found"}), 404 try: # Disassociate meetings created by this user Meeting.query.filter_by(created_by_id=user_id).update({"created_by_id": None}) # Disassociate action items owned by this user ActionItem.query.filter_by(owner_id=user_id).update({"owner_id": None}) db.session.delete(user_to_delete) db.session.commit() return jsonify({"msg": f"User {user_to_delete.username} has been deleted."}), 200 except Exception as e: db.session.rollback() return jsonify({"error": f"An error occurred: {str(e)}"}), 500 @api_bp.route('/admin/users//password', methods=['PUT']) @jwt_required() def update_user_password(user_id): if get_jwt().get('role') != 'admin': return jsonify({"msg": "Administration rights required"}), 403 user_to_update = User.query.get(user_id) if not user_to_update: return jsonify({"error": "User not found"}), 404 data = request.get_json() password = data.get('password') if not password: return jsonify({"error": "Password is required"}), 400 try: user_to_update.set_password(password) db.session.commit() return jsonify({"msg": f"Password for user {user_to_update.username} has been updated."}), 200 except Exception as e: db.session.rollback() current_app.logger.error(f"Error updating password for user {user_id}: {e}") return jsonify({"error": "An internal error occurred while updating the password"}), 500 # --- Meeting Management Routes --- @api_bp.route('/meetings', methods=['GET', 'POST']) @jwt_required() def handle_meetings(): if request.method == 'POST': data = request.get_json() topic = data.get('topic') meeting_date_str = data.get('meeting_date') if not topic or not meeting_date_str: return jsonify({'error': 'Topic and meeting_date are required'}), 400 try: meeting_date = datetime.fromisoformat(meeting_date_str).date() new_meeting = Meeting( topic=topic, meeting_date=meeting_date, created_by_id=get_jwt_identity(), created_at=datetime.utcnow(), # Explicitly set creation time in UTC status='In Progress' # Set default status to 'In Progress' ) db.session.add(new_meeting) db.session.commit() return jsonify(new_meeting.to_dict()), 201 except Exception as e: db.session.rollback() current_app.logger.error(f"Failed to create meeting: {e}") return jsonify({'error': 'Failed to create meeting due to a database error.'}), 500 meetings = Meeting.query.order_by(Meeting.meeting_date.desc()).all() return jsonify([meeting.to_dict() for meeting in meetings]) @api_bp.route('/meetings/', methods=['GET', 'PUT', 'DELETE']) @jwt_required() def handle_meeting_detail(meeting_id): meeting = Meeting.query.get_or_404(meeting_id) if request.method == 'PUT': data = request.get_json() # Only update fields that are present in the request if 'topic' in data: meeting.topic = data.get('topic') if 'status' in data: # Security check: only admin or meeting creator can change the status current_user_id = get_jwt_identity() is_admin = get_jwt().get('role') == 'admin' if not is_admin and str(meeting.created_by_id) != str(current_user_id): return jsonify({"msg": "Only the meeting creator or an admin can change the status."}), 403 meeting.status = data.get('status') if 'transcript' in data: meeting.transcript = data.get('transcript') if 'summary' in data: meeting.summary = data.get('summary') if data.get('meeting_date'): meeting.meeting_date = datetime.fromisoformat(data['meeting_date']).date() db.session.commit() # Refresh the object to avoid session state issues before serialization db.session.refresh(meeting) return jsonify(meeting.to_dict()) if request.method == 'DELETE': current_user_id = get_jwt_identity() is_admin = get_jwt().get('role') == 'admin' if not is_admin and str(meeting.created_by_id) != str(current_user_id): return jsonify({"msg": "Only the meeting creator or an admin can delete this meeting."}), 403 db.session.delete(meeting) db.session.commit() return jsonify({"msg": "Meeting and associated action items deleted"}), 200 # GET request return jsonify(meeting.to_dict()) @api_bp.route('/meetings//summarize', methods=['POST']) @jwt_required() def summarize_meeting(meeting_id): meeting = Meeting.query.get_or_404(meeting_id) if not meeting.transcript: return jsonify({'error': 'Meeting has no transcript to summarize.'}), 400 task = summarize_text_task.delay(meeting_id) return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202 @api_bp.route('/meetings//preview-actions', methods=['POST']) @jwt_required() def preview_actions(meeting_id): meeting = Meeting.query.get_or_404(meeting_id) text_content = meeting.transcript # Always use the full transcript if not text_content: return jsonify({'error': 'Meeting has no transcript to analyze.'}), 400 task = preview_action_items_task.delay(text_content) return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202 # --- Independent Tool Routes --- @api_bp.route('/tools/extract_audio', methods=['POST']) @jwt_required() def handle_extract_audio(): input_path, error = save_uploaded_file(current_app.config['UPLOAD_FOLDER']) if error: return error output_path = os.path.splitext(input_path)[0] + ".wav" task = extract_audio_task.delay(input_path, output_path) return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202 @api_bp.route('/tools/transcribe_audio', methods=['POST']) @jwt_required() def handle_transcribe_audio(): input_path, error = save_uploaded_file(current_app.config['UPLOAD_FOLDER']) if error: return error # The 'language' parameter is no longer needed for the Dify-based task. task = transcribe_audio_task.delay(input_path) return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202 @api_bp.route('/tools/translate_text', methods=['POST']) @jwt_required() def handle_translate_text(): data = request.get_json() text_content = data.get('text') target_language = data.get('target_language', '繁體中文') if not text_content: return jsonify({'error': 'Text content is required'}), 400 task = translate_text_task.delay(text_content, target_language) return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202 # --- Action Item & Task Status Routes (largely unchanged) --- @api_bp.route('/meetings//action_items', methods=['GET']) @jwt_required() def get_action_items_for_meeting(meeting_id): action_items = ActionItem.query.filter_by(meeting_id=meeting_id).all() return jsonify([item.to_dict() for item in action_items]) @api_bp.route('/action_items/', methods=['PUT', 'DELETE']) @jwt_required() def handle_action_item(item_id): item = ActionItem.query.get_or_404(item_id) current_user_id = get_jwt_identity() current_user_role = get_jwt().get('role') meeting_owner_id = str(item.meeting.created_by_id) is_admin = current_user_role == 'admin' is_meeting_owner = str(current_user_id) == meeting_owner_id is_action_owner = str(current_user_id) == str(item.owner_id) if request.method == 'PUT': # Edit Permission: Admin, Meeting Owner, or Action Item Owner if not (is_admin or is_meeting_owner or is_action_owner): return jsonify({"msg": "You do not have permission to edit this item."}), 403 data = request.get_json() item.item = data.get('item', item.item) item.action = data.get('action', item.action) item.status = data.get('status', item.status) # Handle owner_id, allowing it to be set to null if 'owner_id' in data: item.owner_id = data.get('owner_id') if data.get('owner_id') else None if data.get('due_date'): item.due_date = datetime.fromisoformat(data['due_date']).date() if data['due_date'] else None db.session.commit() db.session.refresh(item) return jsonify(item.to_dict()) elif request.method == 'DELETE': # Delete Permission: Admin or Meeting Owner if not (is_admin or is_meeting_owner): return jsonify({"msg": "You do not have permission to delete this item."}), 403 db.session.delete(item) db.session.commit() return jsonify({'msg': 'Action item deleted'}), 200 @api_bp.route('/action_items//upload', methods=['POST']) @jwt_required() def upload_action_item_attachment(item_id): item = ActionItem.query.get_or_404(item_id) # Basic permission check: only meeting creator or action item owner can upload meeting_creator_id = item.meeting.created_by_id current_user_id = get_jwt_identity() if str(current_user_id) != str(meeting_creator_id) and str(current_user_id) != str(item.owner_id): return jsonify({"msg": "Permission denied"}), 403 file_path, error = save_uploaded_file(current_app.config['UPLOAD_FOLDER']) if error: return error # TODO: Consider deleting the old file if it exists item.attachment_path = os.path.basename(file_path) db.session.commit() return jsonify({'attachment_path': item.attachment_path}), 200 @api_bp.route('/status/') @jwt_required() def get_task_status(task_id): task = celery.AsyncResult(task_id) response_data = {'state': task.state, 'info': task.info if isinstance(task.info, dict) else str(task.info)} if task.state == 'SUCCESS' and isinstance(task.info, dict) and 'result_path' in task.info: response_data['info']['download_filename'] = os.path.basename(task.info['result_path']) return jsonify(response_data) @api_bp.route('/task//stop', methods=['POST']) @jwt_required() def stop_task(task_id): celery.control.revoke(task_id, terminate=True) return jsonify({'status': 'revoked'}), 200 @api_bp.route('/download/') @jwt_required() def download_file(filename): return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename, as_attachment=True)