Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-04-25 08:29:11

0001 """
0002 PCS REST API ViewSets.
0003 
0004 Endpoints under /pcs/api/. All endpoints require authentication.
0005 Tag immutability enforced: PATCH returns 400 on locked tags. Lock is one-way via POST /lock/.
0006 Tag delete via POST /delete/ — creator-only, draft-only (locked tags protected by PROTECT FK).
0007 Tag numbers auto-assigned on POST: physics from category range, e/s/r from PersistentState.
0008 Dataset creation requires all four tags to be locked. created_by set from authenticated user.
0009 """
0010 from rest_framework import viewsets, status
0011 from rest_framework.authentication import SessionAuthentication, TokenAuthentication
0012 from monitor_app.middleware import TunnelAuthentication
0013 from rest_framework.decorators import action
0014 from rest_framework.permissions import IsAuthenticatedOrReadOnly, SAFE_METHODS, BasePermission
0015 
0016 
0017 class IsOwnerOrReadOnly(BasePermission):
0018     """Read open to anyone; write requires authenticated owner (by created_by username)."""
0019     def has_permission(self, request, view):
0020         if request.method in SAFE_METHODS:
0021             return True
0022         return bool(request.user and request.user.is_authenticated)
0023 
0024     def has_object_permission(self, request, view, obj):
0025         if request.method in SAFE_METHODS:
0026             return True
0027         return getattr(obj, 'created_by', None) == request.user.username
0028 from rest_framework.response import Response
0029 from django.db.models import Count
0030 
0031 from .models import (
0032     PhysicsCategory, PhysicsTag, EvgenTag, SimuTag, RecoTag,
0033     Dataset, ProdConfig, ProdTask,
0034 )
0035 from .serializers import (
0036     PhysicsCategorySerializer, PhysicsTagSerializer,
0037     EvgenTagSerializer, SimuTagSerializer, RecoTagSerializer,
0038     DatasetSerializer, ProdConfigSerializer, ProdTaskSerializer,
0039 )
0040 from .schemas import validate_parameters, get_tag_model
0041 
0042 
0043 class PhysicsCategoryViewSet(viewsets.ModelViewSet):
0044     """CRUD for physics categories. Categories are mutable (no lock lifecycle)."""
0045     queryset = PhysicsCategory.objects.annotate(tag_count=Count('tags'))
0046     serializer_class = PhysicsCategorySerializer
0047     authentication_classes = [TunnelAuthentication, SessionAuthentication, TokenAuthentication]
0048     permission_classes = [IsAuthenticatedOrReadOnly]
0049     http_method_names = ['get', 'post', 'patch', 'head', 'options']
0050 
0051     def perform_create(self, serializer):
0052         serializer.save(created_by=self.request.user.username)
0053 
0054 
0055 class _TagViewSetMixin:
0056     """Shared behavior for all tag ViewSets: draft/locked lifecycle, PATCH guard, lock/delete actions."""
0057     authentication_classes = [TunnelAuthentication, SessionAuthentication, TokenAuthentication]
0058     permission_classes = [IsAuthenticatedOrReadOnly]
0059     http_method_names = ['get', 'post', 'patch', 'head', 'options']
0060     lookup_field = 'tag_number'
0061 
0062     def partial_update(self, request, *args, **kwargs):
0063         instance = self.get_object()
0064         if instance.status == 'locked':
0065             return Response(
0066                 {'detail': f'Tag {instance.tag_label} is locked and cannot be modified.'},
0067                 status=status.HTTP_400_BAD_REQUEST,
0068             )
0069         if 'status' in request.data:
0070             return Response(
0071                 {'detail': 'Use the /lock/ endpoint to change status.'},
0072                 status=status.HTTP_400_BAD_REQUEST,
0073             )
0074         return super().partial_update(request, *args, **kwargs)
0075 
0076     @action(detail=True, methods=['post'])
0077     def lock(self, request, **kwargs):
0078         instance = self.get_object()
0079         if instance.status == 'locked':
0080             return Response(
0081                 {'detail': f'Tag {instance.tag_label} is already locked.'},
0082                 status=status.HTTP_400_BAD_REQUEST,
0083             )
0084         instance.status = 'locked'
0085         instance.save(update_fields=['status', 'updated_at'])
0086         return Response(self.get_serializer(instance).data)
0087 
0088     @action(detail=True, methods=['post'], url_path='delete')
0089     def soft_delete(self, request, **kwargs):
0090         instance = self.get_object()
0091         if instance.status == 'locked':
0092             return Response(
0093                 {'detail': f'Tag {instance.tag_label} is locked and cannot be deleted.'},
0094                 status=status.HTTP_400_BAD_REQUEST,
0095             )
0096         if instance.created_by != request.user.username:
0097             return Response(
0098                 {'detail': f'Only the creator ({instance.created_by}) can delete this tag.'},
0099                 status=status.HTTP_403_FORBIDDEN,
0100             )
0101         label = instance.tag_label
0102         instance.delete()
0103         return Response({'detail': f'Tag {label} deleted.'})
0104 
0105 
0106 class PhysicsTagViewSet(_TagViewSetMixin, viewsets.ModelViewSet):
0107     queryset = PhysicsTag.objects.select_related('category')
0108     serializer_class = PhysicsTagSerializer
0109 
0110     def create(self, request, *args, **kwargs):
0111         category_digit = request.data.get('category')
0112         if not category_digit:
0113             return Response(
0114                 {'category': ['This field is required.']},
0115                 status=status.HTTP_400_BAD_REQUEST,
0116             )
0117         try:
0118             category = PhysicsCategory.objects.get(digit=category_digit)
0119         except PhysicsCategory.DoesNotExist:
0120             return Response(
0121                 {'category': [f'Category {category_digit} does not exist.']},
0122                 status=status.HTTP_400_BAD_REQUEST,
0123             )
0124         tag_number = PhysicsTag.allocate_next(category)
0125         data = request.data.copy()
0126         data['tag_number'] = tag_number
0127         serializer = self.get_serializer(data=data)
0128         serializer.is_valid(raise_exception=True)
0129         serializer.save(tag_number=tag_number, tag_label=f"p{tag_number}",
0130                         created_by=request.user.username)
0131         return Response(serializer.data, status=status.HTTP_201_CREATED)
0132 
0133 
0134 class _SimpleTagViewSet(_TagViewSetMixin, viewsets.ModelViewSet):
0135     tag_type = None
0136 
0137     def create(self, request, *args, **kwargs):
0138         model = get_tag_model(self.tag_type)
0139         tag_number = model.allocate_next()
0140         prefix = self.tag_type
0141         data = request.data.copy()
0142         data['tag_number'] = tag_number
0143         serializer = self.get_serializer(data=data)
0144         serializer.is_valid(raise_exception=True)
0145         serializer.save(tag_number=tag_number, tag_label=f"{prefix}{tag_number}",
0146                         created_by=request.user.username)
0147         return Response(serializer.data, status=status.HTTP_201_CREATED)
0148 
0149 
0150 class EvgenTagViewSet(_SimpleTagViewSet):
0151     queryset = EvgenTag.objects.all()
0152     serializer_class = EvgenTagSerializer
0153     tag_type = 'e'
0154 
0155 
0156 class SimuTagViewSet(_SimpleTagViewSet):
0157     queryset = SimuTag.objects.all()
0158     serializer_class = SimuTagSerializer
0159     tag_type = 's'
0160 
0161 
0162 class RecoTagViewSet(_SimpleTagViewSet):
0163     queryset = RecoTag.objects.all()
0164     serializer_class = RecoTagSerializer
0165     tag_type = 'r'
0166 
0167 
0168 class DatasetViewSet(viewsets.ModelViewSet):
0169     """Dataset CRUD. POST validates all tags are locked and creates block 1. No DELETE."""
0170     queryset = Dataset.objects.select_related(
0171         'physics_tag', 'evgen_tag', 'simu_tag', 'reco_tag'
0172     )
0173     serializer_class = DatasetSerializer
0174     authentication_classes = [TunnelAuthentication, SessionAuthentication, TokenAuthentication]
0175     permission_classes = [IsAuthenticatedOrReadOnly]
0176     http_method_names = ['get', 'post', 'head', 'options']
0177 
0178     def create(self, request, *args, **kwargs):
0179         serializer = self.get_serializer(data=request.data)
0180         serializer.is_valid(raise_exception=True)
0181         # Validate all tags are locked
0182         for field in ['physics_tag', 'evgen_tag', 'simu_tag', 'reco_tag']:
0183             tag = serializer.validated_data[field]
0184             if tag.status != 'locked':
0185                 return Response(
0186                     {field: [f'Tag {tag.tag_label} must be locked before use in a dataset.']},
0187                     status=status.HTTP_400_BAD_REQUEST,
0188                 )
0189         instance = serializer.save(created_by=request.user.username)
0190         return Response(self.get_serializer(instance).data, status=status.HTTP_201_CREATED)
0191 
0192     @action(detail=True, methods=['post'], url_path='add-block')
0193     def add_block(self, request, pk=None):
0194         dataset = self.get_object()
0195         new_block_num = dataset.blocks + 1
0196         # Update blocks count on all rows with this dataset_name
0197         Dataset.objects.filter(dataset_name=dataset.dataset_name).update(blocks=new_block_num)
0198         # Create the new block
0199         new_block = Dataset.objects.create(
0200             dataset_name=dataset.dataset_name,
0201             scope=dataset.scope,
0202             detector_version=dataset.detector_version,
0203             detector_config=dataset.detector_config,
0204             physics_tag=dataset.physics_tag,
0205             evgen_tag=dataset.evgen_tag,
0206             simu_tag=dataset.simu_tag,
0207             reco_tag=dataset.reco_tag,
0208             block_num=new_block_num,
0209             blocks=new_block_num,
0210             did=f"{dataset.scope}:{dataset.dataset_name}.b{new_block_num}",
0211             description=dataset.description,
0212             metadata=dataset.metadata,
0213             created_by=request.user.username,
0214         )
0215         return Response(self.get_serializer(new_block).data, status=status.HTTP_201_CREATED)
0216 
0217 
0218 class ProdConfigViewSet(viewsets.ModelViewSet):
0219     """Production configuration templates. Owner-only edit; anyone can create."""
0220     queryset = ProdConfig.objects.all()
0221     serializer_class = ProdConfigSerializer
0222     authentication_classes = [TunnelAuthentication, SessionAuthentication, TokenAuthentication]
0223     permission_classes = [IsOwnerOrReadOnly]
0224 
0225     def perform_create(self, serializer):
0226         serializer.save(created_by=self.request.user.username)
0227 
0228 
0229 class ProdTaskViewSet(viewsets.ModelViewSet):
0230     """Production tasks: Dataset + ProdConfig compositions with command generation."""
0231     queryset = ProdTask.objects.select_related(
0232         'dataset', 'dataset__physics_tag', 'dataset__evgen_tag',
0233         'dataset__simu_tag', 'dataset__reco_tag', 'prod_config',
0234     )
0235     serializer_class = ProdTaskSerializer
0236     authentication_classes = [TunnelAuthentication, SessionAuthentication, TokenAuthentication]
0237     permission_classes = [IsOwnerOrReadOnly]
0238 
0239     def perform_create(self, serializer):
0240         serializer.save(created_by=self.request.user.username)
0241 
0242     @action(detail=True, methods=['post'], url_path='generate-commands')
0243     def generate_commands(self, request, pk=None):
0244         task = self.get_object()
0245         task.generate_commands()
0246         task.save(update_fields=['condor_command', 'panda_command', 'updated_at'])
0247         return Response({
0248             'condor_command': task.condor_command,
0249             'panda_command': task.panda_command,
0250         })
0251 
0252     @action(detail=False, methods=['get'], url_path='command')
0253     def command(self, request):
0254         """
0255         Regenerate and return a task's submission artifact in one of three
0256         formats. Lookup by task name. No DB writes.
0257 
0258         Query params:
0259             name — ProdTask.name (required)
0260             fmt  — condor | panda | jedi | dump (required). Named 'fmt'
0261                    (not 'format') because DRF reserves 'format' for
0262                    content negotiation.
0263 
0264         Returns:
0265             text/plain for condor/panda, application/json for jedi/dump.
0266         """
0267         from django.http import HttpResponse, JsonResponse
0268         from .commands import (
0269             build_condor_command, build_panda_command,
0270             build_task_params, build_task_dump,
0271         )
0272 
0273         name = request.query_params.get('name')
0274         fmt = request.query_params.get('fmt', '').lower()
0275         if not name:
0276             return Response({'detail': 'Missing ?name='}, status=status.HTTP_400_BAD_REQUEST)
0277         if fmt not in ('condor', 'panda', 'jedi', 'dump'):
0278             return Response(
0279                 {'detail': "fmt must be one of: condor, panda, jedi, dump"},
0280                 status=status.HTTP_400_BAD_REQUEST,
0281             )
0282         try:
0283             task = self.get_queryset().get(name=name)
0284         except ProdTask.DoesNotExist:
0285             return Response({'detail': f"No task named '{name}'"}, status=status.HTTP_404_NOT_FOUND)
0286 
0287         if fmt == 'condor':
0288             return HttpResponse(build_condor_command(task), content_type='text/plain')
0289         if fmt == 'panda':
0290             return HttpResponse(build_panda_command(task), content_type='text/plain')
0291         if fmt == 'jedi':
0292             return JsonResponse(build_task_params(task), json_dumps_params={'indent': 2})
0293         return JsonResponse(build_task_dump(task), json_dumps_params={'indent': 2})
0294 
0295     @action(detail=True, methods=['post'], url_path='set-status')
0296     def set_status(self, request, pk=None):
0297         task = self.get_object()
0298         new_status = request.data.get('status')
0299         valid = [c[0] for c in task._meta.get_field('status').choices]
0300         if new_status not in valid:
0301             return Response(
0302                 {'detail': f'Invalid status. Choose from: {", ".join(valid)}'},
0303                 status=status.HTTP_400_BAD_REQUEST,
0304             )
0305         task.status = new_status
0306         task.save(update_fields=['status', 'updated_at'])
0307         return Response(self.get_serializer(task).data)