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

observatorycontrolsystem / observation-portal / 21305899195

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

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

95.27
/observation_portal/requestgroups/serializers.py
1
import json
2✔
2
import logging
3✔
3
from math import cos, sin, radians
3✔
4
from json import JSONDecodeError
3✔
5
from abc import ABC, abstractmethod
3✔
6

7
from cerberus import Validator
3✔
8
from rest_framework import serializers
3✔
9
from django.utils.translation import gettext as _
3✔
10
from django.core.exceptions import ObjectDoesNotExist, ValidationError
3✔
11
from django.core.cache import cache
3✔
12
from django.db import transaction
3✔
13
from django.utils import timezone
3✔
14
from django.utils.module_loading import import_string
3✔
15
from django.conf import settings
3✔
16
from django.core.validators import MinValueValidator, MaxValueValidator
3✔
17

18
from observation_portal.proposals.models import TimeAllocation, Membership
3✔
19
from observation_portal.requestgroups.models import (
3✔
20
    Request, Target, Window, RequestGroup, Location, Configuration, Constraints, InstrumentConfig,
21
    AcquisitionConfig, GuidingConfig, RegionOfInterest
22
)
23
from observation_portal.requestgroups.models import DraftRequestGroup
3✔
24
from observation_portal.common.state_changes import debit_ipp_time, TimeAllocationError, validate_ipp
3✔
25
from observation_portal.requestgroups.target_helpers import TARGET_TYPE_HELPER_MAP
3✔
26
from observation_portal.common.mixins import ExtraParamsFormatter
3✔
27
from observation_portal.common.configdb import configdb, ConfigDB
3✔
28
from observation_portal.common.utils import OCSValidator
3✔
29
from observation_portal.requestgroups.duration_utils import (
3✔
30
    get_total_request_duration, get_requestgroup_duration, get_total_duration_dict,
31
    get_instrument_configuration_duration, get_semester_in
32
)
33
from datetime import timedelta
3✔
34
from observation_portal.common.rise_set_utils import get_filtered_rise_set_intervals_by_site, get_largest_interval
3✔
35

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

38

39
class ValidationHelper(ABC):
3✔
40
    CONFIG_SECTIONS = ['guiding_config', 'acquisition_config', 'target', 'constraints']
3✔
41
    """Base class for validating documents"""
2✔
42
    @abstractmethod
3✔
43
    def __init__(self):
3✔
44
        pass
×
45

46
    @abstractmethod
3✔
47
    def validate(self, config_dict: dict) -> dict:
3✔
48
        pass
×
49

50
    def _validate_document(self, document: dict, validation_schema: dict) -> (OCSValidator, dict):
3✔
51
        """
52
        Perform validation on a document using Cerberus validation schema
53
        :param document: Document to be validated
54
        :param validation_schema: Cerberus validation schema
55
        :return: Tuple of validator and a validated document
56
        """
57
        validator = OCSValidator(validation_schema)
3✔
58
        validator.allow_unknown = True
3✔
59
        validated_config_dict = validator.validated(document) or document.copy()
3✔
60

61
        return validator, validated_config_dict
3✔
62

63
    def _cerberus_validation_error_to_str(self, validation_errors: dict) -> str:
3✔
64
        """
65
        Unpack and format Cerberus validation errors as a string
66
        :param validation_errors: Errors from validator (validator.errors)
67
        :return: String containing information about validation errors
68
        """
69
        error_str = ''
3✔
70
        for field, value in validation_errors.items():
3✔
71
            if (isinstance(value, list) and len(value) == 1 and isinstance(value[0], dict)):
3✔
72
                error_str += f'{field}{{{self._cerberus_validation_error_to_str(value[0])}}}'
3✔
73
            else:
74
                error_str += f'{field} error: {", ".join(value)}, '
3✔
75

76
        error_str = error_str.rstrip(', ')
3✔
77
        return error_str
3✔
78

79
    def _cerberus_to_serializer_validation_error(self, validation_errors: dict) -> dict:
3✔
80
        """
81
        Unpack and format Cerberus validation errors as a dict matching the DRF Serializer Validation error format
82
        :param validation_errors: Errors from the validator (validator.errors)
83
        :return: Dict containing DRF serializer validation error for the cerberus errors
84
        """
85
        # The two issues we have are with extra_params becoming a list, and instrument_configs not having their index work properly
86
        serializer_errors = {}
3✔
87
        if 'extra_params' in validation_errors:
3✔
88
            serializer_errors['extra_params'] = validation_errors['extra_params'][0]
×
89
        for section in self.CONFIG_SECTIONS:
3✔
90
            if section in validation_errors:
3✔
91
                error = validation_errors[section][0]
3✔
92
                if section not in serializer_errors:
3✔
93
                    serializer_errors[section] = {}
3✔
94
                serializer_errors[section].update(error)
3✔
95
                if 'extra_params' in error:
3✔
96
                    serializer_errors[section]['extra_params'] = error['extra_params'][0]
×
97
        if 'instrument_configs' in validation_errors:
3✔
98
            instrument_configs_errors = []
3✔
99
            last_instrument_config_with_error = max(validation_errors['instrument_configs'][0].keys())
3✔
100
            for i in range(0, last_instrument_config_with_error+1):
3✔
101
                if i in validation_errors['instrument_configs'][0]:
3✔
102
                    instrument_config_error = validation_errors['instrument_configs'][0][i][0].copy()
3✔
103
                    if 'extra_params' in instrument_config_error:
3✔
104
                        instrument_config_error['extra_params'] = instrument_config_error['extra_params'][0]
3✔
105
                    instrument_configs_errors.append(instrument_config_error)
3✔
106
                else:
107
                    instrument_configs_errors.append({})
×
108
            serializer_errors['instrument_configs'] = instrument_configs_errors
3✔
109
        return serializer_errors
3✔
110

111

112
class InstrumentTypeValidationHelper(ValidationHelper):
3✔
113
    """Class to validate config based on InstrumentType in ConfigDB"""
114
    def __init__(self, instrument_type: str):
3✔
115
        self.instrument_type = instrument_type
3✔
116

117
    def validate(self, config_dict: dict) -> dict:
3✔
118
        """
119
        Using the validation_schema within the instrument type, validate the configuration
120
        :param config_dict: Configuration dictionary
121
        :return: Validated configuration
122
        :raises: ValidationError if config is invalid
123
        """
124
        instrument_type_dict = configdb.get_instrument_type_by_code(self.instrument_type)
3✔
125
        validation_schema = instrument_type_dict.get('validation_schema', {})
3✔
126
        validator, validated_config_dict = self._validate_document(config_dict, validation_schema)
3✔
127
        if validator.errors:
3✔
128
            raise serializers.ValidationError(self._cerberus_to_serializer_validation_error(validator.errors))
3✔
129

130
        return validated_config_dict
3✔
131

132

133
class ModeValidationHelper(ValidationHelper):
3✔
134
    """Class used to validate GenericModes of different types defined in ConfigDB"""
135
    def __init__(self, mode_type: str, instrument_type: str, modes_group: dict, mode_key='mode', is_extra_param_mode=False):
3✔
136
        self._mode_type = mode_type.lower()
3✔
137
        self._instrument_type = instrument_type
3✔
138
        self._modes_group = modes_group
3✔
139
        self._mode_key = mode_key
3✔
140
        self.is_extra_param_mode = is_extra_param_mode
3✔
141

142
    def _get_mode_from_config_dict(self, config_dict: dict) -> str:
3✔
143
        if self.is_extra_param_mode:
3✔
144
            return config_dict.get('extra_params', {}).get(self._mode_key, '')
3✔
145
        return config_dict.get(self._mode_key, '')
3✔
146

147
    def _set_mode_in_config_dict(self, mode_value: str, config_dict: dict) -> dict:
3✔
148
        if self.is_extra_param_mode:
3✔
149
            if 'extra_params' not in config_dict:
3✔
150
                config_dict['extra_params'] = {}
×
151
            config_dict['extra_params'][self._mode_key] = mode_value
3✔
152
        else:
153
            config_dict[self._mode_key] = mode_value
3✔
154
        return config_dict
3✔
155

156
    def validate(self, config_dict) -> dict:
3✔
157
        """Validates the mode using its relevant configuration dict
158

159
        Returns a validated configuration dict with the mode filled in. If no mode is given in the input
160
        dict, a default mode will be filled in if availble. If the mode has a validation_schema, it will
161
        be used to validate the input dict. If any error is encountered during the process, A serializer
162
        ValidationError will be raised with the error.
163

164
        Args:
165
            config_dict (dict): A dictionary of input structure that this mode is a part of
166
        Returns:
167
            dict: A version of the input dictionary with defaults filled in based on the validation_schema
168
        Raises:
169
            serializers.ValidationError: If validation fails
170
        """
171
        mode_value = self._get_mode_from_config_dict(config_dict)
3✔
172
        if not mode_value:
3✔
173
            mode_value = self.get_default_mode()
3✔
174
            if not mode_value:
3✔
175
                return config_dict
×
176
        self.mode_exists_and_is_schedulable(mode_value)
3✔
177
        config_dict = self._set_mode_in_config_dict(mode_value, config_dict)
3✔
178
        mode = configdb.get_mode_with_code(self._instrument_type, mode_value, self._mode_type)
3✔
179
        validation_schema = mode.get('validation_schema', {})
3✔
180
        validator, validated_config_dict = self._validate_document(config_dict, validation_schema)
3✔
181
        if validator.errors:
3✔
182
            raise serializers.ValidationError(_(
3✔
183
                f'{self._mode_type.capitalize()} mode {mode_value} requirements are not met: {self._cerberus_validation_error_to_str(validator.errors)}'
184
            ))
185
        return validated_config_dict
3✔
186

187
    def mode_exists_and_is_schedulable(self, mode_value: str) -> bool:
3✔
188
        if self._modes_group and mode_value:
3✔
189
            for mode in self._modes_group['modes']:
3✔
190
                if mode['code'].lower() == mode_value.lower() and mode['schedulable']:
3✔
191
                    return True
3✔
192
            raise serializers.ValidationError(_(
3✔
193
                f'{self._mode_type.capitalize()} mode {mode_value} is not available for '
194
                f'instrument type {self._instrument_type}'
195
            ))
196
        return True
×
197

198
    def get_default_mode(self) -> str:
3✔
199
        """Choose a mode to set"""
200
        possible_modes = self._modes_group['modes']
3✔
201
        if len(possible_modes) == 1:
3✔
202
            # There is only one mode to choose from, so set that.
203
            return possible_modes[0]['code']
3✔
204
        elif self._modes_group.get('default'):
3✔
205
            return self._modes_group['default']
3✔
206
        elif len(possible_modes) > 1:
3✔
207
            # There are many possible modes, make the user choose.
208
            raise serializers.ValidationError(_(
3✔
209
                f'Must set a {self._mode_type} mode, choose '
210
                f'from {", ".join([mode["code"] for mode in self._modes_group["modes"]])}'
211
            ))
212
        return ''
×
213

214

215
class ConfigurationTypeValidationHelper(ValidationHelper):
3✔
216
    """Class used to validate config based on configuration type"""
217
    def __init__(self, instrument_type: str, configuration_type: str):
3✔
218
        self._instrument_type = instrument_type.lower()
3✔
219
        self._configuration_type = configuration_type
3✔
220

221
    def validate(self, config_dict: dict) -> dict:
3✔
222
        configuration_types = configdb.get_configuration_types(self._instrument_type)
3✔
223
        if self._configuration_type not in configuration_types:
3✔
224
            raise serializers.ValidationError(_(
×
225
                f'configuration type {self._configuration_type} is not valid for instrument type {self._instrument_type}'
226
            ))
227
        configuration_type_properties = configuration_types[self._configuration_type]
3✔
228
        validation_schema = configuration_type_properties.get('validation_schema', {})
3✔
229
        validator, validated_config_dict = self._validate_document(config_dict, validation_schema)
3✔
230
        if validator.errors:
3✔
231
            raise serializers.ValidationError(self._cerberus_to_serializer_validation_error(validator.errors))
×
232

233
        return validated_config_dict
3✔
234

235

236
class CadenceSerializer(serializers.Serializer):
3✔
237
    start = serializers.DateTimeField()
3✔
238
    end = serializers.DateTimeField()
3✔
239
    period = serializers.FloatField(validators=[MinValueValidator(0.02)])
3✔
240
    jitter = serializers.FloatField(validators=[MinValueValidator(0.02)])
3✔
241

242
    def validate_end(self, value):
3✔
243
        if value < timezone.now():
3✔
244
            raise serializers.ValidationError('End time must be in the future')
3✔
245
        return value
3✔
246

247
    def validate(self, data):
3✔
248
        if data['start'] >= data['end']:
3✔
249
            msg = _("Cadence end '{}' cannot be earlier than cadence start '{}'.").format(data['start'], data['end'])
3✔
250
            raise serializers.ValidationError(msg)
3✔
251
        return data
3✔
252

253

254
class ConstraintsSerializer(serializers.ModelSerializer):
3✔
255
    max_airmass = serializers.FloatField(
3✔
256
        default=1.6, validators=[MinValueValidator(1.0), MaxValueValidator(25.0)]  # Duplicated in models.py
257
    )
258
    min_lunar_distance = serializers.FloatField(
3✔
259
        default=30.0, validators=[MinValueValidator(0.0), MaxValueValidator(180.0)]  # Duplicated in models.py
260
    )
261
    max_lunar_phase = serializers.FloatField(
3✔
262
        default=1.0, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]  # Duplicated in models.py
263
    )
264

265
    class Meta:
3✔
266
        model = Constraints
3✔
267
        exclude = Constraints.SERIALIZER_EXCLUDE
3✔
268

269

270
class RegionOfInterestSerializer(serializers.ModelSerializer):
3✔
271
    class Meta:
3✔
272
        model = RegionOfInterest
3✔
273
        exclude = RegionOfInterest.SERIALIZER_EXCLUDE
3✔
274

275
    def validate(self, data):
3✔
276
        return data
3✔
277

278

279
class InstrumentConfigSerializer(ExtraParamsFormatter, serializers.ModelSerializer):
3✔
280
    rois = import_string(settings.SERIALIZERS['requestgroups']['RegionOfInterest'])(many=True, required=False)
3✔
281

282
    class Meta:
3✔
283
        model = InstrumentConfig
3✔
284
        exclude = InstrumentConfig.SERIALIZER_EXCLUDE
3✔
285

286
    def to_representation(self, instance):
3✔
287
        data = super().to_representation(instance)
3✔
288
        if not data['rois']:
3✔
289
            del data['rois']
3✔
290
        return data
3✔
291

292

293
class AcquisitionConfigSerializer(ExtraParamsFormatter, serializers.ModelSerializer):
3✔
294
    class Meta:
3✔
295
        model = AcquisitionConfig
3✔
296
        exclude = AcquisitionConfig.SERIALIZER_EXCLUDE
3✔
297

298
    def validate(self, data):
3✔
299
        return data
3✔
300

301

302
class GuidingConfigSerializer(ExtraParamsFormatter, serializers.ModelSerializer):
3✔
303
    class Meta:
3✔
304
        model = GuidingConfig
3✔
305
        exclude = GuidingConfig.SERIALIZER_EXCLUDE
3✔
306

307
    def validate(self, data):
3✔
308
        return data
3✔
309

310

311
class TargetSerializer(ExtraParamsFormatter, serializers.ModelSerializer):
3✔
312
    class Meta:
3✔
313
        model = Target
3✔
314
        exclude = Target.SERIALIZER_EXCLUDE
3✔
315
        extra_kwargs = {
3✔
316
            'name': {'error_messages': {'blank': 'Please provide a name.'}}
317
        }
318

319
    def to_representation(self, instance):
3✔
320
        # Only return data for the specific target type
321
        data = super().to_representation(instance)
3✔
322
        target_helper = TARGET_TYPE_HELPER_MAP[data['type']](data)
3✔
323
        target_dict = {k: data.get(k) for k in target_helper.fields if data.get(k) is not None}
3✔
324
        target_dict['extra_params'] = data.get('extra_params', {})
3✔
325
        return target_dict
3✔
326

327
    def validate(self, data):
3✔
328
        target_helper = TARGET_TYPE_HELPER_MAP[data['type']](data)
3✔
329
        if target_helper.is_valid():
3✔
330
            data.update(target_helper.data)
3✔
331
        else:
332
            raise serializers.ValidationError(target_helper.error_dict)
3✔
333
        return data
3✔
334

335

336
class ConfigurationSerializer(ExtraParamsFormatter, serializers.ModelSerializer):
3✔
337
    fill_window = serializers.BooleanField(required=False, write_only=True)
3✔
338
    constraints = import_string(settings.SERIALIZERS['requestgroups']['Constraints'])()
3✔
339
    instrument_configs = import_string(settings.SERIALIZERS['requestgroups']['InstrumentConfig'])(many=True)
3✔
340
    acquisition_config = import_string(settings.SERIALIZERS['requestgroups']['AcquisitionConfig'])()
3✔
341
    guiding_config = import_string(settings.SERIALIZERS['requestgroups']['GuidingConfig'])()
3✔
342
    target = import_string(settings.SERIALIZERS['requestgroups']['Target'])()
3✔
343

344
    class Meta:
3✔
345
        model = Configuration
3✔
346
        exclude = Configuration.SERIALIZER_EXCLUDE
3✔
347
        read_only_fields = ('priority',)
3✔
348

349
    def to_representation(self, instance):
3✔
350
        data = super().to_representation(instance)
3✔
351
        # Only return the repeat duration if its a REPEAT type configuration
352
        if 'REPEAT' not in data.get('type') and 'repeat_duration' in data:
3✔
353
            del data['repeat_duration']
3✔
354

355
        return data
3✔
356

357
    def validate_instrument_configs(self, value):
3✔
358
        if len(value) < 1:
3✔
359
            raise serializers.ValidationError(_('A configuration must have at least one instrument configuration'))
3✔
360
        return value
3✔
361

362
    def validate_instrument_type(self, value):
3✔
363
        is_staff = False
3✔
364
        request_context = self.context.get('request')
3✔
365
        if request_context:
3✔
366
            is_staff = request_context.user.is_staff
3✔
367
        if value and value.upper() not in configdb.get_instrument_type_codes({}, only_schedulable=(not is_staff)):
3✔
368
            raise serializers.ValidationError(
3✔
369
                _('Invalid instrument type {}. Valid instruments may include: {}').format(
370
                    value, ', '.join(configdb.get_instrument_type_codes({}, only_schedulable=(not is_staff)))
371
                )
372
            )
373
        return value
3✔
374

375
    def validate(self, data):
3✔
376
        # TODO: Validate the guiding optical elements on the guiding instrument types
377
        instrument_type = data['instrument_type']
3✔
378
        configuration_types = configdb.get_configuration_types(instrument_type)
3✔
379
        data['type'] = data['type'].upper()
3✔
380
        modes = configdb.get_modes_by_type(instrument_type)
3✔
381
        guiding_config = data['guiding_config']
3✔
382

383
        # Validate the guide mode
384
        guide_validation_helper = ModeValidationHelper('guiding', instrument_type, modes['guiding'])
3✔
385
        guiding_config = guide_validation_helper.validate(guiding_config)
3✔
386
        data['guiding_config'] = guiding_config
3✔
387

388
        # Validate the configuration type is available for the instrument requested
389
        if data['type'] not in configuration_types.keys():
3✔
390
            raise serializers.ValidationError(_(
3✔
391
                f'configuration type {data["type"]} is not valid for instrument type {instrument_type}'
392
            ))
393
        elif not configuration_types.get(data['type'], {}).get('schedulable', False):
3✔
394
            raise serializers.ValidationError(_(
3✔
395
                f'configuration type {data["type"]} is not schedulable for instrument type {instrument_type}'
396
            ))
397

398
        if configuration_types.get(data['type'], {}).get('force_acquisition_off', False):
3✔
399
            # These types of observations should only ever be set to guiding mode OFF, but the acquisition modes for
400
            # spectrographs won't necessarily have that mode. Force OFF here.
401
            data['acquisition_config']['mode'] = AcquisitionConfig.OFF
3✔
402
        else:
403
            # Validate acquire modes
404
            acquisition_config = data['acquisition_config']
3✔
405
            acquire_validation_helper = ModeValidationHelper('acquisition', instrument_type, modes['acquisition'])
3✔
406
            acquisition_config = acquire_validation_helper.validate(acquisition_config)
3✔
407
            if not acquisition_config.get('mode'):
3✔
408
                # Acquisition modes have an implicit default of OFF (we could just put this in all relevent validation_schema)
409
                acquisition_config['mode'] = AcquisitionConfig.OFF
×
410
            data['acquisition_config'] = acquisition_config
3✔
411

412
        # Validate the instrument_type and configuration_type properties related validation schema at the configuration level
413
        instrument_type_validation_helper = InstrumentTypeValidationHelper(instrument_type)
3✔
414
        data = instrument_type_validation_helper.validate(data)
3✔
415

416
        configuration_type_validation_helper = ConfigurationTypeValidationHelper(instrument_type, data['type'])
3✔
417
        data = configuration_type_validation_helper.validate(data)
3✔
418

419
        available_optical_elements = configdb.get_optical_elements(instrument_type)
3✔
420
        for i, instrument_config in enumerate(data['instrument_configs']):
3✔
421
            # Validate the named readout mode if set, or set the default readout mode if left blank
422
            readout_validation_helper = ModeValidationHelper('readout', instrument_type, modes['readout'])
3✔
423
            instrument_config = readout_validation_helper.validate(instrument_config)
3✔
424

425
            data['instrument_configs'][i] = instrument_config
3✔
426

427
            # Validate the rotator modes
428
            if 'rotator' in modes:
3✔
429
                rotator_mode_validation_helper = ModeValidationHelper('rotator', instrument_type, modes['rotator'],
3✔
430
                                                                      mode_key='rotator_mode')
431
                instrument_config = rotator_mode_validation_helper.validate(instrument_config)
3✔
432
                data['instrument_configs'][i] = instrument_config
3✔
433

434
            # Check that the optical elements specified are valid in configdb
435
            for oe_type, value in instrument_config.get('optical_elements', {}).items():
3✔
436
                plural_type = '{}s'.format(oe_type)
3✔
437
                if plural_type not in available_optical_elements:
3✔
438
                    raise serializers.ValidationError(_("optical_element of type {} is not available on {} instruments"
×
439
                                                        .format(oe_type, data['instrument_type'])))
440
                available_elements = {element['code'].lower(): element['code'] for element in available_optical_elements[plural_type]}
3✔
441
                if plural_type in available_optical_elements and value.lower() not in available_elements.keys():
3✔
442
                    raise serializers.ValidationError(_("optical element {} of type {} is not available".format(
3✔
443
                        value, oe_type
444
                    )))
445
                else:
446
                    instrument_config['optical_elements'][oe_type] = available_elements[value.lower()]
3✔
447

448
            # Also check that any optical element group in configdb is specified in the request unless this configuration type does
449
            # not require optical elements to be set. This will typically be the case for certain configuration types, like BIAS or DARK.
450
            if configuration_types.get(data['type'], {}).get('requires_optical_elements', True):
3✔
451
                for oe_type in available_optical_elements.keys():
3✔
452
                    singular_type = oe_type[:-1] if oe_type.endswith('s') else oe_type
3✔
453
                    if singular_type not in instrument_config.get('optical_elements', {}):
3✔
454
                        raise serializers.ValidationError(_(
3✔
455
                            f'Must set optical element of type {singular_type} for instrument type {instrument_type}'
456
                        ))
457
            # Validate any regions of interest
458
            if 'rois' in instrument_config:
3✔
459
                max_rois = configdb.get_max_rois(instrument_type)
3✔
460
                ccd_size = configdb.get_ccd_size(instrument_type)
3✔
461
                if len(instrument_config['rois']) > max_rois:
3✔
462
                    raise serializers.ValidationError(_(
3✔
463
                        f'Instrument type {instrument_type} supports up to {max_rois} regions of interest'
464
                    ))
465
                for roi in instrument_config['rois']:
3✔
466
                    if 'x1' not in roi and 'x2' not in roi and 'y1' not in roi and 'y2' not in roi:
3✔
467
                        raise serializers.ValidationError(_('Must submit at least one bound for a region of interest'))
3✔
468

469
                    if 'x1' not in roi:
3✔
470
                        roi['x1'] = 0
3✔
471
                    if 'x2' not in roi:
3✔
472
                        roi['x2'] = ccd_size['x']
3✔
473
                    if 'y1' not in roi:
3✔
474
                        roi['y1'] = 0
3✔
475
                    if 'y2' not in roi:
3✔
476
                        roi['y2'] = ccd_size['y']
3✔
477

478
                    if roi['x1'] >= roi['x2'] or roi['y1'] >= roi['y2']:
3✔
479
                        raise serializers.ValidationError(_(
3✔
480
                            'Region of interest pixels start must be less than pixels end'
481
                        ))
482

483
                    if roi['x2'] > ccd_size['x'] or roi['y2'] > ccd_size['y']:
3✔
484
                        raise serializers.ValidationError(_(
3✔
485
                            'Regions of interest for instrument type {} must be in range 0<=x<={} and 0<=y<={}'.format(
486
                                instrument_type, ccd_size['x'], ccd_size['y']
487
                            ))
488
                        )
489

490
            # Validate the exposure modes
491
            if 'exposure' in modes:
3✔
492
                exposure_mode_validation_helper = ModeValidationHelper(
×
493
                    'exposure', instrument_type, modes['exposure'], mode_key='exposure_mode', is_extra_param_mode=True
494
                )
495
                instrument_config = exposure_mode_validation_helper.validate(instrument_config)
×
496
                data['instrument_configs'][i] = instrument_config
×
497

498
        if data['type'] == 'SCRIPT':
3✔
499
            if (
3✔
500
                    'extra_params' not in data
501
                    or 'script_name' not in data['extra_params']
502
                    or not data['extra_params']['script_name']
503
            ):
504
                raise serializers.ValidationError(_(
3✔
505
                    'Must specify a script_name in extra_params for SCRIPT configuration type'
506
                ))
507

508
        # Validate duration is set if it's a REPEAT_* type configuration
509
        if 'REPEAT' in data['type']:
3✔
510
            if 'repeat_duration' not in data or data['repeat_duration'] is None:
3✔
511
                raise serializers.ValidationError(_(
3✔
512
                    f'Must specify a configuration repeat_duration for {data["type"]} type configurations.'
513
                ))
514
            else:
515
                # Validate that the duration exceeds the minimum to run everything at least once
516
                min_duration = sum(
3✔
517
                    [get_instrument_configuration_duration(
518
                        data, index) for index in range(len(data['instrument_configs']))]
519
                )
520
                if min_duration > data['repeat_duration']:
3✔
521
                    raise serializers.ValidationError(_(
3✔
522
                        f'Configuration repeat_duration of {data["repeat_duration"]} is less than the minimum of '
523
                        f'{min_duration} required to repeat at least once'
524
                    ))
525
        else:
526
            if 'repeat_duration' in data and data['repeat_duration'] is not None:
3✔
527
                raise serializers.ValidationError(_(
3✔
528
                    'You may only specify a repeat_duration for REPEAT_* type configurations.'
529
                ))
530

531
        # Validate dither pattern
532

533
        is_dither_sequence = False
3✔
534
        for instrument_config in data['instrument_configs']:
3✔
535
            offset_ra = instrument_config.get('extra_params', {}).get('offset_ra', 0)
3✔
536
            offset_dec = instrument_config.get('extra_params', {}).get('offset_dec', 0)
3✔
537
            if offset_dec != 0 or offset_ra != 0:
3✔
538
                is_dither_sequence = True
3✔
539
                break
3✔
540

541
        dither_pattern_is_set = 'extra_params' in data and 'dither_pattern' in data['extra_params']
3✔
542
        dither_pattern = data.get('extra_params', {}).get('dither_pattern', None)
3✔
543

544
        # Check that if a dither pattern is set, this configuration is actually a dither sequence
545
        if dither_pattern_is_set and not is_dither_sequence:
3✔
546
            raise serializers.ValidationError(_(
3✔
547
                f'You set a dither pattern of {dither_pattern} but did not supply any non-zero dither offsets. You must specify '
548
                'offset_ra and/or offset_dec fields in the extra_params in one or more instrument_configs to create a '
549
                'dither pattern.'
550
            ))
551

552
        # Check that any dither pattern that is set is valid
553
        if dither_pattern_is_set:
3✔
554
            valid_patterns = list(settings.DITHER['valid_expansion_patterns']) + [settings.DITHER['custom_pattern_key']]
3✔
555
            if dither_pattern not in valid_patterns:
3✔
556
                raise serializers.ValidationError(_(
3✔
557
                    f'Invalid dither pattern {dither_pattern} set in the configuration extra_params, choose from {", ".join(valid_patterns)}'
558
                ))
559

560
        # If a dither pattern is not yet set and this is part of a dither sequence, set the custom dither pattern field.
561
        if not dither_pattern_is_set and is_dither_sequence:
3✔
562
            if 'extra_params' not in data:
3✔
563
                data['extra_params'] = {}
3✔
564
            data['extra_params']['dither_pattern'] = settings.DITHER['custom_pattern_key']
3✔
565

566
        return data
3✔
567

568

569
class LocationSerializer(serializers.ModelSerializer):
3✔
570
    site = serializers.ChoiceField(choices=[], required=False)
3✔
571
    enclosure = serializers.ChoiceField(choices=[], required=False)
3✔
572
    telescope = serializers.ChoiceField(choices=[], required=False)
3✔
573
    telescope_class = serializers.ChoiceField(choices=[], required=True)
3✔
574

575
    def __init__(self, *args, **kwargs):
3✔
576
        super().__init__(*args, **kwargs)
3✔
577

578
        # Can't even make migrations without connecting to ConfigDB :(
579
        self.fields["site"].choices = configdb.get_site_tuples()
3✔
580
        self.fields["enclosure"].choices = configdb.get_enclosure_tuples()
3✔
581
        self.fields["telescope"].choices = configdb.get_telescope_tuples()
3✔
582
        self.fields["telescope_class"].choices = configdb.get_telescope_class_tuples()
3✔
583

584
    class Meta:
3✔
585
        model = Location
3✔
586
        exclude = Location.SERIALIZER_EXCLUDE
3✔
587

588
    def validate(self, data):
3✔
589
        if 'enclosure' in data and 'site' not in data:
3✔
590
            raise serializers.ValidationError(_("Must specify a site with an enclosure."))
3✔
591
        if 'telescope' in data and 'enclosure' not in data:
3✔
592
            raise serializers.ValidationError(_("Must specify an enclosure with a telescope."))
3✔
593

594
        site_data_dict = {site['code']: site for site in configdb.get_site_data()}
3✔
595
        if 'site' in data:
3✔
596
            if data['site'] not in site_data_dict:
3✔
597
                msg = _('Site {} not valid. Valid choices: {}').format(data['site'], ', '.join(site_data_dict.keys()))
×
598
                raise serializers.ValidationError(msg)
×
599
            enc_set = site_data_dict[data['site']]['enclosure_set']
3✔
600
            enc_dict = {enc['code']: enc for enc in enc_set}
3✔
601
            if 'enclosure' in data:
3✔
602
                if data['enclosure'] not in enc_dict:
3✔
603
                    raise serializers.ValidationError(_(
×
604
                        f'Enclosure {data["enclosure"]} not valid. Valid choices: {", ".join(enc_dict.keys())}'
605
                    ))
606
                tel_set = enc_dict[data['enclosure']]['telescope_set']
3✔
607
                tel_list = [tel['code'] for tel in tel_set]
3✔
608
                if 'telescope' in data and data['telescope'] not in tel_list:
3✔
609
                    msg = _('Telescope {} not valid. Valid choices: {}').format(data['telescope'], ', '.join(tel_list))
×
610
                    raise serializers.ValidationError(msg)
×
611

612
        return data
3✔
613

614
    def to_representation(self, instance):
3✔
615
        """
616
        This method is overridden to remove blank fields from serialized output. We could put this into a subclassed
617
        ModelSerializer if we want it to apply to all our Serializers.
618
        """
619
        rep = super().to_representation(instance)
3✔
620
        return {key: val for key, val in rep.items() if val}
3✔
621

622

623
class WindowSerializer(serializers.ModelSerializer):
3✔
624
    start = serializers.DateTimeField(required=False)
3✔
625

626
    class Meta:
3✔
627
        model = Window
3✔
628
        exclude = Window.SERIALIZER_EXCLUDE
3✔
629

630
    def validate(self, data):
3✔
631
        if 'start' not in data:
3✔
632
            data['start'] = timezone.now()
×
633
        if data['end'] <= data['start']:
3✔
634
            msg = _(f"Window end '{data['end']}' cannot be earlier than window start '{data['start']}'")
3✔
635
            raise serializers.ValidationError(msg)
3✔
636

637
        if not get_semester_in(data['start'], data['end']):
3✔
638
            raise serializers.ValidationError('The observation window does not fit within any defined semester.')
3✔
639
        return data
3✔
640

641
    def validate_end(self, value):
3✔
642
        if value < timezone.now():
3✔
643
            raise serializers.ValidationError('Window end time must be in the future')
×
644
        return value
3✔
645

646

647
class RequestUpdateSerializer(serializers.ModelSerializer):
3✔
648
    class Meta:
3✔
649
        model = Request
3✔
650
        fields = ['suspend_until',]
3✔
651

652
    def validate(self, data):
3✔
653
        if 'suspend_until' not in data:
3✔
654
            raise serializers.ValidationError(_('The suspend_until value must be set on update requests'))
3✔
655
        for key in data.keys():
3✔
656
            if key != 'suspend_until':
3✔
UNCOV
657
                raise serializers.ValidationError(_('Only the suspend_until field may be updated in requests'))
×
658
        return data
3✔
659

660
    def validate_suspend_until(self, value):
3✔
661
        if value and value < timezone.now():
3✔
662
            raise serializers.ValidationError(_('The suspend_until value must be in the future'))
3✔
663
        return value
3✔
664

665
    def update(self, instance, validated_data):
3✔
666
        if instance.state != 'PENDING':
3✔
UNCOV
667
            raise serializers.ValidationError(_(f"Can only suspend scheduling on PENDING Requests - this request state is {instance.state}"))
×
668
        instance.suspend_until = validated_data.get('suspend_until')
3✔
669
        instance.save(update_fields=['suspend_until'])
3✔
670
        return instance
3✔
671

672

673
class RequestSerializer(serializers.ModelSerializer):
3✔
674
    location = import_string(settings.SERIALIZERS['requestgroups']['Location'])()
3✔
675
    configurations = import_string(settings.SERIALIZERS['requestgroups']['Configuration'])(many=True)
3✔
676
    windows = import_string(settings.SERIALIZERS['requestgroups']['Window'])(many=True)
3✔
677
    cadence = import_string(settings.SERIALIZERS['requestgroups']['Cadence'])(required=False, write_only=True)
3✔
678
    duration = serializers.ReadOnlyField()
3✔
679

680
    class Meta:
3✔
681
        model = Request
3✔
682
        read_only_fields = (
3✔
683
            'id', 'created', 'duration', 'state',
684
        )
685
        exclude = Request.SERIALIZER_EXCLUDE
3✔
686

687
    def validate_configurations(self, value):
3✔
688
        if not value:
3✔
689
            raise serializers.ValidationError(_('You must specify at least 1 configuration'))
3✔
690

691
        # Only one configuration can have the fill_window attribute set
692
        if [config.get('fill_window', False) for config in value].count(True) > 1:
3✔
693
            raise serializers.ValidationError(_('Only one configuration can have `fill_window` set'))
3✔
694

695
        # Set the relative priority of molecules in order
696
        for i, configuration in enumerate(value):
3✔
697
            configuration['priority'] = i + 1
3✔
698

699
        return value
3✔
700

701
    def validate_windows(self, value):
3✔
702
        if not value:
3✔
703
            raise serializers.ValidationError(_('You must specify at least 1 window'))
3✔
704

705
        if len(set([get_semester_in(window['start'], window['end']) for window in value])) > 1:
3✔
706
            raise serializers.ValidationError(_('The observation windows must all be in the same semester'))
3✔
707

708
        return value
3✔
709

710
    def validate_cadence(self, value):
3✔
711
        if value:
3✔
712
            raise serializers.ValidationError(_('Please use the cadence endpoint to expand your cadence request'))
3✔
UNCOV
713
        return value
×
714

715
    def validate(self, data):
3✔
716
        is_staff = False
3✔
717
        only_schedulable = True
3✔
718
        request_context = self.context.get('request')
3✔
719
        if request_context:
3✔
720
            is_staff = request_context.user.is_staff
3✔
721
            only_schedulable = not (is_staff and ConfigDB.is_location_fully_set(data.get('location', {})))
3✔
722
        # check if the instrument specified is allowed
723
        # TODO: Check if ALL instruments are available at a resource defined by location
724
        if 'location' in data:
3✔
725
            # Check if the location is fully specified, and if not then use only schedulable instruments
726
            valid_instruments = configdb.get_instrument_type_codes(data.get('location', {}),
3✔
727
                                                              only_schedulable=only_schedulable)
728
            for configuration in data['configurations']:
3✔
729
                if configuration['instrument_type'].upper() not in valid_instruments:
3✔
730
                    msg = _("Invalid instrument type '{}' at site={}, enc={}, tel={}. \n").format(
3✔
731
                        configuration['instrument_type'],
732
                        data.get('location', {}).get('site', 'Any'),
733
                        data.get('location', {}).get('enclosure', 'Any'),
734
                        data.get('location', {}).get('telescope', 'Any')
735
                    )
736
                    msg += _("Valid instruments include: ")
3✔
737
                    for inst_name in valid_instruments:
3✔
738
                        msg += inst_name + ', '
3✔
739
                    msg += '.'
3✔
740
                    if is_staff and not only_schedulable:
3✔
UNCOV
741
                        msg += '\nStaff users must fully specify location to schedule on non-SCHEDULABLE instruments'
×
742
                    raise serializers.ValidationError(msg)
3✔
743

744
        if 'acceptability_threshold' not in data:
3✔
745
            data['acceptability_threshold'] = max(
3✔
746
                [configdb.get_default_acceptability_threshold(configuration['instrument_type'])
747
                 for configuration in data['configurations']]
748
            )
749

750
        if 'extra_params' in data and 'mosaic_pattern' in data['extra_params']:
3✔
751
            pattern = data['extra_params']['mosaic_pattern']
3✔
752
            valid_patterns = list(settings.MOSAIC['valid_expansion_patterns']) + [settings.MOSAIC['custom_pattern_key']]
3✔
753
            if pattern not in valid_patterns:
3✔
754
                raise serializers.ValidationError(_(
3✔
755
                    f'Invalid mosaic pattern {pattern} set in the request extra_params, choose from {", ".join(valid_patterns)}'
756
                ))
757

758
        # check that the requests window has enough rise_set visible time to accomodate the requests duration
759
        if data.get('windows'):
3✔
760
            duration = get_total_request_duration(data)
3✔
761
            rise_set_intervals_by_site = get_filtered_rise_set_intervals_by_site(data, is_staff=is_staff)
3✔
762
            largest_interval = get_largest_interval(rise_set_intervals_by_site, exclude_past=True)
3✔
763
            for configuration in data['configurations']:
3✔
764
                if 'REPEAT' in configuration['type'].upper() and configuration.get('fill_window'):
3✔
765
                    max_configuration_duration = largest_interval.total_seconds() - duration + configuration.get('repeat_duration', 0) - 1
3✔
766
                    configuration['repeat_duration'] = max_configuration_duration
3✔
767
                    duration = get_total_request_duration(data)
3✔
768

769
                # delete the fill window attribute, it is only used for this validation
770
                try:
3✔
771
                    del configuration['fill_window']
3✔
772
                except KeyError:
3✔
773
                    pass
3✔
774
            if largest_interval.total_seconds() <= 0:
3✔
775
                raise serializers.ValidationError(
3✔
776
                    _(
777
                        'According to the constraints of the request, the target will not be visible within the '
778
                        'time window. Check that the target is in the nighttime sky. Consider modifying the time '
779
                        'window or loosening the airmass or lunar separation constraints. If the target is '
780
                        'non sidereal, double check that the provided elements are correct.'
781
                    )
782
                )
783
            if largest_interval.total_seconds() <= duration:
3✔
784
                raise serializers.ValidationError(
3✔
785
                    (
786
                        'According to the constraints of the request, the target will only be visible for a maximum '
787
                        'of {0:.2f} hours within the time window. '
788
                        'This is less than the duration of your request {1:.2f} hours. '
789
                        'Consider expanding the time window or loosening the airmass or lunar separation constraints.'
790
                    ).format(
791
                        largest_interval.total_seconds() / 3600.0,
792
                        duration / 3600.0
793
                    )
794
                )
795
        return data
3✔
796

797

798
class CadenceRequestSerializer(RequestSerializer):
3✔
799
    cadence = import_string(settings.SERIALIZERS['requestgroups']['Cadence'])()
3✔
800
    windows = import_string(settings.SERIALIZERS['requestgroups']['Window'])(required=False, many=True)
3✔
801

802
    def validate_cadence(self, value):
3✔
803
        return value
3✔
804

805
    def validate_windows(self, value):
3✔
806
        if value:
3✔
807
            raise serializers.ValidationError(_('Cadence requests may not contain windows'))
3✔
808

UNCOV
809
        return value
×
810

811

812
class RequestGroupSerializer(serializers.ModelSerializer):
3✔
813
    requests = import_string(settings.SERIALIZERS['requestgroups']['Request'])(many=True)
3✔
814
    submitter = serializers.StringRelatedField(default=serializers.CurrentUserDefault(), read_only=True)
3✔
815
    submitter_id = serializers.CharField(write_only=True, required=False)
3✔
816

817
    class Meta:
3✔
818
        model = RequestGroup
3✔
819
        fields = '__all__'
3✔
820
        read_only_fields = (
3✔
821
            'id', 'created', 'state', 'modified'
822
        )
823
        extra_kwargs = {
3✔
824
            'proposal': {'error_messages': {'null': 'Please provide a proposal.'}},
825
            'name': {'error_messages': {'blank': 'Please provide a name.'}}
826
        }
827

828
    def create(self, validated_data):
3✔
829
        request_data = validated_data.pop('requests')
3✔
830
        now = timezone.now()
3✔
831
        with transaction.atomic():
3✔
832
            request_group = RequestGroup.objects.create(**validated_data)
3✔
833

834
            for r in request_data:
3✔
835
                configurations_data = r.pop('configurations')
3✔
836

837
                location_data = r.pop('location', {})
3✔
838
                windows_data = r.pop('windows', [])
3✔
839
                request = Request.objects.create(request_group=request_group, **r)
3✔
840

841
                if validated_data['observation_type'] not in RequestGroup.NON_SCHEDULED_TYPES:
3✔
842
                    Location.objects.create(request=request, **location_data)
3✔
843
                    for window_data in windows_data:
3✔
844
                        Window.objects.create(request=request, **window_data)
3✔
845

846
                for configuration_data in configurations_data:
3✔
847
                    instrument_configs_data = configuration_data.pop('instrument_configs')
3✔
848
                    acquisition_config_data = configuration_data.pop('acquisition_config')
3✔
849
                    guiding_config_data = configuration_data.pop('guiding_config')
3✔
850
                    target_data = configuration_data.pop('target')
3✔
851
                    constraints_data = configuration_data.pop('constraints')
3✔
852
                    configuration = Configuration.objects.create(request=request, **configuration_data)
3✔
853

854
                    AcquisitionConfig.objects.create(configuration=configuration, **acquisition_config_data)
3✔
855
                    GuidingConfig.objects.create(configuration=configuration, **guiding_config_data)
3✔
856
                    Target.objects.create(configuration=configuration, **target_data)
3✔
857
                    Constraints.objects.create(configuration=configuration, **constraints_data)
3✔
858

859
                    for instrument_config_data in instrument_configs_data:
3✔
860
                        rois_data = []
3✔
861
                        if 'rois' in instrument_config_data:
3✔
862
                            rois_data = instrument_config_data.pop('rois')
3✔
863
                        instrument_config = InstrumentConfig.objects.create(configuration=configuration,
3✔
864
                                                                            **instrument_config_data)
865
                        for roi_data in rois_data:
3✔
866
                            RegionOfInterest.objects.create(instrument_config=instrument_config, **roi_data)
3✔
867
                telescope_class = location_data.get('telescope_class')
3✔
868
                if telescope_class:
3✔
869
                    cache.set(f"observation_portal_last_change_time_{telescope_class}", now, None)
3✔
870

871
        if validated_data['observation_type'] == RequestGroup.NORMAL:
3✔
872
            debit_ipp_time(request_group)
3✔
873

874
        logger.info('RequestGroup created', extra={'tags': {
3✔
875
            'user': request_group.submitter.username,
876
            'tracking_num': request_group.id,
877
            'name': request_group.name
878
        }})
879
        cache.set('observation_portal_last_change_time_all', now, None)
3✔
880

881
        return request_group
3✔
882

883
    def validate(self, data):
3✔
884
        # check that the user belongs to the supplied proposal
885
        user = self.context['request'].user
3✔
886
        if data['proposal'] not in user.proposal_set.all():
3✔
887
            raise serializers.ValidationError(
3✔
888
                _('You do not belong to the proposal you are trying to submit with')
889
            )
890

891
        if not user.proposal_set.filter(id=data['proposal'], active=True).exists():
3✔
892
            raise serializers.ValidationError(
3✔
893
                _('The proposal you are trying to submit with is currently inactive')
894
            )
895

896
        # Validate that the ipp_value is within the min/max range
897
        if 'ipp_value' in data:
3✔
898
            if data['ipp_value'] < settings.MIN_IPP_VALUE or data['ipp_value'] > settings.MAX_IPP_VALUE:
3✔
899
                raise serializers.ValidationError(_(f'requestgroups ipp_value must be >= {settings.MIN_IPP_VALUE}, <= {settings.MAX_IPP_VALUE}'))
3✔
900

901
        # validation on the operator matching the number of requests
902
        if data['operator'] == 'SINGLE':
3✔
903
            if len(data['requests']) > 1:
3✔
904
                raise serializers.ValidationError(
3✔
905
                    _("'Single' type requestgroups must have exactly one child request.")
906
                )
907
        elif len(data['requests']) == 1:
3✔
UNCOV
908
            raise serializers.ValidationError(
×
909
                _("'{}' type requestgroups must have more than one child request.".format(data['operator'].title()))
910
            )
911

912
        # Check that the user has not exceeded the time limit on this membership
913
        membership = Membership.objects.get(user=user, proposal=data['proposal'])
3✔
914
        if membership.time_limit >= 0:
3✔
915
            duration = sum(d for i, d in get_requestgroup_duration(data).items())
3✔
916
            time_to_be_used = user.profile.time_used_in_proposal(data['proposal']) + duration
3✔
917
            if membership.time_limit < time_to_be_used:
3✔
918
                raise serializers.ValidationError(
3✔
919
                    _('This request\'s duration will exceed the time limit set for your account on this proposal.')
920
                )
921

922
        if data['observation_type'] in RequestGroup.NON_SCHEDULED_TYPES:
3✔
923
            # Don't do any time accounting stuff if it is a directly scheduled observation
924
            return data
3✔
925
        else:
926
            for request in data['requests']:
3✔
927
                for config in request['configurations']:
3✔
928
                    # for scheduled observations, don't allow HOUR_ANGLE targets (they're not supported in rise-set yet)
929
                    if config['target']['type'] == 'HOUR_ANGLE':
3✔
930
                        raise serializers.ValidationError(_('HOUR_ANGLE Target type not supported in scheduled observations'))
3✔
931

932
        try:
3✔
933
            total_duration_dict = get_total_duration_dict(data)
3✔
934
            for tak, duration in total_duration_dict.items():
3✔
935
                time_allocation = TimeAllocation.objects.get(
3✔
936
                    semester=tak.semester,
937
                    instrument_types__contains=[tak.instrument_type],
938
                    proposal=data['proposal'],
939
                )
940
                time_available = 0
3✔
941
                if data['observation_type'] == RequestGroup.NORMAL:
3✔
942
                    time_available = time_allocation.std_allocation - time_allocation.std_time_used
3✔
943
                elif data['observation_type'] == RequestGroup.RAPID_RESPONSE:
3✔
944
                    time_available = time_allocation.rr_allocation - time_allocation.rr_time_used
3✔
945
                    # For Rapid Response observations, check if the end time of the window is within
946
                    # 24 hours + the duration of the observation
947
                    for request in data['requests']:
3✔
948
                        windows = request.get('windows')
3✔
949
                        for window in windows:
3✔
950
                            if window.get('start') - timezone.now() > timedelta(seconds=0):
3✔
951
                                raise serializers.ValidationError(
3✔
952
                                    _("The Rapid Response observation window start time cannot be in the future.")
953
                                )
954
                            if window.get('end') - timezone.now() > timedelta(seconds=(duration + 86400)):
3✔
955
                                raise serializers.ValidationError(
3✔
956
                                    _(
957
                                        "A Rapid Response observation must start within the next 24 hours, so the "
958
                                        "window end time must be within the next (24 hours + the observation duration)"
959
                                    )
960
                                )
961
                elif data['observation_type'] == RequestGroup.TIME_CRITICAL:
3✔
962
                    # Time critical time
963
                    time_available = time_allocation.tc_allocation - time_allocation.tc_time_used
3✔
964

965
                if time_available <= 0.0:
3✔
966
                    raise serializers.ValidationError(
3✔
967
                        _("Proposal {} does not have any {} time left allocated in semester {} on {} instruments").format(
968
                            data['proposal'], data['observation_type'], tak.semester, tak.instrument_type)
969
                    )
970
                elif time_available * settings.PROPOSAL_TIME_OVERUSE_ALLOWANCE < (duration / 3600.0):
3✔
971
                    raise serializers.ValidationError(
3✔
972
                        _("Proposal {} does not have enough {} time allocated in semester {}").format(
973
                            data['proposal'], data['observation_type'], tak.semester)
974
                    )
975
            # validate the ipp debitting that will take place later
976
            if data['observation_type'] == RequestGroup.NORMAL:
3✔
977
                validate_ipp(data, total_duration_dict)
3✔
978
        except ObjectDoesNotExist:
3✔
979
            raise serializers.ValidationError(
3✔
980
                _("You do not have sufficient {} time allocated on the instrument you're requesting for this proposal.".format(
981
                    data['observation_type']
982
                ))
983
            )
984
        except TimeAllocationError as e:
3✔
985
            raise serializers.ValidationError(repr(e))
3✔
986

987
        return data
3✔
988

989
    def validate_requests(self, value):
3✔
990
        if not value:
3✔
991
            raise serializers.ValidationError(_('You must specify at least 1 request'))
3✔
992
        return value
3✔
993

994

995
class CadenceRequestGroupSerializer(RequestGroupSerializer):
3✔
996
    requests = import_string(settings.SERIALIZERS['requestgroups']['CadenceRequest'])(many=True)
3✔
997

998
    # override the validate method from the RequestGroupSerializer and use the Cadence Request serializer to
999
    # validate the cadence request
1000
    def validate(self, data):
3✔
1001
        if len(data['requests']) > 1:
3✔
1002
            raise ValidationError('Cadence requestgroups may only contain a single request')
3✔
1003

1004
        return data
3✔
1005

1006

1007
class PatternExpansionSerializer(serializers.Serializer):
3✔
1008
    pattern = serializers.ChoiceField(choices=('line', 'grid', 'spiral'), required=True)
3✔
1009
    num_points = serializers.IntegerField(required=False)
3✔
1010
    point_spacing = serializers.FloatField(required=False)
3✔
1011
    line_spacing = serializers.FloatField(required=False)
3✔
1012
    orientation = serializers.FloatField(required=False, default=0.0)
3✔
1013
    num_rows = serializers.IntegerField(required=False)
3✔
1014
    num_columns = serializers.IntegerField(required=False)
3✔
1015
    center = serializers.BooleanField(required=False, default=False)
3✔
1016

1017
    def validate(self, data):
3✔
1018
        validated_data = super().validate(data)
3✔
1019
        if 'num_points' not in validated_data and validated_data.get('pattern') in ['line', 'spiral']:
3✔
1020
            raise serializers.ValidationError(_('Must specify num_points when selecting a line or spiral pattern'))
3✔
1021
        if 'line_spacing' not in validated_data and 'point_spacing' in validated_data and validated_data.get('pattern') == 'grid':
3✔
1022
            # Set a default line spacing equal to the point spacing if it is not specified
1023
            validated_data['line_spacing'] = validated_data['point_spacing']
3✔
1024
        if validated_data.get('pattern') == 'grid':
3✔
1025
            if 'num_rows' not in validated_data or 'num_columns' not in validated_data:
3✔
1026
                raise serializers.ValidationError(_('Must specify num_rows and num_columns when selecting a grid pattern'))
3✔
1027
        return validated_data
3✔
1028

1029

1030
class MosaicSerializer(PatternExpansionSerializer):
3✔
1031
    request = import_string(settings.SERIALIZERS['requestgroups']['Request'])()
3✔
1032
    pattern = serializers.ChoiceField(choices=settings.MOSAIC['valid_expansion_patterns'], required=True)
3✔
1033
    point_overlap_percent = serializers.FloatField(required=False, validators=[MinValueValidator(0.0), MaxValueValidator(100.0)])
3✔
1034
    line_overlap_percent = serializers.FloatField(required=False, validators=[MinValueValidator(0.0), MaxValueValidator(100.0)])
3✔
1035

1036
    def validate_request(self, request):
3✔
1037
        if len(request.get('configurations', [])) > 1:
3✔
1038
            raise serializers.ValidationError(_("Cannot expand a request for mosaicing with more than one configuration set"))
3✔
1039
        if request['configurations'][0]['target']['type'] != 'ICRS':
3✔
1040
            raise serializers.ValidationError(_("Mosaic expansion is only for ICRS Targets. Try using dither expansion for patterns with other target types"))
3✔
1041

1042
        return request
3✔
1043

1044
    def validate(self, data):
3✔
1045
        validated_data = super().validate(data)
3✔
1046
        # If point_overlap_percent is set, we will overwrite the point_spacing based on the requested
1047
        # instrument_type and its fov on horizontal axis. If line_overlap_percent is specified, we will
1048
        # do the same for the fov on the vertical axis - if it is not specified we will use the
1049
        # point_overlap_percent and overwrite the line_spacing value
1050
        if 'point_overlap_percent' in validated_data:
3✔
1051
            instrument_type = data['request']['configurations'][0]['instrument_type']
3✔
1052
            ccd_orientation = configdb.get_average_ccd_orientation(instrument_type)
3✔
1053
            pattern_orientation = validated_data.get('orientation', 0.0)
3✔
1054
            pattern_orientation = pattern_orientation % 360
3✔
1055
            # Decide to flip the point/line overlapped sense based on general orientation
1056
            if pattern_orientation < 45 or pattern_orientation > 315:
3✔
1057
                flip = False
3✔
1058
            elif pattern_orientation < 135:
3✔
1059
                flip = True
3✔
UNCOV
1060
            elif pattern_orientation < 225:
×
UNCOV
1061
                flip = False
×
UNCOV
1062
            elif pattern_orientation < 315:
×
UNCOV
1063
                flip = True
×
1064
            ccd_size = configdb.get_ccd_size(instrument_type)
3✔
1065
            pixel_scale = configdb.get_pixel_scale(instrument_type)
3✔
1066
            coso = cos(radians(ccd_orientation))
3✔
1067
            sino = sin(radians(ccd_orientation))
3✔
1068
            # Rotate the ccd dimensions by the ccd orientation - needed so our % overlap is in the correct frame
1069
            rotated_ccd_x = ccd_size['x'] * coso + ccd_size['y'] * sino
3✔
1070
            rotated_ccd_y = ccd_size['x'] * -sino + ccd_size['y'] * coso
3✔
1071
            if 'line_overlap_percent' not in validated_data:
3✔
1072
                validated_data['line_overlap_percent'] = validated_data['point_overlap_percent']
3✔
1073
            validated_data['point_spacing'] = abs(rotated_ccd_y) * pixel_scale * ((100.0 - validated_data['point_overlap_percent']) / 100.0)
3✔
1074
            validated_data['line_spacing'] = abs(rotated_ccd_x) * pixel_scale * ((100.0 - validated_data['line_overlap_percent']) / 100.0)
3✔
1075
            if flip:
3✔
1076
                # If the pattern orientation is closer to 90 or 270 (within 45 degrees), then flip the point/line spacing to better align with pattern orientation
1077
                temp = validated_data['line_spacing']
3✔
1078
                validated_data['line_spacing'] = validated_data['point_spacing']
3✔
1079
                validated_data['point_spacing'] = temp
3✔
1080
        elif 'point_spacing' not in validated_data:
3✔
1081
            # One of point_spacing or point_overlap_percent must be specified
1082
            raise serializers.ValidationError(_("Must specify one of point_spacing or point_overlap_percent"))
3✔
1083
        return validated_data
3✔
1084

1085

1086
class DitherSerializer(PatternExpansionSerializer):
3✔
1087
    configuration = import_string(settings.SERIALIZERS['requestgroups']['Configuration'])()
3✔
1088
    pattern = serializers.ChoiceField(choices=settings.DITHER['valid_expansion_patterns'], required=True)
3✔
1089
    point_spacing = serializers.FloatField(required=True)
3✔
1090

1091

1092
class DraftRequestGroupSerializer(serializers.ModelSerializer):
3✔
1093
    author = serializers.SlugRelatedField(
3✔
1094
        read_only=True,
1095
        slug_field='username',
1096
        default=serializers.CurrentUserDefault()
1097
    )
1098

1099
    class Meta:
3✔
1100
        model = DraftRequestGroup
3✔
1101
        fields = '__all__'
3✔
1102
        read_only_fields = ('author',)
3✔
1103

1104
    def validate(self, data):
3✔
1105
        if data['proposal'] not in self.context['request'].user.proposal_set.all():
3✔
1106
            raise serializers.ValidationError('You are not a member of that proposal')
3✔
1107
        return data
3✔
1108

1109
    def validate_content(self, data):
3✔
1110
        try:
3✔
1111
            json.loads(data)
3✔
1112
        except JSONDecodeError:
3✔
1113
            raise serializers.ValidationError('Content must be valid JSON')
3✔
1114
        return data
3✔
1115

1116

1117
class LastChangedSerializer(serializers.Serializer):
3✔
1118
    last_change_time = serializers.DateTimeField()
3✔
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