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

observatorycontrolsystem / observation-portal / 21694483446

05 Feb 2026 12:56AM UTC coverage: 96.575% (-0.002%) from 96.577%
21694483446

Pull #352

github

Jon
Remove python 3.9 from build since its EOL and not in github actions anymore
Pull Request #352: 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

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

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

18
from observation_portal.proposals.models import TimeAllocation, Membership
2✔
19
from observation_portal.requestgroups.models import (
2✔
20
    Request, Target, Window, RequestGroup, Location, Configuration, Constraints, InstrumentConfig,
21
    AcquisitionConfig, GuidingConfig, RegionOfInterest
22
)
23
from observation_portal.requestgroups.models import DraftRequestGroup
2✔
24
from observation_portal.common.state_changes import debit_ipp_time, TimeAllocationError, validate_ipp
2✔
25
from observation_portal.requestgroups.target_helpers import TARGET_TYPE_HELPER_MAP
2✔
26
from observation_portal.common.mixins import ExtraParamsFormatter
2✔
27
from observation_portal.common.configdb import configdb, ConfigDB
2✔
28
from observation_portal.common.utils import OCSValidator
2✔
29
from observation_portal.requestgroups.duration_utils import (
2✔
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
2✔
34
from observation_portal.common.rise_set_utils import get_filtered_rise_set_intervals_by_site, get_largest_interval
2✔
35

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

38

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

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

50
    def _validate_document(self, document: dict, validation_schema: dict) -> (OCSValidator, dict):
2✔
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)
2✔
58
        validator.allow_unknown = True
2✔
59
        validated_config_dict = validator.validated(document) or document.copy()
2✔
60

61
        return validator, validated_config_dict
2✔
62

63
    def _cerberus_validation_error_to_str(self, validation_errors: dict) -> str:
2✔
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 = ''
2✔
70
        for field, value in validation_errors.items():
2✔
71
            if (isinstance(value, list) and len(value) == 1 and isinstance(value[0], dict)):
2✔
72
                error_str += f'{field}{{{self._cerberus_validation_error_to_str(value[0])}}}'
2✔
73
            else:
74
                error_str += f'{field} error: {", ".join(value)}, '
2✔
75

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

79
    def _cerberus_to_serializer_validation_error(self, validation_errors: dict) -> dict:
2✔
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 = {}
2✔
87
        if 'extra_params' in validation_errors:
2✔
88
            serializer_errors['extra_params'] = validation_errors['extra_params'][0]
×
89
        for section in self.CONFIG_SECTIONS:
2✔
90
            if section in validation_errors:
2✔
91
                error = validation_errors[section][0]
2✔
92
                if section not in serializer_errors:
2✔
93
                    serializer_errors[section] = {}
2✔
94
                serializer_errors[section].update(error)
2✔
95
                if 'extra_params' in error:
2✔
96
                    serializer_errors[section]['extra_params'] = error['extra_params'][0]
×
97
        if 'instrument_configs' in validation_errors:
2✔
98
            instrument_configs_errors = []
2✔
99
            last_instrument_config_with_error = max(validation_errors['instrument_configs'][0].keys())
2✔
100
            for i in range(0, last_instrument_config_with_error+1):
2✔
101
                if i in validation_errors['instrument_configs'][0]:
2✔
102
                    instrument_config_error = validation_errors['instrument_configs'][0][i][0].copy()
2✔
103
                    if 'extra_params' in instrument_config_error:
2✔
104
                        instrument_config_error['extra_params'] = instrument_config_error['extra_params'][0]
2✔
105
                    instrument_configs_errors.append(instrument_config_error)
2✔
106
                else:
107
                    instrument_configs_errors.append({})
×
108
            serializer_errors['instrument_configs'] = instrument_configs_errors
2✔
109
        return serializer_errors
2✔
110

111

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

117
    def validate(self, config_dict: dict) -> dict:
2✔
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)
2✔
125
        validation_schema = instrument_type_dict.get('validation_schema', {})
2✔
126
        validator, validated_config_dict = self._validate_document(config_dict, validation_schema)
2✔
127
        if validator.errors:
2✔
128
            raise serializers.ValidationError(self._cerberus_to_serializer_validation_error(validator.errors))
2✔
129

130
        return validated_config_dict
2✔
131

132

133
class ModeValidationHelper(ValidationHelper):
2✔
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):
2✔
136
        self._mode_type = mode_type.lower()
2✔
137
        self._instrument_type = instrument_type
2✔
138
        self._modes_group = modes_group
2✔
139
        self._mode_key = mode_key
2✔
140
        self.is_extra_param_mode = is_extra_param_mode
2✔
141

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

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

156
    def validate(self, config_dict) -> dict:
2✔
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)
2✔
172
        if not mode_value:
2✔
173
            mode_value = self.get_default_mode()
2✔
174
            if not mode_value:
2✔
175
                return config_dict
×
176
        self.mode_exists_and_is_schedulable(mode_value)
2✔
177
        config_dict = self._set_mode_in_config_dict(mode_value, config_dict)
2✔
178
        mode = configdb.get_mode_with_code(self._instrument_type, mode_value, self._mode_type)
2✔
179
        validation_schema = mode.get('validation_schema', {})
2✔
180
        validator, validated_config_dict = self._validate_document(config_dict, validation_schema)
2✔
181
        if validator.errors:
2✔
182
            raise serializers.ValidationError(_(
2✔
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
2✔
186

187
    def mode_exists_and_is_schedulable(self, mode_value: str) -> bool:
2✔
188
        if self._modes_group and mode_value:
2✔
189
            for mode in self._modes_group['modes']:
2✔
190
                if mode['code'].lower() == mode_value.lower() and mode['schedulable']:
2✔
191
                    return True
2✔
192
            raise serializers.ValidationError(_(
2✔
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:
2✔
199
        """Choose a mode to set"""
200
        possible_modes = self._modes_group['modes']
2✔
201
        if len(possible_modes) == 1:
2✔
202
            # There is only one mode to choose from, so set that.
203
            return possible_modes[0]['code']
2✔
204
        elif self._modes_group.get('default'):
2✔
205
            return self._modes_group['default']
2✔
206
        elif len(possible_modes) > 1:
2✔
207
            # There are many possible modes, make the user choose.
208
            raise serializers.ValidationError(_(
2✔
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):
2✔
216
    """Class used to validate config based on configuration type"""
217
    def __init__(self, instrument_type: str, configuration_type: str):
2✔
218
        self._instrument_type = instrument_type.lower()
2✔
219
        self._configuration_type = configuration_type
2✔
220

221
    def validate(self, config_dict: dict) -> dict:
2✔
222
        configuration_types = configdb.get_configuration_types(self._instrument_type)
2✔
223
        if self._configuration_type not in configuration_types:
2✔
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]
2✔
228
        validation_schema = configuration_type_properties.get('validation_schema', {})
2✔
229
        validator, validated_config_dict = self._validate_document(config_dict, validation_schema)
2✔
230
        if validator.errors:
2✔
231
            raise serializers.ValidationError(self._cerberus_to_serializer_validation_error(validator.errors))
×
232

233
        return validated_config_dict
2✔
234

235

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

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

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

253

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

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

269

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

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

278

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

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

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

292

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

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

301

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

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

310

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

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

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

335

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

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

349
    def to_representation(self, instance):
2✔
350
        data = super().to_representation(instance)
2✔
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:
2✔
353
            del data['repeat_duration']
2✔
354

355
        return data
2✔
356

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

362
    def validate_instrument_type(self, value):
2✔
363
        is_staff = False
2✔
364
        request_context = self.context.get('request')
2✔
365
        if request_context:
2✔
366
            is_staff = request_context.user.is_staff
2✔
367
        if value and value.upper() not in configdb.get_instrument_type_codes({}, only_schedulable=(not is_staff)):
2✔
368
            raise serializers.ValidationError(
2✔
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
2✔
374

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

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

388
        # Validate the configuration type is available for the instrument requested
389
        if data['type'] not in configuration_types.keys():
2✔
390
            raise serializers.ValidationError(_(
2✔
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):
2✔
394
            raise serializers.ValidationError(_(
2✔
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):
2✔
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
2✔
402
        else:
403
            # Validate acquire modes
404
            acquisition_config = data['acquisition_config']
2✔
405
            acquire_validation_helper = ModeValidationHelper('acquisition', instrument_type, modes['acquisition'])
2✔
406
            acquisition_config = acquire_validation_helper.validate(acquisition_config)
2✔
407
            if not acquisition_config.get('mode'):
2✔
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
2✔
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)
2✔
414
        data = instrument_type_validation_helper.validate(data)
2✔
415

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

419
        available_optical_elements = configdb.get_optical_elements(instrument_type)
2✔
420
        for i, instrument_config in enumerate(data['instrument_configs']):
2✔
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'])
2✔
423
            instrument_config = readout_validation_helper.validate(instrument_config)
2✔
424

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

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

434
            # Check that the optical elements specified are valid in configdb
435
            for oe_type, value in instrument_config.get('optical_elements', {}).items():
2✔
436
                plural_type = '{}s'.format(oe_type)
2✔
437
                if plural_type not in available_optical_elements:
2✔
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]}
2✔
441
                if plural_type in available_optical_elements and value.lower() not in available_elements.keys():
2✔
442
                    raise serializers.ValidationError(_("optical element {} of type {} is not available".format(
2✔
443
                        value, oe_type
444
                    )))
445
                else:
446
                    instrument_config['optical_elements'][oe_type] = available_elements[value.lower()]
2✔
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):
2✔
451
                for oe_type in available_optical_elements.keys():
2✔
452
                    singular_type = oe_type[:-1] if oe_type.endswith('s') else oe_type
2✔
453
                    if singular_type not in instrument_config.get('optical_elements', {}):
2✔
454
                        raise serializers.ValidationError(_(
2✔
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:
2✔
459
                max_rois = configdb.get_max_rois(instrument_type)
2✔
460
                ccd_size = configdb.get_ccd_size(instrument_type)
2✔
461
                if len(instrument_config['rois']) > max_rois:
2✔
462
                    raise serializers.ValidationError(_(
2✔
463
                        f'Instrument type {instrument_type} supports up to {max_rois} regions of interest'
464
                    ))
465
                for roi in instrument_config['rois']:
2✔
466
                    if 'x1' not in roi and 'x2' not in roi and 'y1' not in roi and 'y2' not in roi:
2✔
467
                        raise serializers.ValidationError(_('Must submit at least one bound for a region of interest'))
2✔
468

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

478
                    if roi['x1'] >= roi['x2'] or roi['y1'] >= roi['y2']:
2✔
479
                        raise serializers.ValidationError(_(
2✔
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']:
2✔
484
                        raise serializers.ValidationError(_(
2✔
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:
2✔
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':
2✔
499
            if (
2✔
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(_(
2✔
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']:
2✔
510
            if 'repeat_duration' not in data or data['repeat_duration'] is None:
2✔
511
                raise serializers.ValidationError(_(
2✔
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(
2✔
517
                    [get_instrument_configuration_duration(
518
                        data, index) for index in range(len(data['instrument_configs']))]
519
                )
520
                if min_duration > data['repeat_duration']:
2✔
521
                    raise serializers.ValidationError(_(
2✔
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:
2✔
527
                raise serializers.ValidationError(_(
2✔
528
                    'You may only specify a repeat_duration for REPEAT_* type configurations.'
529
                ))
530

531
        # Validate dither pattern
532

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

541
        dither_pattern_is_set = 'extra_params' in data and 'dither_pattern' in data['extra_params']
2✔
542
        dither_pattern = data.get('extra_params', {}).get('dither_pattern', None)
2✔
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:
2✔
546
            raise serializers.ValidationError(_(
2✔
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:
2✔
554
            valid_patterns = list(settings.DITHER['valid_expansion_patterns']) + [settings.DITHER['custom_pattern_key']]
2✔
555
            if dither_pattern not in valid_patterns:
2✔
556
                raise serializers.ValidationError(_(
2✔
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:
2✔
562
            if 'extra_params' not in data:
2✔
563
                data['extra_params'] = {}
2✔
564
            data['extra_params']['dither_pattern'] = settings.DITHER['custom_pattern_key']
2✔
565

566
        return data
2✔
567

568

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

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

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

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

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

594
        site_data_dict = {site['code']: site for site in configdb.get_site_data()}
2✔
595
        if 'site' in data:
2✔
596
            if data['site'] not in site_data_dict:
2✔
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']
2✔
600
            enc_dict = {enc['code']: enc for enc in enc_set}
2✔
601
            if 'enclosure' in data:
2✔
602
                if data['enclosure'] not in enc_dict:
2✔
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']
2✔
607
                tel_list = [tel['code'] for tel in tel_set]
2✔
608
                if 'telescope' in data and data['telescope'] not in tel_list:
2✔
609
                    msg = _('Telescope {} not valid. Valid choices: {}').format(data['telescope'], ', '.join(tel_list))
×
610
                    raise serializers.ValidationError(msg)
×
611

612
        return data
2✔
613

614
    def to_representation(self, instance):
2✔
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)
2✔
620
        return {key: val for key, val in rep.items() if val}
2✔
621

622

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

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

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

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

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

646

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

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

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

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

671

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

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

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

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

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

698
        return value
2✔
699

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

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

707
        return value
2✔
708

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

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

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

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

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

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

796

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

801
    def validate_cadence(self, value):
2✔
802
        return value
2✔
803

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

UNCOV
808
        return value
×
809

810

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

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

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

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

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

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

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

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

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

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

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

880
        return request_group
2✔
881

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

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

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

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

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

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

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

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

986
        return data
2✔
987

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

993

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

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

1003
        return data
2✔
1004

1005

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

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

1028

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

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

1041
        return request
2✔
1042

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

1084

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

1090

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

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

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

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

1115

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