365 lines
15 KiB
Python
365 lines
15 KiB
Python
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/<int:user_id>', 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/<int:user_id>/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/<int:meeting_id>', 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/<int:meeting_id>/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/<int:meeting_id>/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/<int:meeting_id>/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/<int:item_id>', 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/<int:item_id>/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/<task_id>')
|
|
@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/<task_id>/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/<filename>')
|
|
@jwt_required()
|
|
def download_file(filename):
|
|
return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename, as_attachment=True)
|