tasks.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850
  1. """
  2. Task Management API endpoints
  3. Provides endpoints for:
  4. - GET /api/tasks - Get paginated list of tasks with status filtering
  5. - POST /api/tasks/create - Create a new scan task
  6. - POST /api/tasks/upload-scan - Upload CloudShell scan data and create task
  7. - GET /api/tasks/detail - Get task details
  8. - POST /api/tasks/delete - Delete a task
  9. - GET /api/tasks/logs - Get task logs with pagination
  10. Requirements: 3.1, 3.4, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6
  11. """
  12. import os
  13. import json
  14. import uuid
  15. from datetime import datetime
  16. from flask import jsonify, request, current_app
  17. from werkzeug.utils import secure_filename
  18. from app import db
  19. from app.api import api_bp
  20. from app.models import Task, TaskLog, AWSCredential, UserCredential
  21. from app.services import login_required, admin_required, get_current_user_from_context, check_credential_access
  22. from app.services.scan_data_processor import ScanDataProcessor
  23. from app.errors import ValidationError, NotFoundError, AuthorizationError
  24. ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp'}
  25. def allowed_file(filename: str) -> bool:
  26. """Check if file extension is allowed for network diagram"""
  27. return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
  28. @api_bp.route('/tasks', methods=['GET'])
  29. @login_required
  30. def get_tasks():
  31. """
  32. Get paginated list of tasks with optional status filtering.
  33. Query Parameters:
  34. page: Page number (default: 1)
  35. page_size: Items per page (default: 20, max: 100)
  36. status: Optional filter by status (pending, running, completed, failed)
  37. Returns:
  38. JSON with 'data' array and 'pagination' object
  39. """
  40. current_user = get_current_user_from_context()
  41. # Get pagination parameters
  42. page = request.args.get('page', 1, type=int)
  43. # Support both pageSize (frontend) and page_size (backend convention)
  44. page_size = request.args.get('pageSize', type=int) or request.args.get('page_size', type=int) or 20
  45. page_size = min(page_size, 100)
  46. status = request.args.get('status', type=str)
  47. # Validate pagination
  48. if page < 1:
  49. page = 1
  50. if page_size < 1:
  51. page_size = 20
  52. # Build query based on user role
  53. if current_user.role in ['admin', 'power_user']:
  54. query = Task.query
  55. else:
  56. # Regular users can only see their own tasks
  57. query = Task.query.filter_by(created_by=current_user.id)
  58. # Apply status filter if provided
  59. if status and status in ['pending', 'running', 'completed', 'failed']:
  60. query = query.filter_by(status=status)
  61. # Order by created_at descending
  62. query = query.order_by(Task.created_at.desc())
  63. # Get total count
  64. total = query.count()
  65. total_pages = (total + page_size - 1) // page_size if total > 0 else 1
  66. # Apply pagination
  67. tasks = query.offset((page - 1) * page_size).limit(page_size).all()
  68. return jsonify({
  69. 'data': [task.to_dict() for task in tasks],
  70. 'pagination': {
  71. 'page': page,
  72. 'page_size': page_size,
  73. 'total': total,
  74. 'total_pages': total_pages
  75. }
  76. }), 200
  77. @api_bp.route('/tasks/create', methods=['POST'])
  78. @login_required
  79. def create_task():
  80. """
  81. Create a new scan task.
  82. Request Body (JSON or multipart/form-data):
  83. name: Task name (required)
  84. credential_ids: List of credential IDs to use (required)
  85. regions: List of AWS regions to scan (required)
  86. project_metadata: Project metadata object (required)
  87. - clientName: Client name (required)
  88. - projectName: Project name (required)
  89. - bdManager: BD Manager name (optional)
  90. - bdManagerEmail: BD Manager email (optional)
  91. - solutionsArchitect: Solutions Architect name (optional)
  92. - solutionsArchitectEmail: Solutions Architect email (optional)
  93. - cloudEngineer: Cloud Engineer name (optional)
  94. - cloudEngineerEmail: Cloud Engineer email (optional)
  95. network_diagram: Network diagram image file (optional, multipart only)
  96. Returns:
  97. JSON with created task details and task_id
  98. """
  99. current_user = get_current_user_from_context()
  100. # Handle both JSON and multipart/form-data
  101. if request.content_type and 'multipart/form-data' in request.content_type:
  102. data = request.form.to_dict()
  103. # Parse JSON fields from form data
  104. import json
  105. if 'credential_ids' in data:
  106. data['credential_ids'] = json.loads(data['credential_ids'])
  107. if 'regions' in data:
  108. data['regions'] = json.loads(data['regions'])
  109. if 'project_metadata' in data:
  110. data['project_metadata'] = json.loads(data['project_metadata'])
  111. network_diagram = request.files.get('network_diagram')
  112. else:
  113. data = request.get_json() or {}
  114. network_diagram = None
  115. # Validate required fields
  116. if not data.get('name'):
  117. raise ValidationError(
  118. message="Task name is required",
  119. details={"missing_fields": ["name"]}
  120. )
  121. credential_ids = data.get('credential_ids', [])
  122. if not credential_ids or not isinstance(credential_ids, list) or len(credential_ids) == 0:
  123. raise ValidationError(
  124. message="At least one credential must be selected",
  125. details={"missing_fields": ["credential_ids"]}
  126. )
  127. regions = data.get('regions', [])
  128. if not regions or not isinstance(regions, list) or len(regions) == 0:
  129. raise ValidationError(
  130. message="At least one region must be selected",
  131. details={"missing_fields": ["regions"]}
  132. )
  133. project_metadata = data.get('project_metadata', {})
  134. if not isinstance(project_metadata, dict):
  135. raise ValidationError(
  136. message="Project metadata must be an object",
  137. details={"field": "project_metadata", "reason": "invalid_type"}
  138. )
  139. # Validate required project metadata fields
  140. required_metadata = ['clientName', 'projectName']
  141. missing_metadata = [field for field in required_metadata if not project_metadata.get(field)]
  142. if missing_metadata:
  143. raise ValidationError(
  144. message="Missing required project metadata fields",
  145. details={"missing_fields": missing_metadata}
  146. )
  147. # Validate clientName and projectName don't contain invalid filename characters
  148. import re
  149. invalid_chars_pattern = r'[<>\/\\|*:?"]'
  150. client_name = project_metadata.get('clientName', '')
  151. project_name = project_metadata.get('projectName', '')
  152. if re.search(invalid_chars_pattern, client_name):
  153. raise ValidationError(
  154. message="Client name contains invalid characters",
  155. details={"field": "clientName", "reason": "Cannot contain < > / \\ | * : ? \""}
  156. )
  157. if re.search(invalid_chars_pattern, project_name):
  158. raise ValidationError(
  159. message="Project name contains invalid characters",
  160. details={"field": "projectName", "reason": "Cannot contain < > / \\ | * : ? \""}
  161. )
  162. # Validate credential access for regular users
  163. for cred_id in credential_ids:
  164. if not check_credential_access(current_user, cred_id):
  165. raise AuthorizationError(
  166. message=f"Access denied to credential {cred_id}",
  167. details={"credential_id": cred_id, "reason": "not_assigned"}
  168. )
  169. # Verify credential exists and is active
  170. credential = db.session.get(AWSCredential, cred_id)
  171. if not credential:
  172. raise NotFoundError(
  173. message=f"Credential {cred_id} not found",
  174. details={"credential_id": cred_id}
  175. )
  176. if not credential.is_active:
  177. raise ValidationError(
  178. message=f"Credential {cred_id} is not active",
  179. details={"credential_id": cred_id, "reason": "inactive"}
  180. )
  181. # Handle network diagram upload
  182. network_diagram_path = None
  183. if network_diagram and network_diagram.filename:
  184. if not allowed_file(network_diagram.filename):
  185. raise ValidationError(
  186. message="Invalid file type for network diagram. Allowed: png, jpg, jpeg, gif, bmp",
  187. details={"field": "network_diagram", "reason": "invalid_file_type"}
  188. )
  189. # Save the file
  190. uploads_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads')
  191. os.makedirs(uploads_folder, exist_ok=True)
  192. filename = secure_filename(network_diagram.filename)
  193. # Add timestamp to avoid conflicts
  194. import time
  195. filename = f"{int(time.time())}_{filename}"
  196. network_diagram_path = os.path.join(uploads_folder, filename)
  197. network_diagram.save(network_diagram_path)
  198. # Store path in project metadata
  199. project_metadata['network_diagram_path'] = network_diagram_path
  200. # Create task
  201. task = Task(
  202. name=data['name'].strip(),
  203. status='pending',
  204. progress=0,
  205. created_by=current_user.id
  206. )
  207. task.credential_ids = credential_ids
  208. task.regions = regions
  209. task.project_metadata = project_metadata
  210. db.session.add(task)
  211. db.session.commit()
  212. # Dispatch to Celery
  213. celery_task = None
  214. try:
  215. # 先测试Redis连接
  216. import redis
  217. print(f"🔍 测试Redis连接...")
  218. r = redis.Redis(host='localhost', port=6379, db=0)
  219. r.ping()
  220. print(f"✅ Redis连接成功")
  221. # 导入并初始化Celery应用
  222. from app.celery_app import celery_app, init_celery
  223. init_celery(current_app._get_current_object())
  224. print(f"✅ Celery初始化完成, broker: {celery_app.conf.broker_url}")
  225. # 导入Celery任务
  226. from app.tasks.scan_tasks import scan_aws_resources
  227. print(f"✅ 任务模块导入成功")
  228. # 提交任务
  229. print(f"🔍 提交任务到Celery队列...")
  230. celery_task = scan_aws_resources.delay(
  231. task_id=task.id,
  232. credential_ids=credential_ids,
  233. regions=regions,
  234. project_metadata=project_metadata
  235. )
  236. print(f"✅ 任务已提交: {celery_task.id}")
  237. except redis.ConnectionError as e:
  238. # Redis连接失败,删除已创建的任务并返回错误
  239. db.session.delete(task)
  240. db.session.commit()
  241. raise ValidationError(
  242. message="Redis服务不可用,无法创建任务。请确保Redis服务已启动。",
  243. details={"error": str(e)}
  244. )
  245. except Exception as e:
  246. # 其他错误
  247. db.session.delete(task)
  248. db.session.commit()
  249. raise ValidationError(
  250. message="任务提交失败",
  251. details={"error": str(e), "error_type": type(e).__name__}
  252. )
  253. # Store Celery task ID
  254. task.celery_task_id = celery_task.id
  255. db.session.commit()
  256. return jsonify({
  257. 'message': 'Task created successfully',
  258. 'task': task.to_dict(),
  259. 'celery_task_id': celery_task.id
  260. }), 201
  261. @api_bp.route('/tasks/upload-scan', methods=['POST'])
  262. @login_required
  263. def upload_scan():
  264. """
  265. Upload CloudShell scan data and create a task.
  266. This endpoint accepts JSON scan data from CloudShell scanner and creates
  267. a task for report generation without requiring AWS credentials.
  268. Request Body (multipart/form-data):
  269. scan_data: JSON file containing CloudShell scan results (required, max 50MB)
  270. project_metadata: JSON string with project metadata (required)
  271. - clientName: Client name (required)
  272. - projectName: Project name (required)
  273. - bdManager: BD Manager name (optional)
  274. - bdManagerEmail: BD Manager email (optional)
  275. - solutionsArchitect: Solutions Architect name (optional)
  276. - solutionsArchitectEmail: Solutions Architect email (optional)
  277. - cloudEngineer: Cloud Engineer name (optional)
  278. - cloudEngineerEmail: Cloud Engineer email (optional)
  279. network_diagram: Network diagram image file (optional)
  280. Returns:
  281. JSON with created task details
  282. Requirements:
  283. - 4.1: Provide API endpoint to receive uploaded JSON data
  284. - 4.2: Validate JSON structure completeness
  285. - 4.5: Return detailed error information if validation fails
  286. - 4.6: Support large file uploads (max 50MB)
  287. """
  288. import re
  289. import redis
  290. current_user = get_current_user_from_context()
  291. # Check content type
  292. if not request.content_type or 'multipart/form-data' not in request.content_type:
  293. raise ValidationError(
  294. message="Content-Type must be multipart/form-data",
  295. details={"expected": "multipart/form-data", "received": request.content_type}
  296. )
  297. # Get scan_data file
  298. scan_data_file = request.files.get('scan_data')
  299. if not scan_data_file or not scan_data_file.filename:
  300. raise ValidationError(
  301. message="scan_data file is required",
  302. details={"missing_fields": ["scan_data"]}
  303. )
  304. # Validate file extension
  305. if not scan_data_file.filename.lower().endswith('.json'):
  306. raise ValidationError(
  307. message="scan_data must be a JSON file",
  308. details={"field": "scan_data", "reason": "invalid_file_type", "expected": ".json"}
  309. )
  310. # Read and parse JSON data
  311. try:
  312. scan_data_content = scan_data_file.read()
  313. # Check file size (50MB limit)
  314. max_size = current_app.config.get('MAX_CONTENT_LENGTH', 50 * 1024 * 1024)
  315. if len(scan_data_content) > max_size:
  316. raise ValidationError(
  317. message="File size exceeds limit",
  318. details={"max_size": f"{max_size // (1024 * 1024)}MB", "field": "scan_data"}
  319. )
  320. scan_data = json.loads(scan_data_content.decode('utf-8'))
  321. except json.JSONDecodeError as e:
  322. raise ValidationError(
  323. message="Invalid JSON format",
  324. details={"error": str(e), "field": "scan_data"}
  325. )
  326. except UnicodeDecodeError as e:
  327. raise ValidationError(
  328. message="Invalid file encoding. File must be UTF-8 encoded",
  329. details={"error": str(e), "field": "scan_data"}
  330. )
  331. # Validate scan data structure using ScanDataProcessor
  332. processor = ScanDataProcessor()
  333. is_valid, validation_errors = processor.validate_scan_data(scan_data)
  334. if not is_valid:
  335. raise ValidationError(
  336. message="Invalid scan data structure",
  337. details={"validation_errors": validation_errors, "missing_fields": validation_errors}
  338. )
  339. # Parse project_metadata from form data
  340. project_metadata_str = request.form.get('project_metadata')
  341. if not project_metadata_str:
  342. raise ValidationError(
  343. message="project_metadata is required",
  344. details={"missing_fields": ["project_metadata"]}
  345. )
  346. try:
  347. project_metadata = json.loads(project_metadata_str)
  348. except json.JSONDecodeError as e:
  349. raise ValidationError(
  350. message="Invalid project_metadata JSON format",
  351. details={"error": str(e), "field": "project_metadata"}
  352. )
  353. if not isinstance(project_metadata, dict):
  354. raise ValidationError(
  355. message="project_metadata must be a JSON object",
  356. details={"field": "project_metadata", "reason": "invalid_type"}
  357. )
  358. # Validate required project metadata fields
  359. required_metadata = ['clientName', 'projectName']
  360. missing_metadata = [field for field in required_metadata if not project_metadata.get(field)]
  361. if missing_metadata:
  362. raise ValidationError(
  363. message="Missing required project metadata fields",
  364. details={"missing_fields": missing_metadata}
  365. )
  366. # Validate clientName and projectName don't contain invalid filename characters
  367. invalid_chars_pattern = r'[<>\/\\|*:?"]'
  368. client_name = project_metadata.get('clientName', '')
  369. project_name = project_metadata.get('projectName', '')
  370. if re.search(invalid_chars_pattern, client_name):
  371. raise ValidationError(
  372. message="Client name contains invalid characters",
  373. details={"field": "clientName", "reason": "Cannot contain < > / \\ | * : ? \""}
  374. )
  375. if re.search(invalid_chars_pattern, project_name):
  376. raise ValidationError(
  377. message="Project name contains invalid characters",
  378. details={"field": "projectName", "reason": "Cannot contain < > / \\ | * : ? \""}
  379. )
  380. # Handle network diagram upload
  381. network_diagram_path = None
  382. network_diagram = request.files.get('network_diagram')
  383. if network_diagram and network_diagram.filename:
  384. if not allowed_file(network_diagram.filename):
  385. raise ValidationError(
  386. message="Invalid file type for network diagram. Allowed: png, jpg, jpeg, gif, bmp",
  387. details={"field": "network_diagram", "reason": "invalid_file_type"}
  388. )
  389. # Save the file
  390. uploads_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads')
  391. os.makedirs(uploads_folder, exist_ok=True)
  392. filename = secure_filename(network_diagram.filename)
  393. import time
  394. filename = f"{int(time.time())}_{filename}"
  395. network_diagram_path = os.path.join(uploads_folder, filename)
  396. network_diagram.save(network_diagram_path)
  397. # Store path in project metadata
  398. project_metadata['network_diagram_path'] = network_diagram_path
  399. # Save scan data to file
  400. scan_data_folder = current_app.config.get('SCAN_DATA_FOLDER', 'uploads/scan_data')
  401. os.makedirs(scan_data_folder, exist_ok=True)
  402. # Generate unique filename for scan data
  403. scan_data_filename = f"scan_{uuid.uuid4().hex}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
  404. scan_data_path = os.path.join(scan_data_folder, scan_data_filename)
  405. # Write scan data to file
  406. with open(scan_data_path, 'w', encoding='utf-8') as f:
  407. json.dump(scan_data, f, ensure_ascii=False, indent=2)
  408. # Extract metadata from scan data
  409. metadata = scan_data.get('metadata', {})
  410. account_id = metadata.get('account_id', 'Unknown')
  411. regions_scanned = metadata.get('regions_scanned', [])
  412. # Create task name
  413. task_name = f"CloudShell Scan - {account_id} - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
  414. # Create task with source='upload'
  415. task = Task(
  416. name=task_name,
  417. status='pending',
  418. progress=0,
  419. created_by=current_user.id,
  420. source='upload',
  421. scan_data_path=scan_data_path
  422. )
  423. task.credential_ids = [] # No credentials for upload tasks
  424. task.regions = regions_scanned
  425. task.project_metadata = project_metadata
  426. db.session.add(task)
  427. db.session.commit()
  428. # Dispatch to Celery for processing
  429. celery_task = None
  430. try:
  431. # Test Redis connection
  432. r = redis.Redis(host='localhost', port=6379, db=0)
  433. r.ping()
  434. # Initialize Celery
  435. from app.celery_app import celery_app, init_celery
  436. init_celery(current_app._get_current_object())
  437. # Import and dispatch task
  438. from app.tasks.scan_tasks import process_uploaded_scan
  439. celery_task = process_uploaded_scan.delay(
  440. task_id=task.id,
  441. scan_data_path=scan_data_path,
  442. project_metadata=project_metadata
  443. )
  444. except redis.ConnectionError as e:
  445. # Redis connection failed, delete task and scan data file
  446. db.session.delete(task)
  447. db.session.commit()
  448. if os.path.exists(scan_data_path):
  449. os.remove(scan_data_path)
  450. raise ValidationError(
  451. message="Redis服务不可用,无法创建任务。请确保Redis服务已启动。",
  452. details={"error": str(e)}
  453. )
  454. except Exception as e:
  455. # Other errors
  456. db.session.delete(task)
  457. db.session.commit()
  458. if os.path.exists(scan_data_path):
  459. os.remove(scan_data_path)
  460. raise ValidationError(
  461. message="任务提交失败",
  462. details={"error": str(e), "error_type": type(e).__name__}
  463. )
  464. # Store Celery task ID
  465. task.celery_task_id = celery_task.id
  466. db.session.commit()
  467. return jsonify({
  468. 'message': '任务创建成功',
  469. 'task': task.to_dict(),
  470. 'celery_task_id': celery_task.id
  471. }), 201
  472. @api_bp.route('/tasks/detail', methods=['GET'])
  473. @login_required
  474. def get_task_detail():
  475. """
  476. Get task details including current status and progress.
  477. Query Parameters:
  478. id: Task ID (required)
  479. Returns:
  480. JSON with task details
  481. """
  482. current_user = get_current_user_from_context()
  483. task_id = request.args.get('id', type=int)
  484. if not task_id:
  485. raise ValidationError(
  486. message="Task ID is required",
  487. details={"missing_fields": ["id"]}
  488. )
  489. task = db.session.get(Task, task_id)
  490. if not task:
  491. raise NotFoundError(
  492. message="Task not found",
  493. details={"task_id": task_id}
  494. )
  495. # Check access for regular users
  496. if current_user.role == 'user' and task.created_by != current_user.id:
  497. raise AuthorizationError(
  498. message="Access denied",
  499. details={"reason": "not_owner"}
  500. )
  501. # Get task details with additional info
  502. task_dict = task.to_dict()
  503. # Add report info if available
  504. if task.report:
  505. task_dict['report'] = task.report.to_dict()
  506. # Add error count
  507. error_count = TaskLog.query.filter_by(task_id=task_id, level='error').count()
  508. task_dict['error_count'] = error_count
  509. # Get Celery task status if running
  510. if task.status == 'running' and task.celery_task_id:
  511. from celery.result import AsyncResult
  512. from app.celery_app import celery_app
  513. result = AsyncResult(task.celery_task_id, app=celery_app)
  514. if result.state == 'PROGRESS':
  515. task_dict['celery_progress'] = result.info
  516. return jsonify(task_dict), 200
  517. @api_bp.route('/tasks/delete', methods=['POST'])
  518. @login_required
  519. def delete_task():
  520. """
  521. Delete a task and its associated logs and report.
  522. Request Body:
  523. id: Task ID (required)
  524. Returns:
  525. JSON with success message
  526. """
  527. current_user = get_current_user_from_context()
  528. data = request.get_json() or {}
  529. task_id = data.get('id')
  530. if not task_id:
  531. raise ValidationError(
  532. message="Task ID is required",
  533. details={"missing_fields": ["id"]}
  534. )
  535. task = db.session.get(Task, task_id)
  536. if not task:
  537. raise NotFoundError(
  538. message="Task not found",
  539. details={"task_id": task_id}
  540. )
  541. # Check access - only admin or task owner can delete
  542. if current_user.role != 'admin' and task.created_by != current_user.id:
  543. raise AuthorizationError(
  544. message="Access denied",
  545. details={"reason": "not_owner_or_admin"}
  546. )
  547. # Cannot delete running tasks
  548. if task.status == 'running':
  549. raise ValidationError(
  550. message="Cannot delete a running task",
  551. details={"task_id": task_id, "status": task.status}
  552. )
  553. # Delete associated report file if exists
  554. if task.report and task.report.file_path:
  555. try:
  556. if os.path.exists(task.report.file_path):
  557. os.remove(task.report.file_path)
  558. except OSError:
  559. pass # File may already be deleted
  560. # Delete task (cascade will handle logs and report)
  561. db.session.delete(task)
  562. db.session.commit()
  563. return jsonify({
  564. 'message': 'Task deleted successfully'
  565. }), 200
  566. @api_bp.route('/tasks/logs', methods=['GET'])
  567. @login_required
  568. def get_task_logs():
  569. """
  570. Get paginated task logs.
  571. Query Parameters:
  572. id: Task ID (required)
  573. page: Page number (default: 1)
  574. page_size: Items per page (default: 20, max: 100)
  575. level: Optional filter by log level (info, warning, error)
  576. Returns:
  577. JSON with 'data' array and 'pagination' object
  578. Requirements:
  579. - 8.3: Display error logs associated with task
  580. """
  581. current_user = get_current_user_from_context()
  582. task_id = request.args.get('id', type=int)
  583. if not task_id:
  584. raise ValidationError(
  585. message="Task ID is required",
  586. details={"missing_fields": ["id"]}
  587. )
  588. task = db.session.get(Task, task_id)
  589. if not task:
  590. raise NotFoundError(
  591. message="Task not found",
  592. details={"task_id": task_id}
  593. )
  594. # Check access for regular users
  595. if current_user.role == 'user' and task.created_by != current_user.id:
  596. raise AuthorizationError(
  597. message="Access denied",
  598. details={"reason": "not_owner"}
  599. )
  600. # Get pagination parameters
  601. page = request.args.get('page', 1, type=int)
  602. # Support both pageSize (frontend) and page_size (backend convention)
  603. page_size = request.args.get('pageSize', type=int) or request.args.get('page_size', type=int) or 20
  604. page_size = min(page_size, 100)
  605. level = request.args.get('level', type=str)
  606. # Validate pagination
  607. if page < 1:
  608. page = 1
  609. if page_size < 1:
  610. page_size = 20
  611. # Build query
  612. query = TaskLog.query.filter_by(task_id=task_id)
  613. # Apply level filter if provided
  614. if level and level in ['info', 'warning', 'error']:
  615. query = query.filter_by(level=level)
  616. # Order by created_at descending
  617. query = query.order_by(TaskLog.created_at.desc())
  618. # Get total count
  619. total = query.count()
  620. total_pages = (total + page_size - 1) // page_size if total > 0 else 1
  621. # Apply pagination
  622. logs = query.offset((page - 1) * page_size).limit(page_size).all()
  623. return jsonify({
  624. 'data': [log.to_dict() for log in logs],
  625. 'pagination': {
  626. 'page': page,
  627. 'page_size': page_size,
  628. 'total': total,
  629. 'total_pages': total_pages
  630. }
  631. }), 200
  632. @api_bp.route('/tasks/errors', methods=['GET'])
  633. @login_required
  634. def get_task_errors():
  635. """
  636. Get error logs for a specific task.
  637. This is a convenience endpoint that returns only error-level logs
  638. with full details including stack traces.
  639. Query Parameters:
  640. id: Task ID (required)
  641. page: Page number (default: 1)
  642. page_size: Items per page (default: 20, max: 100)
  643. Returns:
  644. JSON with 'data' array containing error logs and 'pagination' object
  645. Requirements:
  646. - 8.2: Record error details in task record
  647. - 8.3: Display error logs associated with task
  648. """
  649. current_user = get_current_user_from_context()
  650. task_id = request.args.get('id', type=int)
  651. if not task_id:
  652. raise ValidationError(
  653. message="Task ID is required",
  654. details={"missing_fields": ["id"]}
  655. )
  656. task = db.session.get(Task, task_id)
  657. if not task:
  658. raise NotFoundError(
  659. message="Task not found",
  660. details={"task_id": task_id}
  661. )
  662. # Check access for regular users
  663. if current_user.role == 'user' and task.created_by != current_user.id:
  664. raise AuthorizationError(
  665. message="Access denied",
  666. details={"reason": "not_owner"}
  667. )
  668. # Get pagination parameters
  669. page = request.args.get('page', 1, type=int)
  670. # Support both pageSize (frontend) and page_size (backend convention)
  671. page_size = request.args.get('pageSize', type=int) or request.args.get('page_size', type=int) or 20
  672. page_size = min(page_size, 100)
  673. # Validate pagination
  674. if page < 1:
  675. page = 1
  676. if page_size < 1:
  677. page_size = 20
  678. # Build query for error logs only
  679. query = TaskLog.query.filter_by(task_id=task_id, level='error')
  680. # Order by created_at descending
  681. query = query.order_by(TaskLog.created_at.desc())
  682. # Get total count
  683. total = query.count()
  684. total_pages = (total + page_size - 1) // page_size if total > 0 else 1
  685. # Apply pagination
  686. logs = query.offset((page - 1) * page_size).limit(page_size).all()
  687. # Build response with full error details
  688. error_data = []
  689. for log in logs:
  690. log_dict = log.to_dict()
  691. # Ensure details are included for error analysis
  692. error_data.append(log_dict)
  693. return jsonify({
  694. 'data': error_data,
  695. 'pagination': {
  696. 'page': page,
  697. 'page_size': page_size,
  698. 'total': total,
  699. 'total_pages': total_pages
  700. },
  701. 'summary': {
  702. 'total_errors': total,
  703. 'task_status': task.status
  704. }
  705. }), 200