tasks.py 29 KB

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