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

observatorycontrolsystem / observation-portal / 21694896293

05 Feb 2026 01:14AM UTC coverage: 96.575% (-0.002%) from 96.577%
21694896293

push

github

web-flow
Merge pull request #352 from observatorycontrolsystem/feature/suspend_scheduling

Added suspend_until field to Requst and serializer and used it to fil…

182 of 184 new or added lines in 6 files covered. (98.91%)

59 existing lines in 6 files now uncovered.

36124 of 37405 relevant lines covered (96.58%)

1.93 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
1✔
2

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

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

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

37

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

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

56
    def get_queryset(self):
2✔
57
        if self.request.user.is_authenticated:
2✔
58
            if self.request.user.profile.staff_view and self.request.user.is_staff:
2✔
59
                qs = RequestGroup.objects.all()
2✔
60
            else:
61
                qs = RequestGroup.objects.filter(proposal__in=self.request.user.proposal_set.all())
2✔
62
                if self.request.user.profile.view_authored_requests_only:
2✔
63
                    qs = qs.filter(submitter=self.request.user)
2✔
64
        else:
65
            qs = RequestGroup.objects.filter(proposal__in=Proposal.objects.filter(public=True))
2✔
66
        return qs.prefetch_related(
2✔
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):
2✔
75
        serializer.save(submitter=self.request.user)
2✔
76

77
    @action(detail=False, methods=['get'], permission_classes=(IsAdminUser,))
2✔
78
    def schedulable_requests(self, request):
2✔
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()
2✔
85
        start = parse(request.query_params.get('start', str(current_semester.start))).replace(tzinfo=timezone.utc)
2✔
86
        end = parse(request.query_params.get('end', str(current_semester.end))).replace(tzinfo=timezone.utc)
2✔
87
        telescope_classes = request.query_params.getlist('telescope_class')
2✔
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')
2✔
91
        configuration_query = Configuration.objects.select_related(
2✔
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(
2✔
96
            'windows', Prefetch('configurations', queryset=configuration_query)
97
        )
98
        queryset = RequestGroup.objects.exclude(
2✔
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:
2✔
112
            queryset = queryset.filter(requests__location__telescope_class__in=telescope_classes)
2✔
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 = []
2✔
117
        tas = {}
2✔
118
        for request_group in queryset.all():
2✔
119
            total_duration_dict = request_group.total_duration
2✔
120
            for tak, duration in total_duration_dict.items():
2✔
121
                if (tak, request_group.proposal.id) in tas:
2✔
122
                    time_allocation = tas[(tak, request_group.proposal.id)]
2✔
123
                else:
124
                    time_allocation = TimeAllocation.objects.get(
2✔
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
2✔
130
                if request_group.observation_type == RequestGroup.NORMAL:
2✔
131
                    time_left = time_allocation.std_allocation - time_allocation.std_time_used
2✔
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):
2✔
143
                    request_group_dict = request_group.as_dict()
2✔
144
                    request_group_dict['is_staff'] = request_group.submitter.is_staff
2✔
145
                    request_group_data.append(request_group_dict)
2✔
146
                    break
2✔
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)
2✔
154

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

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

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

182
    @action(detail=False, methods=['post'])
2✔
183
    def max_allowable_ipp(self, request):
2✔
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
2✔
188
        serializer = import_string(settings.SERIALIZERS['requestgroups']['RequestGroup'])(data=request.data, context={'request': request})
2✔
189
        if serializer.is_valid():
2✔
190
            ipp_dict = get_max_ipp_for_requestgroup(serializer.validated_data)
2✔
191
            return Response(ipp_dict)
2✔
192
        else:
193
            return Response({'errors': serializer.errors})
2✔
194

195
    @action(detail=False, methods=['post'])
2✔
196
    def cadence(self, request):
2✔
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})
2✔
201
        expanded_requests = []
2✔
202
        if request_serializer.is_valid():
2✔
203
            cadence_request = request_serializer.validated_data.get('requests')[0]
2✔
204
            if isinstance(cadence_request, dict) and cadence_request.get('cadence'):
2✔
205
                cadence_request_serializer = import_string(settings.SERIALIZERS['requestgroups']['CadenceRequest'])(data=cadence_request)
2✔
206
                if cadence_request_serializer.is_valid():
2✔
207
                    expanded_requests.extend(expand_cadence_request(cadence_request_serializer.validated_data,
2✔
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:
2✔
214
                return Response({'errors': 'No visible requests within cadence window parameters'}, status=status.HTTP_400_BAD_REQUEST)
2✔
215

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

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

229
    def get_example_response(self):
2✔
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):
2✔
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):
2✔
243
        serializers = {'cadence': import_string(settings.SERIALIZERS['requestgroups']['CadenceRequestGroup'])}
2✔
244

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

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

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

252

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

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

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

278
    def get_queryset(self):
2✔
279
        if self.request.user.is_authenticated:
2✔
280
            if self.request.user.profile.staff_view and self.request.user.is_staff:
2✔
281
                qs = Request.objects.all()
2✔
282
            else:
283
                qs = Request.objects.filter(request_group__proposal__in=self.request.user.proposal_set.all())
2✔
284
                if self.request.user.profile.view_authored_requests_only:
2✔
285
                    qs = qs.filter(request_group__submitter=self.request.user)
2✔
286
        else:
287
            qs = Request.objects.filter(request_group__proposal__in=Proposal.objects.filter(public=True))
2✔
288
        return qs.prefetch_related(
2✔
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):
2✔
295
        """
296
        Block the default create action (POST) to the main endpoint.
297
        """
UNCOV
298
        raise MethodNotAllowed('POST')
×
299

300
    @action(detail=True)
2✔
301
    def airmass(self, request, pk=None):
2✔
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)
2✔
305
    def telescope_states(self, request, pk=None):
2✔
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)
2✔
311
    def observations(self, request, pk=None):
2✔
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'])
2✔
318
    def mosaic(self, request):
2✔
319
        # Check that the mosaic parameters specified are valid
320
        mosaic_serializer = import_string(settings.SERIALIZERS['requestgroups']['Mosaic'])(data=request.data)
2✔
321
        if not mosaic_serializer.is_valid():
2✔
322
            return Response(mosaic_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
2✔
323

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

328
    def get_request_serializer(self, *args, **kwargs):
2✔
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):
2✔
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):
2✔
UNCOV
340
        query_parameters = {'observations': QUERY_PARAMETERS['requests']['observations']}
×
341

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

344
    def get_endpoint_name(self):
2✔
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):
2✔
353
    schema = ObservationPortalSchema(tags=['RequestGroups'])
2✔
354
    serializer_class = import_string(settings.SERIALIZERS['requestgroups']['DraftRequestGroup'])
2✔
355
    ordering = ('-modified',)
2✔
356

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

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

368

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

374
    @action(detail=False, methods=['post'])
2✔
375
    def dither(self, request):
2✔
376
        # Check that the dither parameters specified are valid
377
        request_serializer = self.get_request_serializer(data=request.data)
2✔
378
        if not request_serializer.is_valid():
2✔
379
            return Response(request_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
2✔
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)
2✔
383

384
        return Response(configuration_dict)
2✔
385

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

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

391
    def get_example_response(self):
2✔
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):
2✔
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