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
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
0197 Dataset.objects.filter(dataset_name=dataset.dataset_name).update(blocks=new_block_num)
0198
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)