• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

observatorycontrolsystem / observation-portal / 21305899177

24 Jan 2026 12:35AM UTC coverage: 96.593% (+0.02%) from 96.577%
21305899177

Pull #352

github

Jon
Added suspend_until field to Requst and serializer and used it to filter out schedulable requests
Pull Request #352: Added suspend_until field to Requst and serializer and used it to fil…

174 of 176 new or added lines in 5 files covered. (98.86%)

52 existing lines in 3 files now uncovered.

36122 of 37396 relevant lines covered (96.59%)

2.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.06
/observation_portal/requestgroups/viewsets.py
1
import logging
2✔
2

3
from rest_framework import viewsets, filters
3✔
4
from rest_framework.decorators import action
3✔
5
from rest_framework.exceptions import MethodNotAllowed
3✔
6
from rest_framework.response import Response
3✔
7
from rest_framework.permissions import IsAuthenticatedOrReadOnly, IsAdminUser, IsAuthenticated
3✔
8
from rest_framework import status
3✔
9
from django.utils import timezone
3✔
10
from django.db.models import Prefetch
3✔
11
from django_filters.rest_framework import DjangoFilterBackend
3✔
12
from dateutil.parser import parse
3✔
13
from django.contrib.auth.models import User
3✔
14
from django.utils.module_loading import import_string
3✔
15
from django.conf import settings
3✔
16

17
from observation_portal.proposals.models import Proposal, Semester, TimeAllocation
3✔
18
from observation_portal.requestgroups.models import (RequestGroup, Request, DraftRequestGroup, InstrumentConfig,
3✔
19
                                                     Configuration)
20
from observation_portal.requestgroups.filters import RequestGroupFilter, RequestFilter
3✔
21
from observation_portal.requestgroups.serializers import RequestUpdateSerializer
3✔
22
from observation_portal.requestgroups.cadence import expand_cadence_request
3✔
23
from observation_portal.requestgroups.pattern_expansion import expand_dither_pattern, expand_mosaic_pattern
3✔
24
from observation_portal.requestgroups.duration_utils import (
3✔
25
    get_request_duration_dict, get_max_ipp_for_requestgroup
26
)
27
from observation_portal.common.state_changes import InvalidStateChange, TERMINAL_REQUEST_STATES
3✔
28
from observation_portal.requestgroups.request_utils import (
3✔
29
    get_airmasses_for_request_at_sites, get_telescope_states_for_request
30
)
31
from observation_portal.common.mixins import ListAsDictMixin
3✔
32
from observation_portal.common.schema import ObservationPortalSchema
3✔
33
from observation_portal.common.doc_examples import EXAMPLE_RESPONSES, QUERY_PARAMETERS
3✔
34

35
logger = logging.getLogger(__name__)
3✔
36

37

38
class RequestGroupViewSet(ListAsDictMixin, viewsets.ModelViewSet):
3✔
39
    permission_classes = (IsAuthenticatedOrReadOnly,)
3✔
40
    http_method_names = ['get', 'post', 'head', 'options']
3✔
41
    schema = ObservationPortalSchema(tags=['RequestGroups'])
3✔
42
    serializer_class = import_string(settings.SERIALIZERS['requestgroups']['RequestGroup'])
3✔
43
    filterset_class = RequestGroupFilter
3✔
44
    filter_backends = (
3✔
45
        filters.OrderingFilter,
46
        DjangoFilterBackend
47
    )
48
    ordering = ('-id',)
3✔
49

50
    def get_throttles(self):
3✔
51
        actions_to_throttle = ['cancel', 'validate', 'create']
3✔
52
        if self.action in actions_to_throttle:
3✔
53
            self.throttle_scope = 'requestgroups.' + self.action
3✔
54
        return super().get_throttles()
3✔
55

56
    def get_queryset(self):
3✔
57
        if self.request.user.is_authenticated:
3✔
58
            if self.request.user.profile.staff_view and self.request.user.is_staff:
3✔
59
                qs = RequestGroup.objects.all()
3✔
60
            else:
61
                qs = RequestGroup.objects.filter(proposal__in=self.request.user.proposal_set.all())
3✔
62
                if self.request.user.profile.view_authored_requests_only:
3✔
63
                    qs = qs.filter(submitter=self.request.user)
3✔
64
        else:
65
            qs = RequestGroup.objects.filter(proposal__in=Proposal.objects.filter(public=True))
3✔
66
        return qs.prefetch_related(
3✔
67
            'requests', 'requests__windows', 'requests__configurations', 'requests__location',
68
            'requests__configurations__instrument_configs', 'requests__configurations__target',
69
            'requests__configurations__acquisition_config', 'submitter', 'proposal',
70
            'requests__configurations__guiding_config', 'requests__configurations__constraints',
71
            'requests__configurations__instrument_configs__rois'
72
        ).distinct()
73

74
    def perform_create(self, serializer):
3✔
75
        serializer.save(submitter=self.request.user)
3✔
76

77
    @action(detail=False, methods=['get'], permission_classes=(IsAdminUser,))
3✔
78
    def schedulable_requests(self, request):
3✔
79
        """
80
            Gets the set of schedulable User requests for the scheduler.
81
            Needs a start and end time specified as the range of time to get requests in. Usually this is the entire
82
            semester for a scheduling run.
83
        """
84
        current_semester = Semester.current_semesters().first()
3✔
85
        start = parse(request.query_params.get('start', str(current_semester.start))).replace(tzinfo=timezone.utc)
3✔
86
        end = parse(request.query_params.get('end', str(current_semester.end))).replace(tzinfo=timezone.utc)
3✔
87
        telescope_classes = request.query_params.getlist('telescope_class')
3✔
88
        # Schedulable requests are not in a terminal state, are part of an active proposal,
89
        # and have a window within this semester
90
        instrument_config_query = InstrumentConfig.objects.prefetch_related('rois')
3✔
91
        configuration_query = Configuration.objects.select_related(
3✔
92
            'constraints', 'target', 'acquisition_config', 'guiding_config').prefetch_related(
93
            Prefetch('instrument_configs', queryset=instrument_config_query)
94
        )
95
        request_query = Request.objects.exclude(suspend_until__gt=timezone.now()).select_related('location').prefetch_related(
3✔
96
            'windows', Prefetch('configurations', queryset=configuration_query)
97
        )
98
        queryset = RequestGroup.objects.exclude(
3✔
99
            state__in=TERMINAL_REQUEST_STATES
100
        ).exclude(
101
            observation_type__in=RequestGroup.NON_SCHEDULED_TYPES
102
        ).filter(
103
            requests__windows__start__lte=end,
104
            requests__windows__start__gte=start,
105
            proposal__active=True
106
        ).prefetch_related(
107
            Prefetch('requests', queryset=request_query),
108
            Prefetch('proposal', queryset=Proposal.objects.only('id').all()),
109
            Prefetch('submitter', queryset=User.objects.only('username', 'is_staff').all())
110
        ).distinct()
111
        if telescope_classes:
3✔
112
            queryset = queryset.filter(requests__location__telescope_class__in=telescope_classes)
3✔
113

114
        # queryset now contains all the schedulable URs and their associated requests and data
115
        # Check that each request time available in its proposal still
116
        request_group_data = []
3✔
117
        tas = {}
3✔
118
        for request_group in queryset.all():
3✔
119
            total_duration_dict = request_group.total_duration
3✔
120
            for tak, duration in total_duration_dict.items():
3✔
121
                if (tak, request_group.proposal.id) in tas:
3✔
122
                    time_allocation = tas[(tak, request_group.proposal.id)]
3✔
123
                else:
124
                    time_allocation = TimeAllocation.objects.get(
3✔
125
                        semester=tak.semester,
126
                        instrument_types__contains=[tak.instrument_type],
127
                        proposal=request_group.proposal.id,
128
                    )
129
                    tas[(tak, request_group.proposal.id)] = time_allocation
3✔
130
                if request_group.observation_type == RequestGroup.NORMAL:
3✔
131
                    time_left = time_allocation.std_allocation - time_allocation.std_time_used
3✔
132
                elif request_group.observation_type == RequestGroup.RAPID_RESPONSE:
×
133
                    time_left = time_allocation.rr_allocation - time_allocation.rr_time_used
×
UNCOV
134
                elif request_group.observation_type == RequestGroup.TIME_CRITICAL:
×
135
                    time_left = time_allocation.tc_allocation - time_allocation.tc_time_used
×
136
                else:
UNCOV
137
                    logger.critical('request_group {} observation_type {} is not allowed'.format(
×
138
                        request_group.id,
139
                        request_group.observation_type)
140
                    )
UNCOV
141
                    continue
×
142
                if time_left * settings.PROPOSAL_TIME_OVERUSE_ALLOWANCE >= (duration / 3600.0):
3✔
143
                    request_group_dict = request_group.as_dict()
3✔
144
                    request_group_dict['is_staff'] = request_group.submitter.is_staff
3✔
145
                    request_group_data.append(request_group_dict)
3✔
146
                    break
3✔
147
                else:
UNCOV
148
                    logger.warning(
×
149
                        'not enough time left {0} in proposal {1} for ur {2} of duration {3}, skipping'.format(
150
                            time_left, request_group.proposal.id, request_group.id, (duration / 3600.0)
151
                        )
152
                    )
153
        return Response(request_group_data)
3✔
154

155
    @action(detail=True, methods=['post'])
3✔
156
    def cancel(self, request, pk=None):
3✔
157
        """ Cancel a RequestGroup
158
        """
159
        request_group = self.get_object()
3✔
160
        try:
3✔
161
            request_group.state = 'CANCELED'
3✔
162
            request_group.save()
3✔
163
        except InvalidStateChange as exc:
3✔
164
            return Response({'errors': [str(exc)]}, status=status.HTTP_400_BAD_REQUEST)
3✔
165
        return Response(import_string(settings.SERIALIZERS['requestgroups']['RequestGroup'])(request_group).data)
3✔
166

167
    @action(detail=False, methods=['post'])
3✔
168
    def validate(self, request):
3✔
169
        """ Validate a RequestGrouo
170
        """
171
        serializer = import_string(settings.SERIALIZERS['requestgroups']['RequestGroup'])(data=request.data, context={'request': request})
3✔
172
        req_durations = {}
3✔
173
        if serializer.is_valid():
3✔
174
            req_durations = get_request_duration_dict(serializer.validated_data['requests'], request.user.is_staff)
3✔
175
            errors = {}
3✔
176
        else:
177
            errors = serializer.errors
3✔
178

179
        return Response({'request_durations': req_durations,
3✔
180
                         'errors': errors})
181

182
    @action(detail=False, methods=['post'])
3✔
183
    def max_allowable_ipp(self, request):
3✔
184
        """ Get the maximum allowable IPP for a RequestGroup
185
        """
186
        # change requested ipp to 1 because we want it to always pass the serializers ipp check
187
        request.data['ipp_value'] = 1.0
3✔
188
        serializer = import_string(settings.SERIALIZERS['requestgroups']['RequestGroup'])(data=request.data, context={'request': request})
3✔
189
        if serializer.is_valid():
3✔
190
            ipp_dict = get_max_ipp_for_requestgroup(serializer.validated_data)
3✔
191
            return Response(ipp_dict)
3✔
192
        else:
193
            return Response({'errors': serializer.errors})
3✔
194

195
    @action(detail=False, methods=['post'])
3✔
196
    def cadence(self, request):
3✔
197
        """ Given a well-formed RequestGroup containing a single Request, return a new RequestGroup containing many requests
198
        generated by the cadence function.
199
        """
200
        request_serializer = self.get_request_serializer(data=request.data, context={'request': request})
3✔
201
        expanded_requests = []
3✔
202
        if request_serializer.is_valid():
3✔
203
            cadence_request = request_serializer.validated_data.get('requests')[0]
3✔
204
            if isinstance(cadence_request, dict) and cadence_request.get('cadence'):
3✔
205
                cadence_request_serializer = import_string(settings.SERIALIZERS['requestgroups']['CadenceRequest'])(data=cadence_request)
3✔
206
                if cadence_request_serializer.is_valid():
3✔
207
                    expanded_requests.extend(expand_cadence_request(cadence_request_serializer.validated_data,
3✔
208
                                                                    request.user.is_staff))
209
                else:
UNCOV
210
                    return Response(cadence_request_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
×
211

212
            # if we couldn't find any valid cadence requests, return that as an error
213
            if not expanded_requests:
3✔
214
                return Response({'errors': 'No visible requests within cadence window parameters'}, status=status.HTTP_400_BAD_REQUEST)
3✔
215

216
            # now replace the originally sent requests with the cadence requests and send it back
217
            ret_data = request.data.copy()
3✔
218
            ret_data['requests'] = expanded_requests
3✔
219

220
            if len(ret_data['requests']) > 1:
3✔
221
                ret_data['operator'] = 'MANY'
3✔
222
            response_serializer = self.get_response_serializer(data=ret_data, context={'request': request})
3✔
223
            if not response_serializer.is_valid():
3✔
UNCOV
224
                return Response(response_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
×
225
            return Response(ret_data, status=status.HTTP_200_OK)
3✔
226
        else:
227
            return(Response(request_serializer.errors, status=status.HTTP_400_BAD_REQUEST))
3✔
228

229
    def get_example_response(self):
3✔
UNCOV
230
        example_data = {'max_allowable_ipp': Response(data=EXAMPLE_RESPONSES['requestgroups']['max_allowable_ipp'], status=status.HTTP_200_OK),
×
231
                        'schedulable_requests': Response(data=EXAMPLE_RESPONSES['requestgroups']['schedulable_requests'], status=status.HTTP_200_OK)}
232

UNCOV
233
        return example_data.get(self.action)
×
234

235
    def get_endpoint_name(self):
3✔
UNCOV
236
        endpoint_names = {'max_allowable_ipp': 'getMaxAllowableIPP',
×
237
                          'cadence': 'generateCadence',
238
                          'schedulable_requests': 'listSchedulableRequests'}
239

UNCOV
240
        return endpoint_names.get(self.action)
×
241

242
    def get_request_serializer(self, *args, **kwargs):
3✔
243
        serializers = {'cadence': import_string(settings.SERIALIZERS['requestgroups']['CadenceRequestGroup'])}
3✔
244

245
        return serializers.get(self.action, self.serializer_class)(*args, **kwargs)
3✔
246

247
    def get_response_serializer(self, *args, **kwargs):
3✔
248
        serializers = {'cadence': import_string(settings.SERIALIZERS['requestgroups']['RequestGroup'])}
3✔
249

250
        return serializers.get(self.action, self.serializer_class)(*args, **kwargs)
3✔
251

252

253
class RequestViewSet(ListAsDictMixin, viewsets.ModelViewSet):
3✔
254
    permission_classes = (IsAuthenticatedOrReadOnly,)
3✔
255
    http_method_names = ['get', 'options', 'post', 'patch']
3✔
256
    schema = ObservationPortalSchema(tags=['Requests'])
3✔
257
    serializer_class = import_string(settings.SERIALIZERS['requestgroups']['Request'])
3✔
258
    filterset_class = RequestFilter
3✔
259
    filter_backends = (
3✔
260
        filters.OrderingFilter,
261
        DjangoFilterBackend
262
    )
263
    ordering = ('-id',)
3✔
264
    ordering_fields = ('id', 'state')
3✔
265
    undocumented_actions = ['telescope_states']
3✔
266

267
    def get_permissions(self):
3✔
268
        if self.request.method == 'PATCH':
3✔
269
            return [IsAdminUser()]
3✔
270
        else:
271
            return [IsAuthenticatedOrReadOnly()]
3✔
272

273
    def get_serializer_class(self):
3✔
274
        if self.action == 'partial_update':
3✔
275
            return RequestUpdateSerializer
3✔
276
        return super().get_serializer_class()
3✔
277

278
    def get_queryset(self):
3✔
279
        if self.request.user.is_authenticated:
3✔
280
            if self.request.user.profile.staff_view and self.request.user.is_staff:
3✔
281
                qs = Request.objects.all()
3✔
282
            else:
283
                qs = Request.objects.filter(request_group__proposal__in=self.request.user.proposal_set.all())
3✔
284
                if self.request.user.profile.view_authored_requests_only:
3✔
285
                    qs = qs.filter(request_group__submitter=self.request.user)
3✔
286
        else:
287
            qs = Request.objects.filter(request_group__proposal__in=Proposal.objects.filter(public=True))
3✔
288
        return qs.prefetch_related(
3✔
289
            'windows', 'configurations', 'location', 'configurations__instrument_configs', 'configurations__target',
290
            'configurations__acquisition_config', 'configurations__guiding_config', 'configurations__constraints',
291
            'configurations__instrument_configs__rois'
292
        ).distinct()
293

294
    def create(self, request, *args, **kwargs):
3✔
295
        """
296
        Block the default create action (POST) to the main endpoint.
297
        """
UNCOV
298
        raise MethodNotAllowed('POST')
×
299

300
    @action(detail=True)
3✔
301
    def airmass(self, request, pk=None):
3✔
UNCOV
302
        return Response(get_airmasses_for_request_at_sites(self.get_object().as_dict(), is_staff=request.user.is_staff))
×
303

304
    @action(detail=True)
3✔
305
    def telescope_states(self, request, pk=None):
3✔
UNCOV
306
        telescope_states = get_telescope_states_for_request(self.get_object().as_dict(), is_staff=request.user.is_staff)
×
UNCOV
307
        str_telescope_states = {str(k): v for k, v in telescope_states.items()}
×
UNCOV
308
        return Response(str_telescope_states)
×
309

310
    @action(detail=True)
3✔
311
    def observations(self, request, pk=None):
3✔
UNCOV
312
        observations = self.get_object().observation_set.order_by('id').all()
×
UNCOV
313
        if request.GET.get('exclude_canceled'):
×
314
            return Response([o.as_dict(no_request=True) for o in observations if o.state != 'CANCELED'])
×
UNCOV
315
        return Response([o.as_dict(no_request=True) for o in observations])
×
316

317
    @action(detail=False, methods=['post'])
3✔
318
    def mosaic(self, request):
3✔
319
        # Check that the mosaic parameters specified are valid
320
        mosaic_serializer = import_string(settings.SERIALIZERS['requestgroups']['Mosaic'])(data=request.data)
3✔
321
        if not mosaic_serializer.is_valid():
3✔
322
            return Response(mosaic_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
3✔
323

324
        # Expand the configurations within the request based on the mosaic pattern specified
325
        request_dict = expand_mosaic_pattern(mosaic_serializer.validated_data)
3✔
326
        return Response(request_dict)
3✔
327

328
    def get_request_serializer(self, *args, **kwargs):
3✔
329
        serializers = {'mosaic': import_string(settings.SERIALIZERS['requestgroups']['Mosaic'])}
×
330

UNCOV
331
        return serializers.get(self.action, self.serializer_class)(*args, **kwargs)
×
332

333
    def get_example_response(self):
3✔
UNCOV
334
        example_data = {'airmass': Response(data=EXAMPLE_RESPONSES['requests']['airmass'], status=status.HTTP_200_OK),
×
335
                        'observations': Response(data=EXAMPLE_RESPONSES['requests']['observations'], status=status.HTTP_200_OK)}
336

UNCOV
337
        return example_data.get(self.action)
×
338

339
    def get_query_parameters(self):
3✔
UNCOV
340
        query_parameters = {'observations': QUERY_PARAMETERS['requests']['observations']}
×
341

342
        return query_parameters.get(self.action)
×
343

344
    def get_endpoint_name(self):
3✔
UNCOV
345
        endpoint_names = {'mosaic': 'expandMosaic',
×
346
                          'observations': 'observationsForRequest',
347
                          'airmass': 'airmassForRequest'}
348

UNCOV
349
        return endpoint_names.get(self.action)
×
350

351

352
class DraftRequestGroupViewSet(viewsets.ModelViewSet):
3✔
353
    schema = ObservationPortalSchema(tags=['RequestGroups'])
3✔
354
    serializer_class = import_string(settings.SERIALIZERS['requestgroups']['DraftRequestGroup'])
3✔
355
    ordering = ('-modified',)
3✔
356

357
    def perform_create(self, serializer):
3✔
358
        serializer.save(author=self.request.user)
3✔
359

360
    def get_queryset(self):
3✔
361
        if self.request.user.is_staff and self.request.user.profile.staff_view:
3✔
UNCOV
362
            return DraftRequestGroup.objects.all()
×
363
        elif self.request.user.is_authenticated:
3✔
364
            return DraftRequestGroup.objects.filter(proposal__in=self.request.user.proposal_set.all())
3✔
365
        else:
UNCOV
366
            return DraftRequestGroup.objects.none()
×
367

368

369
class ConfigurationViewSet(viewsets.GenericViewSet):
3✔
370
    permission_classes = (IsAuthenticated,)
3✔
371
    schema = ObservationPortalSchema(tags=['RequestGroups'])
3✔
372
    serializer_class = import_string(settings.SERIALIZERS['requestgroups']['Dither'])
3✔
373

374
    @action(detail=False, methods=['post'])
3✔
375
    def dither(self, request):
3✔
376
        # Check that the dither parameters specified are valid
377
        request_serializer = self.get_request_serializer(data=request.data)
3✔
378
        if not request_serializer.is_valid():
3✔
379
            return Response(request_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
3✔
380

381
        # Expand the instrument_configs within the configuration based on the dither pattern specified
382
        configuration_dict = expand_dither_pattern(request_serializer.validated_data)
3✔
383

384
        return Response(configuration_dict)
3✔
385

386
    def get_request_serializer(self, *args, **kwargs):
3✔
387
        request_serializers = {'dither': import_string(settings.SERIALIZERS['requestgroups']['Dither'])}
3✔
388

389
        return request_serializers.get(self.action)(*args, **kwargs)
3✔
390

391
    def get_example_response(self):
3✔
UNCOV
392
        example_data = {'dither': Response(data=EXAMPLE_RESPONSES['requestgroups']['dither'], status=status.HTTP_200_OK)}
×
393

UNCOV
394
        return example_data.get(self.action)
×
395

396
    def get_endpoint_name(self):
3✔
UNCOV
397
        endpoint_names = {'dither': 'expandDitherPattern'}
×
398

UNCOV
399
        return endpoint_names.get(self.action)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc