548 lines
18 KiB
Python
548 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
翻譯任務管理 API
|
||
|
||
Author: PANJIT IT Team
|
||
Created: 2024-01-28
|
||
Modified: 2024-01-28
|
||
"""
|
||
|
||
from flask import Blueprint, request, jsonify, g
|
||
from app.utils.decorators import jwt_login_required, admin_required
|
||
from app.utils.validators import (
|
||
validate_job_uuid,
|
||
validate_pagination,
|
||
validate_date_range
|
||
)
|
||
from app.utils.helpers import create_response, calculate_processing_time
|
||
from app.utils.exceptions import ValidationError
|
||
from app.utils.logger import get_logger
|
||
from app.models.job import TranslationJob
|
||
from app.models.stats import APIUsageStats
|
||
from app.models.log import SystemLog
|
||
from sqlalchemy import and_, or_
|
||
|
||
jobs_bp = Blueprint('jobs', __name__, url_prefix='/jobs')
|
||
logger = get_logger(__name__)
|
||
|
||
|
||
@jobs_bp.route('', methods=['GET'])
|
||
@jwt_login_required
|
||
def get_user_jobs():
|
||
"""取得使用者任務列表"""
|
||
try:
|
||
# 取得查詢參數
|
||
page = request.args.get('page', 1, type=int)
|
||
per_page = request.args.get('per_page', 20, type=int)
|
||
status = request.args.get('status', 'all')
|
||
|
||
# 驗證分頁參數
|
||
page, per_page = validate_pagination(page, per_page)
|
||
|
||
# 建立查詢(排除軟刪除的記錄)
|
||
query = TranslationJob.query.filter_by(user_id=g.current_user_id).filter(TranslationJob.deleted_at.is_(None))
|
||
|
||
# 狀態篩選
|
||
if status and status != 'all':
|
||
valid_statuses = ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRY']
|
||
if status.upper() in valid_statuses:
|
||
query = query.filter_by(status=status.upper())
|
||
|
||
# 排序
|
||
query = query.order_by(TranslationJob.created_at.desc())
|
||
|
||
# 分頁
|
||
pagination = query.paginate(
|
||
page=page,
|
||
per_page=per_page,
|
||
error_out=False
|
||
)
|
||
|
||
jobs = pagination.items
|
||
|
||
# 組合回應資料
|
||
jobs_data = []
|
||
for job in jobs:
|
||
job_data = job.to_dict(include_files=False)
|
||
|
||
# 計算處理時間
|
||
if job.processing_started_at and job.completed_at:
|
||
job_data['processing_time'] = calculate_processing_time(
|
||
job.processing_started_at, job.completed_at
|
||
)
|
||
|
||
# 取得佇列位置(只對 PENDING 狀態)
|
||
if job.status == 'PENDING':
|
||
job_data['queue_position'] = TranslationJob.get_queue_position(job.job_uuid)
|
||
|
||
jobs_data.append(job_data)
|
||
|
||
return jsonify(create_response(
|
||
success=True,
|
||
data={
|
||
'jobs': jobs_data,
|
||
'pagination': {
|
||
'page': page,
|
||
'per_page': per_page,
|
||
'total': pagination.total,
|
||
'pages': pagination.pages,
|
||
'has_prev': pagination.has_prev,
|
||
'has_next': pagination.has_next
|
||
}
|
||
}
|
||
))
|
||
|
||
except ValidationError as e:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error=e.error_code,
|
||
message=str(e)
|
||
)), 400
|
||
|
||
except Exception as e:
|
||
logger.error(f"Get user jobs error: {str(e)}")
|
||
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='SYSTEM_ERROR',
|
||
message='取得任務列表失敗'
|
||
)), 500
|
||
|
||
|
||
@jobs_bp.route('/<job_uuid>', methods=['GET'])
|
||
@jwt_login_required
|
||
def get_job_detail(job_uuid):
|
||
"""取得任務詳細資訊"""
|
||
try:
|
||
# 驗證 UUID 格式
|
||
validate_job_uuid(job_uuid)
|
||
|
||
# 取得任務(排除軟刪除的記錄)
|
||
job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first()
|
||
|
||
if not job:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='JOB_NOT_FOUND',
|
||
message='任務不存在'
|
||
)), 404
|
||
|
||
# 檢查權限
|
||
if job.user_id != g.current_user_id and not g.is_admin:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='PERMISSION_DENIED',
|
||
message='無權限存取此任務'
|
||
)), 403
|
||
|
||
# 取得任務詳細資料
|
||
job_data = job.to_dict(include_files=True)
|
||
|
||
# 計算處理時間
|
||
if job.processing_started_at and job.completed_at:
|
||
job_data['processing_time'] = calculate_processing_time(
|
||
job.processing_started_at, job.completed_at
|
||
)
|
||
elif job.processing_started_at:
|
||
job_data['processing_time'] = calculate_processing_time(
|
||
job.processing_started_at
|
||
)
|
||
|
||
# 取得佇列位置(只對 PENDING 狀態)
|
||
if job.status == 'PENDING':
|
||
job_data['queue_position'] = TranslationJob.get_queue_position(job.job_uuid)
|
||
|
||
# 取得 API 使用統計(如果已完成)
|
||
if job.status == 'COMPLETED':
|
||
api_stats = APIUsageStats.get_user_statistics(
|
||
user_id=job.user_id,
|
||
start_date=job.created_at,
|
||
end_date=job.completed_at
|
||
)
|
||
job_data['api_usage'] = api_stats
|
||
|
||
return jsonify(create_response(
|
||
success=True,
|
||
data={
|
||
'job': job_data
|
||
}
|
||
))
|
||
|
||
except ValidationError as e:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error=e.error_code,
|
||
message=str(e)
|
||
)), 400
|
||
|
||
except Exception as e:
|
||
logger.error(f"Get job detail error: {str(e)}")
|
||
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='SYSTEM_ERROR',
|
||
message='取得任務詳情失敗'
|
||
)), 500
|
||
|
||
|
||
@jobs_bp.route('/<job_uuid>/retry', methods=['POST'])
|
||
@jwt_login_required
|
||
def retry_job(job_uuid):
|
||
"""重試失敗任務"""
|
||
try:
|
||
# 驗證 UUID 格式
|
||
validate_job_uuid(job_uuid)
|
||
|
||
# 取得任務(排除軟刪除的記錄)
|
||
job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first()
|
||
|
||
if not job:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='JOB_NOT_FOUND',
|
||
message='任務不存在'
|
||
)), 404
|
||
|
||
# 檢查權限
|
||
if job.user_id != g.current_user_id and not g.is_admin:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='PERMISSION_DENIED',
|
||
message='無權限操作此任務'
|
||
)), 403
|
||
|
||
# 檢查是否可以重試
|
||
if not job.can_retry():
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='CANNOT_RETRY',
|
||
message='任務無法重試(狀態不正確或重試次數已達上限)'
|
||
)), 400
|
||
|
||
# 重置任務狀態
|
||
job.update_status('PENDING', error_message=None)
|
||
job.increment_retry()
|
||
|
||
# 計算新的佇列位置
|
||
queue_position = TranslationJob.get_queue_position(job.job_uuid)
|
||
|
||
# 記錄重試日誌
|
||
SystemLog.info(
|
||
'jobs.retry',
|
||
f'Job retry requested: {job_uuid}',
|
||
user_id=g.current_user_id,
|
||
job_id=job.id,
|
||
extra_data={
|
||
'retry_count': job.retry_count,
|
||
'previous_error': job.error_message
|
||
}
|
||
)
|
||
|
||
logger.info(f"Job retry requested: {job_uuid} (retry count: {job.retry_count})")
|
||
|
||
# 重新觸發翻譯任務(這裡會在實作 Celery 時加入)
|
||
# from app.tasks.translation import process_translation_job
|
||
# process_translation_job.delay(job.id)
|
||
|
||
return jsonify(create_response(
|
||
success=True,
|
||
data={
|
||
'job_uuid': job.job_uuid,
|
||
'status': job.status,
|
||
'retry_count': job.retry_count,
|
||
'queue_position': queue_position
|
||
},
|
||
message='任務已重新加入佇列'
|
||
))
|
||
|
||
except ValidationError as e:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error=e.error_code,
|
||
message=str(e)
|
||
)), 400
|
||
|
||
except Exception as e:
|
||
logger.error(f"Job retry error: {str(e)}")
|
||
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='SYSTEM_ERROR',
|
||
message='重試任務失敗'
|
||
)), 500
|
||
|
||
|
||
@jobs_bp.route('/statistics', methods=['GET'])
|
||
@jwt_login_required
|
||
def get_user_statistics():
|
||
"""取得使用者統計資料"""
|
||
try:
|
||
# 取得日期範圍參數
|
||
start_date = request.args.get('start_date')
|
||
end_date = request.args.get('end_date')
|
||
|
||
# 驗證日期範圍
|
||
if start_date or end_date:
|
||
start_date, end_date = validate_date_range(start_date, end_date)
|
||
|
||
# 取得任務統計
|
||
job_stats = TranslationJob.get_statistics(
|
||
user_id=g.current_user_id,
|
||
start_date=start_date,
|
||
end_date=end_date
|
||
)
|
||
|
||
# 取得 API 使用統計
|
||
api_stats = APIUsageStats.get_user_statistics(
|
||
user_id=g.current_user_id,
|
||
start_date=start_date,
|
||
end_date=end_date
|
||
)
|
||
|
||
return jsonify(create_response(
|
||
success=True,
|
||
data={
|
||
'job_statistics': job_stats,
|
||
'api_statistics': api_stats
|
||
}
|
||
))
|
||
|
||
except ValidationError as e:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error=e.error_code,
|
||
message=str(e)
|
||
)), 400
|
||
|
||
except Exception as e:
|
||
logger.error(f"Get user statistics error: {str(e)}")
|
||
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='SYSTEM_ERROR',
|
||
message='取得統計資料失敗'
|
||
)), 500
|
||
|
||
|
||
@jobs_bp.route('/queue/status', methods=['GET'])
|
||
def get_queue_status():
|
||
"""取得佇列狀態(不需登入)"""
|
||
try:
|
||
# 取得各狀態任務數量
|
||
pending_count = TranslationJob.query.filter_by(status='PENDING').count()
|
||
processing_count = TranslationJob.query.filter_by(status='PROCESSING').count()
|
||
|
||
# 取得當前處理中的任務(最多5個)
|
||
processing_jobs = TranslationJob.query.filter_by(
|
||
status='PROCESSING'
|
||
).order_by(TranslationJob.processing_started_at).limit(5).all()
|
||
|
||
processing_jobs_data = []
|
||
for job in processing_jobs:
|
||
processing_jobs_data.append({
|
||
'job_uuid': job.job_uuid,
|
||
'original_filename': job.original_filename,
|
||
'progress': float(job.progress) if job.progress else 0.0,
|
||
'processing_started_at': job.processing_started_at.isoformat() if job.processing_started_at else None,
|
||
'processing_time': calculate_processing_time(job.processing_started_at) if job.processing_started_at else None
|
||
})
|
||
|
||
return jsonify(create_response(
|
||
success=True,
|
||
data={
|
||
'queue_status': {
|
||
'pending': pending_count,
|
||
'processing': processing_count,
|
||
'total_in_queue': pending_count + processing_count
|
||
},
|
||
'processing_jobs': processing_jobs_data
|
||
}
|
||
))
|
||
|
||
except Exception as e:
|
||
logger.error(f"Get queue status error: {str(e)}")
|
||
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='SYSTEM_ERROR',
|
||
message='取得佇列狀態失敗'
|
||
)), 500
|
||
|
||
|
||
@jobs_bp.route('/<job_uuid>/cancel', methods=['POST'])
|
||
@jwt_login_required
|
||
def cancel_job(job_uuid):
|
||
"""取消任務(支援 PENDING 和 PROCESSING 狀態)"""
|
||
try:
|
||
# 驗證 UUID 格式
|
||
validate_job_uuid(job_uuid)
|
||
|
||
# 取得任務(排除軟刪除的記錄)
|
||
job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first()
|
||
|
||
if not job:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='JOB_NOT_FOUND',
|
||
message='任務不存在'
|
||
)), 404
|
||
|
||
# 檢查權限
|
||
if job.user_id != g.current_user_id and not g.is_admin:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='PERMISSION_DENIED',
|
||
message='無權限操作此任務'
|
||
)), 403
|
||
|
||
# 只能取消等待中或處理中的任務
|
||
if job.status not in ['PENDING', 'PROCESSING']:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='CANNOT_CANCEL',
|
||
message='只能取消等待中或處理中的任務'
|
||
)), 400
|
||
|
||
# 如果是處理中的任務,需要中斷 Celery 任務
|
||
if job.status == 'PROCESSING':
|
||
try:
|
||
from app.services.celery_service import revoke_task
|
||
# 嘗試撤銷 Celery 任務
|
||
revoke_task(job.job_uuid)
|
||
logger.info(f"Celery task revoked for job: {job.job_uuid}")
|
||
except Exception as celery_error:
|
||
logger.warning(f"Failed to revoke Celery task for job {job.job_uuid}: {celery_error}")
|
||
# 即使撤銷失敗也繼續取消任務,因為用戶請求取消
|
||
|
||
# 更新任務狀態為失敗(取消)
|
||
cancel_message = f'使用者取消任務 (原狀態: {job.status})'
|
||
job.update_status('FAILED', error_message=cancel_message)
|
||
|
||
# 記錄取消日誌
|
||
SystemLog.info(
|
||
'jobs.cancel',
|
||
f'Job cancelled by user: {job_uuid}',
|
||
user_id=g.current_user_id,
|
||
job_id=job.id
|
||
)
|
||
|
||
logger.info(f"Job cancelled by user: {job_uuid}")
|
||
|
||
return jsonify(create_response(
|
||
success=True,
|
||
data={
|
||
'job_uuid': job.job_uuid,
|
||
'status': job.status
|
||
},
|
||
message='任務已取消'
|
||
))
|
||
|
||
except ValidationError as e:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error=e.error_code,
|
||
message=str(e)
|
||
)), 400
|
||
|
||
except Exception as e:
|
||
logger.error(f"Cancel job error: {str(e)}")
|
||
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='SYSTEM_ERROR',
|
||
message='取消任務失敗'
|
||
)), 500
|
||
|
||
|
||
@jobs_bp.route('/<job_uuid>', methods=['DELETE'])
|
||
@jwt_login_required
|
||
def delete_job(job_uuid):
|
||
"""刪除任務"""
|
||
try:
|
||
# 驗證 UUID 格式
|
||
validate_job_uuid(job_uuid)
|
||
|
||
# 取得任務
|
||
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
|
||
|
||
if not job:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='JOB_NOT_FOUND',
|
||
message='任務不存在'
|
||
)), 404
|
||
|
||
# 檢查權限
|
||
if job.user_id != g.current_user_id and not g.is_admin:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='PERMISSION_DENIED',
|
||
message='無權限操作此任務'
|
||
)), 403
|
||
|
||
# 如果是處理中的任務,先嘗試中斷 Celery 任務
|
||
if job.status == 'PROCESSING':
|
||
try:
|
||
from app.services.celery_service import revoke_task
|
||
# 嘗試撤銷 Celery 任務
|
||
revoke_task(job.job_uuid)
|
||
logger.info(f"Celery task revoked before deletion for job: {job.job_uuid}")
|
||
except Exception as celery_error:
|
||
logger.warning(f"Failed to revoke Celery task before deletion for job {job.job_uuid}: {celery_error}")
|
||
# 即使撤銷失敗也繼續刪除任務,因為用戶要求刪除
|
||
|
||
# 刪除任務相關檔案
|
||
import os
|
||
import shutil
|
||
from pathlib import Path
|
||
|
||
try:
|
||
if job.file_path and os.path.exists(job.file_path):
|
||
# 取得任務目錄(通常是 uploads/job_uuid)
|
||
job_dir = Path(job.file_path).parent
|
||
if job_dir.exists() and job_dir.name == job.job_uuid:
|
||
shutil.rmtree(job_dir)
|
||
logger.info(f"Deleted job directory: {job_dir}")
|
||
except Exception as file_error:
|
||
logger.warning(f"Failed to delete job files: {str(file_error)}")
|
||
|
||
# 記錄刪除日誌
|
||
SystemLog.info(
|
||
'jobs.delete',
|
||
f'Job deleted by user: {job_uuid}',
|
||
user_id=g.current_user_id,
|
||
job_id=job.id,
|
||
extra_data={
|
||
'filename': job.original_filename,
|
||
'status': job.status
|
||
}
|
||
)
|
||
|
||
from app import db
|
||
|
||
# 軟刪除資料庫記錄(保留數據供報表使用)
|
||
job.soft_delete()
|
||
|
||
logger.info(f"Job soft deleted by user: {job_uuid}")
|
||
|
||
return jsonify(create_response(
|
||
success=True,
|
||
message='任務已刪除'
|
||
))
|
||
|
||
except ValidationError as e:
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error=e.error_code,
|
||
message=str(e)
|
||
)), 400
|
||
|
||
except Exception as e:
|
||
logger.error(f"Delete job error: {str(e)}")
|
||
|
||
return jsonify(create_response(
|
||
success=False,
|
||
error='SYSTEM_ERROR',
|
||
message='刪除任務失敗'
|
||
)), 500 |