import json import os import zipfile from django.conf import settings from django.http import FileResponse, Http404 from django.utils import timezone from rest_framework import permissions, status from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from api.models import AnalysisTask, Project from api.serializers import AnalysisTaskSerializer from api.utils.r_executor import RExecutor class _AnalysisViewMixin: permission_classes = [permissions.IsAuthenticated] DATA_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff", ".svg", ".pdf"} @staticmethod def _get_project(request, project_id): return Project.objects.get(id=project_id, user=request.user) @staticmethod def _get_project_media_dir(user_id, project_id): return os.path.join(settings.MEDIA_ROOT, f"users/{user_id}/projects/{project_id}") @staticmethod def _get_results_dir(user_id, project_id): return os.path.join(settings.MEDIA_ROOT, f"users/{user_id}/projects/{project_id}/imgs/analysis") @staticmethod def _get_images_dir(user_id, project_id): return os.path.join(settings.MEDIA_ROOT, f"users/{user_id}/projects/{project_id}/imgs") @staticmethod def _get_temp_dir(user_id, project_id): return os.path.join(settings.MEDIA_ROOT, f"users/{user_id}/projects/{project_id}/temp") @staticmethod def _get_ramex_data_path(user_id, project_id): return os.path.join(settings.MEDIA_ROOT, f"users/{user_id}/projects/{project_id}/data/ramex_data.rds") @staticmethod def _parse_params(request): params = request.data if "params" in params and isinstance(params["params"], str): params = json.loads(params["params"]) return dict(params) @staticmethod def _save_optional_uploads(request, project_id, user_id, params): if "bands_csv" not in request.FILES: return params file_path = os.path.join( settings.MEDIA_ROOT, f"users/{user_id}/projects/{project_id}/data/bands_ann.csv", ) os.makedirs(os.path.dirname(file_path), exist_ok=True) with open(file_path, "wb+") as destination: for chunk in request.FILES["bands_csv"].chunks(): destination.write(chunk) params["bands_csv_path"] = file_path return params @staticmethod def _build_empty_result(): return {"status": "success", "analyses": {}, "plots": {}, "artifacts": {}} @classmethod def _should_export_file(cls, project_media_dir, file_path, temp_dir): rel_path = os.path.relpath(file_path, project_media_dir) normalized_rel_path = rel_path.replace(os.sep, "/") if os.path.normpath(file_path).startswith(os.path.normpath(temp_dir)): return False # RamEx functions often create plots in the R working directory first. # The executor copies those plots into imgs/ for preview, so exporting # image files from data/ creates duplicate images in the result zip. if normalized_rel_path.startswith("data/"): _, ext = os.path.splitext(file_path) if ext.lower() in cls.DATA_IMAGE_EXTENSIONS: return False return True class AnalysisStatusView(_AnalysisViewMixin, APIView): def get(self, request, project_id): try: project = self._get_project(request, project_id) except Project.DoesNotExist: return Response( {"error": {"code": "project_not_found", "message": "项目不存在"}}, status=status.HTTP_404_NOT_FOUND, ) task = AnalysisTask.objects.filter(project=project).order_by("-created_at").first() if not task: return Response({"status": "not_started"}, status=status.HTTP_200_OK) serializer = AnalysisTaskSerializer(task) return Response(serializer.data, status=status.HTTP_200_OK) class AnalysisTaskView(_AnalysisViewMixin, APIView): parser_classes = [JSONParser, MultiPartParser, FormParser] def post(self, request, project_id): try: project = self._get_project(request, project_id) except Project.DoesNotExist: return Response( {"error": {"code": "project_not_found", "message": "项目不存在"}}, status=status.HTTP_404_NOT_FOUND, ) ramex_data_path = self._get_ramex_data_path(request.user.id, project_id) if not project.active_data_file or not os.path.exists(ramex_data_path): return Response( {"error": {"code": "invalid_status", "message": "项目当前没有可用的数据,无法执行分析"}}, status=status.HTTP_400_BAD_REQUEST, ) try: params = self._parse_params(request) except json.JSONDecodeError: return Response( {"error": {"code": "invalid_params", "message": "参数格式无效"}}, status=status.HTTP_400_BAD_REQUEST, ) params = self._save_optional_uploads(request, project_id, request.user.id, params) if not RExecutor.has_selected_work(params): return Response( {"error": {"code": "no_method_selected", "message": "请至少选择一种需要执行的算法或处理步骤"}}, status=status.HTTP_400_BAD_REQUEST, ) task = AnalysisTask.objects.create( project=project, analysis_type=RExecutor.infer_analysis_type(params), parameters=params, status="processing", ) project.status = "analysis" project.save() user_id = str(request.user.id) results_dir = self._get_results_dir(user_id, project_id) os.makedirs(results_dir, exist_ok=True) with open(os.path.join(results_dir, "analysis_params.json"), "w", encoding="utf-8") as file_obj: json.dump(params, file_obj, ensure_ascii=False, indent=4) try: result = RExecutor.execute_analysis(project_id=str(project.id), params=params) if result.get("status") == "error": raise RuntimeError(result.get("message", "分析执行失败")) with open(os.path.join(results_dir, "analysis_results.json"), "w", encoding="utf-8") as file_obj: json.dump(result, file_obj, ensure_ascii=False, indent=4) task.status = "completed" task.result = result task.completed_at = timezone.now() task.save() project.status = "completed" project.save() return Response( {"status": "success", "message": "分析完成", "result": result}, status=status.HTTP_200_OK, ) except Exception as exc: task.status = "error" task.result = {"error": str(exc)} task.completed_at = timezone.now() task.save() project.status = "error" project.save() return Response( {"status": "error", "error": {"message": str(exc)}}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class AnalysisResultsView(_AnalysisViewMixin, APIView): def get(self, request, project_id): try: project = self._get_project(request, project_id) except Project.DoesNotExist: return Response( {"error": {"code": "project_not_found", "message": "项目不存在"}}, status=status.HTTP_404_NOT_FOUND, ) user_id = str(request.user.id) results_dir = self._get_results_dir(user_id, project_id) results_file = os.path.join(results_dir, "analysis_results.json") params_file = os.path.join(results_dir, "analysis_params.json") if os.path.exists(results_file): try: with open(results_file, "r", encoding="utf-8") as file_obj: saved_results = json.load(file_obj) if os.path.exists(params_file) and "parameters" not in saved_results: with open(params_file, "r", encoding="utf-8") as file_obj: saved_results["parameters"] = json.load(file_obj) return Response({"status": "success", "result": saved_results}, status=status.HTTP_200_OK) except Exception: pass task = ( AnalysisTask.objects.filter(project=project, status="completed") .order_by("-completed_at") .first() ) if task: result = task.result or self._build_empty_result() if isinstance(result, dict) and "parameters" not in result: result["parameters"] = task.parameters return Response({"status": "success", "result": result}, status=status.HTTP_200_OK) if AnalysisTask.objects.filter(project=project, status="processing").exists(): return Response( {"status": "processing", "message": "分析任务正在进行中"}, status=status.HTTP_200_OK, ) return Response( { "status": "success", "message": "没有找到已完成的分析结果", "result": self._build_empty_result(), }, status=status.HTTP_200_OK, ) class AnalysisExportView(_AnalysisViewMixin, APIView): def get(self, request, project_id): try: project = self._get_project(request, project_id) except Project.DoesNotExist: return Response( {"error": {"code": "project_not_found", "message": "项目不存在"}}, status=status.HTTP_404_NOT_FOUND, ) user_id = str(request.user.id) results_dir = self._get_results_dir(user_id, project_id) images_dir = self._get_images_dir(user_id, project_id) temp_dir = self._get_temp_dir(user_id, project_id) has_results = os.path.exists(results_dir) and any( os.path.isfile(os.path.join(results_dir, name)) for name in os.listdir(results_dir) ) has_images = os.path.exists(images_dir) and any( os.path.isfile(os.path.join(root, name)) for root, _, files in os.walk(images_dir) for name in files ) if not has_results and not has_images: return Response( {"error": {"code": "no_results", "message": "没有找到可导出的分析结果"}}, status=status.HTTP_404_NOT_FOUND, ) os.makedirs(temp_dir, exist_ok=True) zip_filename = f"analysis_results_{project.name}_{timezone.now().strftime('%Y%m%d%H%M%S')}.zip" zip_filepath = os.path.join(temp_dir, zip_filename) with zipfile.ZipFile(zip_filepath, "w", zipfile.ZIP_DEFLATED) as zip_file: project_media_dir = self._get_project_media_dir(user_id, project_id) for root, _, files in os.walk(project_media_dir): for file_name in files: file_path = os.path.join(root, file_name) if not self._should_export_file(project_media_dir, file_path, temp_dir): continue arcname = os.path.relpath(file_path, project_media_dir) zip_file.write(file_path, arcname) if os.path.exists(zip_filepath): response = FileResponse(open(zip_filepath, "rb"), as_attachment=True, filename=zip_filename) response["Content-Type"] = "application/zip" response["X-Accel-Redirect"] = zip_filepath return response raise Http404("无法创建导出文件")