#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 翻譯任務資料模型 Author: PANJIT IT Team Created: 2024-01-28 Modified: 2024-01-28 """ import json import uuid from datetime import datetime, timedelta from sqlalchemy.sql import func from sqlalchemy import event from app import db from app.utils.timezone import format_taiwan_time class TranslationJob(db.Model): """翻譯任務表 (dt_translation_jobs)""" __tablename__ = 'dt_translation_jobs' id = db.Column(db.Integer, primary_key=True, autoincrement=True) job_uuid = db.Column(db.String(36), unique=True, nullable=False, index=True, comment='任務唯一識別碼') user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), nullable=False, comment='使用者ID') original_filename = db.Column(db.String(500), nullable=False, comment='原始檔名') file_extension = db.Column(db.String(10), nullable=False, comment='檔案副檔名') file_size = db.Column(db.BigInteger, nullable=False, comment='檔案大小(bytes)') file_path = db.Column(db.String(1000), nullable=False, comment='檔案路徑') source_language = db.Column(db.String(50), default=None, comment='來源語言') target_languages = db.Column(db.JSON, nullable=False, comment='目標語言陣列') status = db.Column( db.Enum('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRY', name='job_status'), default='PENDING', comment='任務狀態' ) progress = db.Column(db.Numeric(5, 2), default=0.00, comment='處理進度(%)') retry_count = db.Column(db.Integer, default=0, comment='重試次數') error_message = db.Column(db.Text, comment='錯誤訊息') total_tokens = db.Column(db.Integer, default=0, comment='總token數') total_cost = db.Column(db.Numeric(10, 4), default=0.0000, comment='總成本') processing_started_at = db.Column(db.DateTime, comment='開始處理時間') completed_at = db.Column(db.DateTime, comment='完成時間') created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間') updated_at = db.Column( db.DateTime, default=func.now(), onupdate=func.now(), comment='更新時間' ) deleted_at = db.Column(db.DateTime, comment='軟刪除時間') # 關聯關係 files = db.relationship('JobFile', backref='job', lazy='dynamic', cascade='all, delete-orphan') api_usage_stats = db.relationship('APIUsageStats', backref='job', lazy='dynamic') def __repr__(self): return f'' def __init__(self, **kwargs): """初始化,自動生成 UUID""" super().__init__(**kwargs) if not self.job_uuid: self.job_uuid = str(uuid.uuid4()) def to_dict(self, include_files=False): """轉換為字典格式""" data = { 'id': self.id, 'job_uuid': self.job_uuid, 'user_id': self.user_id, 'original_filename': self.original_filename, 'file_extension': self.file_extension, 'file_size': self.file_size, 'file_path': self.file_path, 'source_language': self.source_language, 'target_languages': self.target_languages, 'status': self.status, 'progress': float(self.progress) if self.progress else 0.0, 'retry_count': self.retry_count, 'error_message': self.error_message, 'total_tokens': self.total_tokens, 'total_cost': float(self.total_cost) if self.total_cost else 0.0, 'processing_started_at': format_taiwan_time(self.processing_started_at, "%Y-%m-%d %H:%M:%S") if self.processing_started_at else None, 'completed_at': format_taiwan_time(self.completed_at, "%Y-%m-%d %H:%M:%S") if self.completed_at else None, 'created_at': format_taiwan_time(self.created_at, "%Y-%m-%d %H:%M:%S") if self.created_at else None, 'updated_at': format_taiwan_time(self.updated_at, "%Y-%m-%d %H:%M:%S") if self.updated_at else None, 'deleted_at': format_taiwan_time(self.deleted_at, "%Y-%m-%d %H:%M:%S") if self.deleted_at else None } if include_files: data['files'] = [f.to_dict() for f in self.files] return data def update_status(self, status, error_message=None, progress=None): """更新任務狀態""" self.status = status if error_message: self.error_message = error_message if progress is not None: self.progress = progress if status == 'PROCESSING' and not self.processing_started_at: self.processing_started_at = datetime.utcnow() elif status == 'COMPLETED': self.completed_at = datetime.utcnow() self.progress = 100.00 self.updated_at = datetime.utcnow() db.session.commit() def add_original_file(self, filename, file_path, file_size): """新增原始檔案記錄""" original_file = JobFile( job_id=self.id, file_type='ORIGINAL', filename=filename, file_path=file_path, file_size=file_size ) db.session.add(original_file) db.session.commit() return original_file def add_translated_file(self, language_code, filename, file_path, file_size): """新增翻譯檔案記錄""" translated_file = JobFile( job_id=self.id, file_type='TRANSLATED', language_code=language_code, filename=filename, file_path=file_path, file_size=file_size ) db.session.add(translated_file) db.session.commit() return translated_file def get_translated_files(self): """取得翻譯檔案""" return self.files.filter_by(file_type='TRANSLATED').all() def get_original_file(self): """取得原始檔案""" return self.files.filter_by(file_type='ORIGINAL').first() def can_retry(self): """是否可以重試""" return self.status in ['FAILED', 'RETRY'] and self.retry_count < 3 def increment_retry(self): """增加重試次數""" self.retry_count += 1 self.updated_at = datetime.utcnow() db.session.commit() def soft_delete(self): """軟刪除任務(保留資料供報表使用)""" self.deleted_at = datetime.utcnow() self.updated_at = datetime.utcnow() db.session.commit() def restore(self): """恢復已刪除的任務""" self.deleted_at = None self.updated_at = datetime.utcnow() db.session.commit() def is_deleted(self): """檢查任務是否已被軟刪除""" return self.deleted_at is not None @classmethod def get_queue_position(cls, job_uuid): """取得任務在佇列中的位置""" job = cls.query.filter_by(job_uuid=job_uuid, deleted_at=None).first() if not job: return None position = cls.query.filter( cls.status == 'PENDING', cls.deleted_at.is_(None), cls.created_at < job.created_at ).count() return position + 1 @classmethod def get_pending_jobs(cls): """取得所有等待處理的任務""" return cls.query.filter_by(status='PENDING', deleted_at=None).order_by(cls.created_at.asc()).all() @classmethod def get_processing_jobs(cls): """取得所有處理中的任務""" return cls.query.filter_by(status='PROCESSING', deleted_at=None).all() @classmethod def get_user_jobs(cls, user_id, status=None, limit=None, offset=None, include_deleted=False): """取得使用者的任務列表""" query = cls.query.filter_by(user_id=user_id) # 預設排除軟刪除的記錄,除非明確要求包含 if not include_deleted: query = query.filter(cls.deleted_at.is_(None)) if status and status != 'all': query = query.filter_by(status=status.upper()) query = query.order_by(cls.created_at.desc()) if limit: query = query.limit(limit) if offset: query = query.offset(offset) return query.all() @classmethod def get_statistics(cls, user_id=None, start_date=None, end_date=None, include_deleted=True): """取得統計資料(預設包含所有記錄以確保報表完整性)""" query = cls.query # 報表統計預設包含已刪除記錄以確保數據完整性 if not include_deleted: query = query.filter(cls.deleted_at.is_(None)) if user_id: query = query.filter_by(user_id=user_id) if start_date: query = query.filter(cls.created_at >= start_date) if end_date: query = query.filter(cls.created_at <= end_date) total = query.count() completed = query.filter_by(status='COMPLETED').count() failed = query.filter_by(status='FAILED').count() processing = query.filter_by(status='PROCESSING').count() pending = query.filter_by(status='PENDING').count() return { 'total': total, 'completed': completed, 'failed': failed, 'processing': processing, 'pending': pending, 'success_rate': (completed / total * 100) if total > 0 else 0 } class JobFile(db.Model): """檔案記錄表 (dt_job_files)""" __tablename__ = 'dt_job_files' id = db.Column(db.Integer, primary_key=True, autoincrement=True) job_id = db.Column(db.Integer, db.ForeignKey('dt_translation_jobs.id'), nullable=False, comment='任務ID') file_type = db.Column( db.Enum('ORIGINAL', 'TRANSLATED', name='file_type'), nullable=False, comment='檔案類型' ) language_code = db.Column(db.String(50), comment='語言代碼(翻譯檔案)') filename = db.Column(db.String(500), nullable=False, comment='檔案名稱') file_path = db.Column(db.String(1000), nullable=False, comment='檔案路徑') file_size = db.Column(db.BigInteger, nullable=False, comment='檔案大小') created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間') def __repr__(self): return f'' def to_dict(self): """轉換為字典格式""" return { 'id': self.id, 'job_id': self.job_id, 'file_type': self.file_type, 'language_code': self.language_code, 'filename': self.filename, 'file_path': self.file_path, 'file_size': self.file_size, 'created_at': format_taiwan_time(self.created_at, "%Y-%m-%d %H:%M:%S") if self.created_at else None } # 事件監聽器:自動生成 UUID @event.listens_for(TranslationJob, 'before_insert') def receive_before_insert(mapper, connection, target): """在插入前自動生成 UUID""" if not target.job_uuid: target.job_uuid = str(uuid.uuid4())