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

observatorycontrolsystem / observation-portal / 13686447536

05 Mar 2025 10:04PM UTC coverage: 96.553% (-0.003%) from 96.556%
13686447536

Pull #326

github

Jon
Merge branch 'fix/visibility_in_future' of github.com:observatorycontrolsystem/observation-portal into fix/visibility_in_future
Pull Request #326: Changed behavior of checking visibliity windows to ensure that only f…

47 of 49 new or added lines in 7 files covered. (95.92%)

19 existing lines in 5 files now uncovered.

34982 of 36231 relevant lines covered (96.55%)

2.88 hits per line

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

95.5
/observation_portal/requestgroups/serializers.py
1
import json
3✔
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
    """Base class for validating documents"""
41
    @abstractmethod
3✔
42
    def __init__(self):
3✔
43
        pass
×
44

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

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

60
        return validator, validated_config_dict
3✔
61

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

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

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

102

103
class InstrumentTypeValidationHelper(ValidationHelper):
3✔
104
    """Class to validate config based on InstrumentType in ConfigDB"""
105
    def __init__(self, instrument_type: str):
3✔
106
        self.instrument_type = instrument_type
3✔
107

108
    def validate(self, config_dict: dict) -> dict:
3✔
109
        """
110
        Using the validation_schema within the instrument type, validate the configuration
111
        :param config_dict: Configuration dictionary
112
        :return: Validated configuration
113
        :raises: ValidationError if config is invalid
114
        """
115
        instrument_type_dict = configdb.get_instrument_type_by_code(self.instrument_type)
3✔
116
        validation_schema = instrument_type_dict.get('validation_schema', {})
3✔
117
        validator, validated_config_dict = self._validate_document(config_dict, validation_schema)
3✔
118
        if validator.errors:
3✔
119
            raise serializers.ValidationError(self._cerberus_to_serializer_validation_error(validator.errors))
3✔
120

121
        return validated_config_dict
3✔
122

123

124
class ModeValidationHelper(ValidationHelper):
3✔
125
    """Class used to validate GenericModes of different types defined in ConfigDB"""
126
    def __init__(self, mode_type: str, instrument_type: str, modes_group: dict, mode_key='mode', is_extra_param_mode=False):
3✔
127
        self._mode_type = mode_type.lower()
3✔
128
        self._instrument_type = instrument_type
3✔
129
        self._modes_group = modes_group
3✔
130
        self._mode_key = mode_key
3✔
131
        self.is_extra_param_mode = is_extra_param_mode
3✔
132

133
    def _get_mode_from_config_dict(self, config_dict: dict) -> str:
3✔
134
        if self.is_extra_param_mode:
3✔
135
            return config_dict.get('extra_params', {}).get(self._mode_key, '')
3✔
136
        return config_dict.get(self._mode_key, '')
3✔
137

138
    def _set_mode_in_config_dict(self, mode_value: str, config_dict: dict) -> dict:
3✔
139
        if self.is_extra_param_mode:
3✔
140
            if 'extra_params' not in config_dict:
3✔
141
                config_dict['extra_params'] = {}
×
142
            config_dict['extra_params'][self._mode_key] = mode_value
3✔
143
        else:
144
            config_dict[self._mode_key] = mode_value
3✔
145
        return config_dict
3✔
146

147
    def validate(self, config_dict) -> dict:
3✔
148
        """Validates the mode using its relevant configuration dict
149

150
        Returns a validated configuration dict with the mode filled in. If no mode is given in the input
151
        dict, a default mode will be filled in if availble. If the mode has a validation_schema, it will
152
        be used to validate the input dict. If any error is encountered during the process, A serializer
153
        ValidationError will be raised with the error.
154

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

178
    def mode_exists_and_is_schedulable(self, mode_value: str) -> bool:
3✔
179
        if self._modes_group and mode_value:
3✔
180
            for mode in self._modes_group['modes']:
3✔
181
                if mode['code'].lower() == mode_value.lower() and mode['schedulable']:
3✔
182
                    return True
3✔
183
            raise serializers.ValidationError(_(
3✔
184
                f'{self._mode_type.capitalize()} mode {mode_value} is not available for '
185
                f'instrument type {self._instrument_type}'
186
            ))
187
        return True
×
188

189
    def get_default_mode(self) -> str:
3✔
190
        """Choose a mode to set"""
191
        possible_modes = self._modes_group['modes']
3✔
192
        if len(possible_modes) == 1:
3✔
193
            # There is only one mode to choose from, so set that.
194
            return possible_modes[0]['code']
3✔
195
        elif self._modes_group.get('default'):
3✔
196
            return self._modes_group['default']
3✔
197
        elif len(possible_modes) > 1:
3✔
198
            # There are many possible modes, make the user choose.
199
            raise serializers.ValidationError(_(
3✔
200
                f'Must set a {self._mode_type} mode, choose '
201
                f'from {", ".join([mode["code"] for mode in self._modes_group["modes"]])}'
202
            ))
203
        return ''
×
204

205

206
class ConfigurationTypeValidationHelper(ValidationHelper):
3✔
207
    """Class used to validate config based on configuration type"""
208
    def __init__(self, instrument_type: str, configuration_type: str):
3✔
209
        self._instrument_type = instrument_type.lower()
3✔
210
        self._configuration_type = configuration_type
3✔
211

212
    def validate(self, config_dict: dict) -> dict:
3✔
213
        configuration_types = configdb.get_configuration_types(self._instrument_type)
3✔
214
        if self._configuration_type not in configuration_types:
3✔
215
            raise serializers.ValidationError(_(
×
216
                f'configuration type {self._configuration_type} is not valid for instrument type {self._instrument_type}'
217
            ))
218
        configuration_type_properties = configuration_types[self._configuration_type]
3✔
219
        validation_schema = configuration_type_properties.get('validation_schema', {})
3✔
220
        validator, validated_config_dict = self._validate_document(config_dict, validation_schema)
3✔
221
        if validator.errors:
3✔
222
            raise serializers.ValidationError(self._cerberus_to_serializer_validation_error(validator.errors))
×
223

224
        return validated_config_dict
3✔
225

226

227
class CadenceSerializer(serializers.Serializer):
3✔
228
    start = serializers.DateTimeField()
3✔
229
    end = serializers.DateTimeField()
3✔
230
    period = serializers.FloatField(validators=[MinValueValidator(0.02)])
3✔
231
    jitter = serializers.FloatField(validators=[MinValueValidator(0.02)])
3✔
232

233
    def validate_end(self, value):
3✔
234
        if value < timezone.now():
3✔
235
            raise serializers.ValidationError('End time must be in the future')
3✔
236
        return value
3✔
237

238
    def validate(self, data):
3✔
239
        if data['start'] >= data['end']:
3✔
240
            msg = _("Cadence end '{}' cannot be earlier than cadence start '{}'.").format(data['start'], data['end'])
3✔
241
            raise serializers.ValidationError(msg)
3✔
242
        return data
3✔
243

244

245
class ConstraintsSerializer(serializers.ModelSerializer):
3✔
246
    max_airmass = serializers.FloatField(
3✔
247
        default=1.6, validators=[MinValueValidator(1.0), MaxValueValidator(25.0)]  # Duplicated in models.py
248
    )
249
    min_lunar_distance = serializers.FloatField(
3✔
250
        default=30.0, validators=[MinValueValidator(0.0), MaxValueValidator(180.0)]  # Duplicated in models.py
251
    )
252
    max_lunar_phase = serializers.FloatField(
3✔
253
        default=1.0, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]  # Duplicated in models.py
254
    )
255

256
    class Meta:
3✔
257
        model = Constraints
3✔
258
        exclude = Constraints.SERIALIZER_EXCLUDE
3✔
259

260

261
class RegionOfInterestSerializer(serializers.ModelSerializer):
3✔
262
    class Meta:
3✔
263
        model = RegionOfInterest
3✔
264
        exclude = RegionOfInterest.SERIALIZER_EXCLUDE
3✔
265

266
    def validate(self, data):
3✔
267
        return data
3✔
268

269

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

273
    class Meta:
3✔
274
        model = InstrumentConfig
3✔
275
        exclude = InstrumentConfig.SERIALIZER_EXCLUDE
3✔
276

277
    def to_representation(self, instance):
3✔
278
        data = super().to_representation(instance)
3✔
279
        if not data['rois']:
3✔
280
            del data['rois']
3✔
281
        return data
3✔
282

283

284
class AcquisitionConfigSerializer(ExtraParamsFormatter, serializers.ModelSerializer):
3✔
285
    class Meta:
3✔
286
        model = AcquisitionConfig
3✔
287
        exclude = AcquisitionConfig.SERIALIZER_EXCLUDE
3✔
288

289
    def validate(self, data):
3✔
290
        return data
3✔
291

292

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

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

301

302
class TargetSerializer(ExtraParamsFormatter, serializers.ModelSerializer):
3✔
303
    class Meta:
3✔
304
        model = Target
3✔
305
        exclude = Target.SERIALIZER_EXCLUDE
3✔
306
        extra_kwargs = {
3✔
307
            'name': {'error_messages': {'blank': 'Please provide a name.'}}
308
        }
309

310
    def to_representation(self, instance):
3✔
311
        # Only return data for the specific target type
312
        data = super().to_representation(instance)
3✔
313
        target_helper = TARGET_TYPE_HELPER_MAP[data['type']](data)
3✔
314
        target_dict = {k: data.get(k) for k in target_helper.fields if data.get(k) is not None}
3✔
315
        target_dict['extra_params'] = data.get('extra_params', {})
3✔
316
        return target_dict
3✔
317

318
    def validate(self, data):
3✔
319
        target_helper = TARGET_TYPE_HELPER_MAP[data['type']](data)
3✔
320
        if target_helper.is_valid():
3✔
321
            data.update(target_helper.data)
3✔
322
        else:
323
            raise serializers.ValidationError(target_helper.error_dict)
3✔
324
        return data
3✔
325

326

327
class ConfigurationSerializer(ExtraParamsFormatter, serializers.ModelSerializer):
3✔
328
    fill_window = serializers.BooleanField(required=False, write_only=True)
3✔
329
    constraints = import_string(settings.SERIALIZERS['requestgroups']['Constraints'])()
3✔
330
    instrument_configs = import_string(settings.SERIALIZERS['requestgroups']['InstrumentConfig'])(many=True)
3✔
331
    acquisition_config = import_string(settings.SERIALIZERS['requestgroups']['AcquisitionConfig'])()
3✔
332
    guiding_config = import_string(settings.SERIALIZERS['requestgroups']['GuidingConfig'])()
3✔
333
    target = import_string(settings.SERIALIZERS['requestgroups']['Target'])()
3✔
334

335
    class Meta:
3✔
336
        model = Configuration
3✔
337
        exclude = Configuration.SERIALIZER_EXCLUDE
3✔
338
        read_only_fields = ('priority',)
3✔
339

340
    def to_representation(self, instance):
3✔
341
        data = super().to_representation(instance)
3✔
342
        # Only return the repeat duration if its a REPEAT type configuration
343
        if 'REPEAT' not in data.get('type') and 'repeat_duration' in data:
3✔
344
            del data['repeat_duration']
3✔
345

346
        return data
3✔
347

348
    def validate_instrument_configs(self, value):
3✔
349
        if len(value) < 1:
3✔
350
            raise serializers.ValidationError(_('A configuration must have at least one instrument configuration'))
3✔
351
        return value
3✔
352

353
    def validate_instrument_type(self, value):
3✔
354
        is_staff = False
3✔
355
        request_context = self.context.get('request')
3✔
356
        if request_context:
3✔
357
            is_staff = request_context.user.is_staff
3✔
358
        if value and value.upper() not in configdb.get_instrument_type_codes({}, only_schedulable=(not is_staff)):
3✔
359
            raise serializers.ValidationError(
3✔
360
                _('Invalid instrument type {}. Valid instruments may include: {}').format(
361
                    value, ', '.join(configdb.get_instrument_type_codes({}, only_schedulable=(not is_staff)))
362
                )
363
            )
364
        return value
3✔
365

366
    def validate(self, data):
3✔
367
        # TODO: Validate the guiding optical elements on the guiding instrument types
368
        instrument_type = data['instrument_type']
3✔
369
        configuration_types = configdb.get_configuration_types(instrument_type)
3✔
370
        data['type'] = data['type'].upper()
3✔
371
        modes = configdb.get_modes_by_type(instrument_type)
3✔
372
        guiding_config = data['guiding_config']
3✔
373

374
        # Validate the guide mode
375
        guide_validation_helper = ModeValidationHelper('guiding', instrument_type, modes['guiding'])
3✔
376
        guiding_config = guide_validation_helper.validate(guiding_config)
3✔
377
        data['guiding_config'] = guiding_config
3✔
378

379
        # Validate the configuration type is available for the instrument requested
380
        if data['type'] not in configuration_types.keys():
3✔
381
            raise serializers.ValidationError(_(
3✔
382
                f'configuration type {data["type"]} is not valid for instrument type {instrument_type}'
383
            ))
384
        elif not configuration_types.get(data['type'], {}).get('schedulable', False):
3✔
385
            raise serializers.ValidationError(_(
3✔
386
                f'configuration type {data["type"]} is not schedulable for instrument type {instrument_type}'
387
            ))
388

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

403
        # Validate the instrument_type and configuration_type properties related validation schema at the configuration level
404
        instrument_type_validation_helper = InstrumentTypeValidationHelper(instrument_type)
3✔
405
        data = instrument_type_validation_helper.validate(data)
3✔
406

407
        configuration_type_validation_helper = ConfigurationTypeValidationHelper(instrument_type, data['type'])
3✔
408
        data = configuration_type_validation_helper.validate(data)
3✔
409

410
        available_optical_elements = configdb.get_optical_elements(instrument_type)
3✔
411
        for i, instrument_config in enumerate(data['instrument_configs']):
3✔
412
            # Validate the named readout mode if set, or set the default readout mode if left blank
413
            readout_validation_helper = ModeValidationHelper('readout', instrument_type, modes['readout'])
3✔
414
            instrument_config = readout_validation_helper.validate(instrument_config)
3✔
415

416
            data['instrument_configs'][i] = instrument_config
3✔
417

418
            # Validate the rotator modes
419
            if 'rotator' in modes:
3✔
420
                rotator_mode_validation_helper = ModeValidationHelper('rotator', instrument_type, modes['rotator'],
3✔
421
                                                                      mode_key='rotator_mode')
422
                instrument_config = rotator_mode_validation_helper.validate(instrument_config)
3✔
423
                data['instrument_configs'][i] = instrument_config
3✔
424

425
            # Check that the optical elements specified are valid in configdb
426
            for oe_type, value in instrument_config.get('optical_elements', {}).items():
3✔
427
                plural_type = '{}s'.format(oe_type)
3✔
428
                if plural_type not in available_optical_elements:
3✔
429
                    raise serializers.ValidationError(_("optical_element of type {} is not available on {} instruments"
×
430
                                                        .format(oe_type, data['instrument_type'])))
431
                available_elements = {element['code'].lower(): element['code'] for element in available_optical_elements[plural_type]}
3✔
432
                if plural_type in available_optical_elements and value.lower() not in available_elements.keys():
3✔
433
                    raise serializers.ValidationError(_("optical element {} of type {} is not available".format(
3✔
434
                        value, oe_type
435
                    )))
436
                else:
437
                    instrument_config['optical_elements'][oe_type] = available_elements[value.lower()]
3✔
438

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

460
                    if 'x1' not in roi:
3✔
461
                        roi['x1'] = 0
3✔
462
                    if 'x2' not in roi:
3✔
463
                        roi['x2'] = ccd_size['x']
3✔
464
                    if 'y1' not in roi:
3✔
465
                        roi['y1'] = 0
3✔
466
                    if 'y2' not in roi:
3✔
467
                        roi['y2'] = ccd_size['y']
3✔
468

469
                    if roi['x1'] >= roi['x2'] or roi['y1'] >= roi['y2']:
3✔
470
                        raise serializers.ValidationError(_(
3✔
471
                            'Region of interest pixels start must be less than pixels end'
472
                        ))
473

474
                    if roi['x2'] > ccd_size['x'] or roi['y2'] > ccd_size['y']:
3✔
475
                        raise serializers.ValidationError(_(
3✔
476
                            'Regions of interest for instrument type {} must be in range 0<=x<={} and 0<=y<={}'.format(
477
                                instrument_type, ccd_size['x'], ccd_size['y']
478
                            ))
479
                        )
480

481
            # Validate the exposure modes
482
            if 'exposure' in modes:
3✔
483
                exposure_mode_validation_helper = ModeValidationHelper(
×
484
                    'exposure', instrument_type, modes['exposure'], mode_key='exposure_mode', is_extra_param_mode=True
485
                )
486
                instrument_config = exposure_mode_validation_helper.validate(instrument_config)
×
487
                data['instrument_configs'][i] = instrument_config
×
488

489
        if data['type'] == 'SCRIPT':
3✔
490
            if (
3✔
491
                    'extra_params' not in data
492
                    or 'script_name' not in data['extra_params']
493
                    or not data['extra_params']['script_name']
494
            ):
495
                raise serializers.ValidationError(_(
3✔
496
                    'Must specify a script_name in extra_params for SCRIPT configuration type'
497
                ))
498

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

522
        # Validate dither pattern
523

524
        is_dither_sequence = False
3✔
525
        for instrument_config in data['instrument_configs']:
3✔
526
            offset_ra = instrument_config.get('extra_params', {}).get('offset_ra', 0)
3✔
527
            offset_dec = instrument_config.get('extra_params', {}).get('offset_dec', 0)
3✔
528
            if offset_dec != 0 or offset_ra != 0:
3✔
529
                is_dither_sequence = True
3✔
530
                break
3✔
531

532
        dither_pattern_is_set = 'extra_params' in data and 'dither_pattern' in data['extra_params']
3✔
533
        dither_pattern = data.get('extra_params', {}).get('dither_pattern', None)
3✔
534

535
        # Check that if a dither pattern is set, this configuration is actually a dither sequence
536
        if dither_pattern_is_set and not is_dither_sequence:
3✔
537
            raise serializers.ValidationError(_(
3✔
538
                f'You set a dither pattern of {dither_pattern} but did not supply any non-zero dither offsets. You must specify '
539
                'offset_ra and/or offset_dec fields in the extra_params in one or more instrument_configs to create a '
540
                'dither pattern.'
541
            ))
542

543
        # Check that any dither pattern that is set is valid
544
        if dither_pattern_is_set:
3✔
545
            valid_patterns = list(settings.DITHER['valid_expansion_patterns']) + [settings.DITHER['custom_pattern_key']]
3✔
546
            if dither_pattern not in valid_patterns:
3✔
547
                raise serializers.ValidationError(_(
3✔
548
                    f'Invalid dither pattern {dither_pattern} set in the configuration extra_params, choose from {", ".join(valid_patterns)}'
549
                ))
550

551
        # If a dither pattern is not yet set and this is part of a dither sequence, set the custom dither pattern field.
552
        if not dither_pattern_is_set and is_dither_sequence:
3✔
553
            if 'extra_params' not in data:
3✔
554
                data['extra_params'] = {}
3✔
555
            data['extra_params']['dither_pattern'] = settings.DITHER['custom_pattern_key']
3✔
556

557
        return data
3✔
558

559

560
class LocationSerializer(serializers.ModelSerializer):
3✔
561
    site = serializers.ChoiceField(choices=[], required=False)
3✔
562
    enclosure = serializers.ChoiceField(choices=[], required=False)
3✔
563
    telescope = serializers.ChoiceField(choices=[], required=False)
3✔
564
    telescope_class = serializers.ChoiceField(choices=[], required=True)
3✔
565

566
    def __init__(self, *args, **kwargs):
3✔
567
        super().__init__(*args, **kwargs)
3✔
568

569
        # Can't even make migrations without connecting to ConfigDB :(
570
        self.fields["site"].choices = configdb.get_site_tuples()
3✔
571
        self.fields["enclosure"].choices = configdb.get_enclosure_tuples()
3✔
572
        self.fields["telescope"].choices = configdb.get_telescope_tuples()
3✔
573
        self.fields["telescope_class"].choices = configdb.get_telescope_class_tuples()
3✔
574

575
    class Meta:
3✔
576
        model = Location
3✔
577
        exclude = Location.SERIALIZER_EXCLUDE
3✔
578

579
    def validate(self, data):
3✔
580
        if 'enclosure' in data and 'site' not in data:
3✔
581
            raise serializers.ValidationError(_("Must specify a site with an enclosure."))
3✔
582
        if 'telescope' in data and 'enclosure' not in data:
3✔
583
            raise serializers.ValidationError(_("Must specify an enclosure with a telescope."))
3✔
584

585
        site_data_dict = {site['code']: site for site in configdb.get_site_data()}
3✔
586
        if 'site' in data:
3✔
587
            if data['site'] not in site_data_dict:
3✔
588
                msg = _('Site {} not valid. Valid choices: {}').format(data['site'], ', '.join(site_data_dict.keys()))
×
589
                raise serializers.ValidationError(msg)
×
590
            enc_set = site_data_dict[data['site']]['enclosure_set']
3✔
591
            enc_dict = {enc['code']: enc for enc in enc_set}
3✔
592
            if 'enclosure' in data:
3✔
593
                if data['enclosure'] not in enc_dict:
3✔
594
                    raise serializers.ValidationError(_(
×
595
                        f'Enclosure {data["enclosure"]} not valid. Valid choices: {", ".join(enc_dict.keys())}'
596
                    ))
597
                tel_set = enc_dict[data['enclosure']]['telescope_set']
3✔
598
                tel_list = [tel['code'] for tel in tel_set]
3✔
599
                if 'telescope' in data and data['telescope'] not in tel_list:
3✔
600
                    msg = _('Telescope {} not valid. Valid choices: {}').format(data['telescope'], ', '.join(tel_list))
×
601
                    raise serializers.ValidationError(msg)
×
602

603
        return data
3✔
604

605
    def to_representation(self, instance):
3✔
606
        """
607
        This method is overridden to remove blank fields from serialized output. We could put this into a subclassed
608
        ModelSerializer if we want it to apply to all our Serializers.
609
        """
610
        rep = super().to_representation(instance)
3✔
611
        return {key: val for key, val in rep.items() if val}
3✔
612

613

614
class WindowSerializer(serializers.ModelSerializer):
3✔
615
    start = serializers.DateTimeField(required=False)
3✔
616

617
    class Meta:
3✔
618
        model = Window
3✔
619
        exclude = Window.SERIALIZER_EXCLUDE
3✔
620

621
    def validate(self, data):
3✔
622
        if 'start' not in data:
3✔
623
            data['start'] = timezone.now()
×
624
        if data['end'] <= data['start']:
3✔
625
            msg = _(f"Window end '{data['end']}' cannot be earlier than window start '{data['start']}'")
3✔
626
            raise serializers.ValidationError(msg)
3✔
627

628
        if not get_semester_in(data['start'], data['end']):
3✔
629
            raise serializers.ValidationError('The observation window does not fit within any defined semester.')
3✔
630
        return data
3✔
631

632
    def validate_end(self, value):
3✔
633
        if value < timezone.now():
3✔
634
            raise serializers.ValidationError('Window end time must be in the future')
×
635
        return value
3✔
636

637

638
class RequestSerializer(serializers.ModelSerializer):
3✔
639
    location = import_string(settings.SERIALIZERS['requestgroups']['Location'])()
3✔
640
    configurations = import_string(settings.SERIALIZERS['requestgroups']['Configuration'])(many=True)
3✔
641
    windows = import_string(settings.SERIALIZERS['requestgroups']['Window'])(many=True)
3✔
642
    cadence = import_string(settings.SERIALIZERS['requestgroups']['Cadence'])(required=False, write_only=True)
3✔
643
    duration = serializers.ReadOnlyField()
3✔
644

645
    class Meta:
3✔
646
        model = Request
3✔
647
        read_only_fields = (
3✔
648
            'id', 'created', 'duration', 'state',
649
        )
650
        exclude = Request.SERIALIZER_EXCLUDE
3✔
651

652
    def validate_configurations(self, value):
3✔
653
        if not value:
3✔
654
            raise serializers.ValidationError(_('You must specify at least 1 configuration'))
3✔
655

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

660
        # Set the relative priority of molecules in order
661
        for i, configuration in enumerate(value):
3✔
662
            configuration['priority'] = i + 1
3✔
663

664
        return value
3✔
665

666
    def validate_windows(self, value):
3✔
667
        if not value:
3✔
668
            raise serializers.ValidationError(_('You must specify at least 1 window'))
3✔
669

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

673
        return value
3✔
674

675
    def validate_cadence(self, value):
3✔
676
        if value:
3✔
677
            raise serializers.ValidationError(_('Please use the cadence endpoint to expand your cadence request'))
3✔
678
        return value
×
679

680
    def validate(self, data):
3✔
681
        is_staff = False
3✔
682
        only_schedulable = True
3✔
683
        request_context = self.context.get('request')
3✔
684
        if request_context:
3✔
685
            is_staff = request_context.user.is_staff
3✔
686
            only_schedulable = not (is_staff and ConfigDB.is_location_fully_set(data.get('location', {})))
3✔
687
        # check if the instrument specified is allowed
688
        # TODO: Check if ALL instruments are available at a resource defined by location
689
        if 'location' in data:
3✔
690
            # Check if the location is fully specified, and if not then use only schedulable instruments
691
            valid_instruments = configdb.get_instrument_type_codes(data.get('location', {}),
3✔
692
                                                              only_schedulable=only_schedulable)
693
            for configuration in data['configurations']:
3✔
694
                if configuration['instrument_type'].upper() not in valid_instruments:
3✔
695
                    msg = _("Invalid instrument type '{}' at site={}, enc={}, tel={}. \n").format(
3✔
696
                        configuration['instrument_type'],
697
                        data.get('location', {}).get('site', 'Any'),
698
                        data.get('location', {}).get('enclosure', 'Any'),
699
                        data.get('location', {}).get('telescope', 'Any')
700
                    )
701
                    msg += _("Valid instruments include: ")
3✔
702
                    for inst_name in valid_instruments:
3✔
703
                        msg += inst_name + ', '
3✔
704
                    msg += '.'
3✔
705
                    if is_staff and not only_schedulable:
3✔
706
                        msg += '\nStaff users must fully specify location to schedule on non-SCHEDULABLE instruments'
×
707
                    raise serializers.ValidationError(msg)
3✔
708

709
        if 'acceptability_threshold' not in data:
3✔
710
            data['acceptability_threshold'] = max(
3✔
711
                [configdb.get_default_acceptability_threshold(configuration['instrument_type'])
712
                 for configuration in data['configurations']]
713
            )
714

715
        if 'extra_params' in data and 'mosaic_pattern' in data['extra_params']:
3✔
716
            pattern = data['extra_params']['mosaic_pattern']
3✔
717
            valid_patterns = list(settings.MOSAIC['valid_expansion_patterns']) + [settings.MOSAIC['custom_pattern_key']]
3✔
718
            if pattern not in valid_patterns:
3✔
719
                raise serializers.ValidationError(_(
3✔
720
                    f'Invalid mosaic pattern {pattern} set in the request extra_params, choose from {", ".join(valid_patterns)}'
721
                ))
722

723
        # check that the requests window has enough rise_set visible time to accomodate the requests duration
724
        if data.get('windows'):
3✔
725
            duration = get_total_request_duration(data)
3✔
726
            rise_set_intervals_by_site = get_filtered_rise_set_intervals_by_site(data, is_staff=is_staff)
3✔
727
            largest_interval = get_largest_interval(rise_set_intervals_by_site, exclude_past=True)
3✔
728
            for configuration in data['configurations']:
3✔
729
                if 'REPEAT' in configuration['type'].upper() and configuration.get('fill_window'):
3✔
730
                    max_configuration_duration = largest_interval.total_seconds() - duration + configuration.get('repeat_duration', 0) - 1
3✔
731
                    configuration['repeat_duration'] = max_configuration_duration
3✔
732
                    duration = get_total_request_duration(data)
3✔
733

734
                # delete the fill window attribute, it is only used for this validation
735
                try:
3✔
736
                    del configuration['fill_window']
3✔
737
                except KeyError:
3✔
738
                    pass
3✔
739
            if largest_interval.total_seconds() <= 0:
3✔
740
                raise serializers.ValidationError(
3✔
741
                    _(
742
                        'According to the constraints of the request, the target will not be visible within the '
743
                        'time window. Check that the target is in the nighttime sky. Consider modifying the time '
744
                        'window or loosening the airmass or lunar separation constraints. If the target is '
745
                        'non sidereal, double check that the provided elements are correct.'
746
                    )
747
                )
748
            if largest_interval.total_seconds() <= duration:
3✔
749
                raise serializers.ValidationError(
3✔
750
                    (
751
                        'According to the constraints of the request, the target will only be visible for a maximum '
752
                        'of {0:.2f} hours within the time window. '
753
                        'This is less than the duration of your request {1:.2f} hours. '
754
                        'Consider expanding the time window or loosening the airmass or lunar separation constraints.'
755
                    ).format(
756
                        largest_interval.total_seconds() / 3600.0,
757
                        duration / 3600.0
758
                    )
759
                )
760
        return data
3✔
761

762

763
class CadenceRequestSerializer(RequestSerializer):
3✔
764
    cadence = import_string(settings.SERIALIZERS['requestgroups']['Cadence'])()
3✔
765
    windows = import_string(settings.SERIALIZERS['requestgroups']['Window'])(required=False, many=True)
3✔
766

767
    def validate_cadence(self, value):
3✔
768
        return value
3✔
769

770
    def validate_windows(self, value):
3✔
771
        if value:
3✔
772
            raise serializers.ValidationError(_('Cadence requests may not contain windows'))
3✔
773

UNCOV
774
        return value
×
775

776

777
class RequestGroupSerializer(serializers.ModelSerializer):
3✔
778
    requests = import_string(settings.SERIALIZERS['requestgroups']['Request'])(many=True)
3✔
779
    submitter = serializers.StringRelatedField(default=serializers.CurrentUserDefault(), read_only=True)
3✔
780
    submitter_id = serializers.CharField(write_only=True, required=False)
3✔
781

782
    class Meta:
3✔
783
        model = RequestGroup
3✔
784
        fields = '__all__'
3✔
785
        read_only_fields = (
3✔
786
            'id', 'created', 'state', 'modified'
787
        )
788
        extra_kwargs = {
3✔
789
            'proposal': {'error_messages': {'null': 'Please provide a proposal.'}},
790
            'name': {'error_messages': {'blank': 'Please provide a name.'}}
791
        }
792

793
    def create(self, validated_data):
3✔
794
        request_data = validated_data.pop('requests')
3✔
795
        now = timezone.now()
3✔
796
        with transaction.atomic():
3✔
797
            request_group = RequestGroup.objects.create(**validated_data)
3✔
798

799
            for r in request_data:
3✔
800
                configurations_data = r.pop('configurations')
3✔
801

802
                location_data = r.pop('location', {})
3✔
803
                windows_data = r.pop('windows', [])
3✔
804
                request = Request.objects.create(request_group=request_group, **r)
3✔
805

806
                if validated_data['observation_type'] not in RequestGroup.NON_SCHEDULED_TYPES:
3✔
807
                    Location.objects.create(request=request, **location_data)
3✔
808
                    for window_data in windows_data:
3✔
809
                        Window.objects.create(request=request, **window_data)
3✔
810

811
                for configuration_data in configurations_data:
3✔
812
                    instrument_configs_data = configuration_data.pop('instrument_configs')
3✔
813
                    acquisition_config_data = configuration_data.pop('acquisition_config')
3✔
814
                    guiding_config_data = configuration_data.pop('guiding_config')
3✔
815
                    target_data = configuration_data.pop('target')
3✔
816
                    constraints_data = configuration_data.pop('constraints')
3✔
817
                    configuration = Configuration.objects.create(request=request, **configuration_data)
3✔
818

819
                    AcquisitionConfig.objects.create(configuration=configuration, **acquisition_config_data)
3✔
820
                    GuidingConfig.objects.create(configuration=configuration, **guiding_config_data)
3✔
821
                    Target.objects.create(configuration=configuration, **target_data)
3✔
822
                    Constraints.objects.create(configuration=configuration, **constraints_data)
3✔
823

824
                    for instrument_config_data in instrument_configs_data:
3✔
825
                        rois_data = []
3✔
826
                        if 'rois' in instrument_config_data:
3✔
827
                            rois_data = instrument_config_data.pop('rois')
3✔
828
                        instrument_config = InstrumentConfig.objects.create(configuration=configuration,
3✔
829
                                                                            **instrument_config_data)
830
                        for roi_data in rois_data:
3✔
831
                            RegionOfInterest.objects.create(instrument_config=instrument_config, **roi_data)
3✔
832
                telescope_class = location_data.get('telescope_class')
3✔
833
                if telescope_class:
3✔
834
                    cache.set(f"observation_portal_last_change_time_{telescope_class}", now, None)
3✔
835

836
        if validated_data['observation_type'] == RequestGroup.NORMAL:
3✔
837
            debit_ipp_time(request_group)
3✔
838

839
        logger.info('RequestGroup created', extra={'tags': {
3✔
840
            'user': request_group.submitter.username,
841
            'tracking_num': request_group.id,
842
            'name': request_group.name
843
        }})
844
        cache.set('observation_portal_last_change_time_all', now, None)
3✔
845

846
        return request_group
3✔
847

848
    def validate(self, data):
3✔
849
        # check that the user belongs to the supplied proposal
850
        user = self.context['request'].user
3✔
851
        if data['proposal'] not in user.proposal_set.all():
3✔
852
            raise serializers.ValidationError(
3✔
853
                _('You do not belong to the proposal you are trying to submit with')
854
            )
855

856
        if not user.proposal_set.filter(id=data['proposal'], active=True).exists():
3✔
857
            raise serializers.ValidationError(
3✔
858
                _('The proposal you are trying to submit with is currently inactive')
859
            )
860

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

866
        # validation on the operator matching the number of requests
867
        if data['operator'] == 'SINGLE':
3✔
868
            if len(data['requests']) > 1:
3✔
869
                raise serializers.ValidationError(
3✔
870
                    _("'Single' type requestgroups must have exactly one child request.")
871
                )
872
        elif len(data['requests']) == 1:
3✔
UNCOV
873
            raise serializers.ValidationError(
×
874
                _("'{}' type requestgroups must have more than one child request.".format(data['operator'].title()))
875
            )
876

877
        # Check that the user has not exceeded the time limit on this membership
878
        membership = Membership.objects.get(user=user, proposal=data['proposal'])
3✔
879
        if membership.time_limit >= 0:
3✔
880
            duration = sum(d for i, d in get_requestgroup_duration(data).items())
3✔
881
            time_to_be_used = user.profile.time_used_in_proposal(data['proposal']) + duration
3✔
882
            if membership.time_limit < time_to_be_used:
3✔
883
                raise serializers.ValidationError(
3✔
884
                    _('This request\'s duration will exceed the time limit set for your account on this proposal.')
885
                )
886

887
        if data['observation_type'] in RequestGroup.NON_SCHEDULED_TYPES:
3✔
888
            # Don't do any time accounting stuff if it is a directly scheduled observation
889
            return data
3✔
890
        else:
891
            for request in data['requests']:
3✔
892
                for config in request['configurations']:
3✔
893
                    # for scheduled observations, don't allow HOUR_ANGLE targets (they're not supported in rise-set yet)
894
                    if config['target']['type'] == 'HOUR_ANGLE':
3✔
895
                        raise serializers.ValidationError(_('HOUR_ANGLE Target type not supported in scheduled observations'))
3✔
896

897
        try:
3✔
898
            total_duration_dict = get_total_duration_dict(data)
3✔
899
            for tak, duration in total_duration_dict.items():
3✔
900
                time_allocation = TimeAllocation.objects.get(
3✔
901
                    semester=tak.semester,
902
                    instrument_types__contains=[tak.instrument_type],
903
                    proposal=data['proposal'],
904
                )
905
                time_available = 0
3✔
906
                if data['observation_type'] == RequestGroup.NORMAL:
3✔
907
                    time_available = time_allocation.std_allocation - time_allocation.std_time_used
3✔
908
                elif data['observation_type'] == RequestGroup.RAPID_RESPONSE:
3✔
909
                    time_available = time_allocation.rr_allocation - time_allocation.rr_time_used
3✔
910
                    # For Rapid Response observations, check if the end time of the window is within
911
                    # 24 hours + the duration of the observation
912
                    for request in data['requests']:
3✔
913
                        windows = request.get('windows')
3✔
914
                        for window in windows:
3✔
915
                            if window.get('start') - timezone.now() > timedelta(seconds=0):
3✔
916
                                raise serializers.ValidationError(
3✔
917
                                    _("The Rapid Response observation window start time cannot be in the future.")
918
                                )
919
                            if window.get('end') - timezone.now() > timedelta(seconds=(duration + 86400)):
3✔
920
                                raise serializers.ValidationError(
3✔
921
                                    _(
922
                                        "A Rapid Response observation must start within the next 24 hours, so the "
923
                                        "window end time must be within the next (24 hours + the observation duration)"
924
                                    )
925
                                )
926
                elif data['observation_type'] == RequestGroup.TIME_CRITICAL:
3✔
927
                    # Time critical time
928
                    time_available = time_allocation.tc_allocation - time_allocation.tc_time_used
3✔
929

930
                if time_available <= 0.0:
3✔
931
                    raise serializers.ValidationError(
3✔
932
                        _("Proposal {} does not have any {} time left allocated in semester {} on {} instruments").format(
933
                            data['proposal'], data['observation_type'], tak.semester, tak.instrument_type)
934
                    )
935
                elif time_available * settings.PROPOSAL_TIME_OVERUSE_ALLOWANCE < (duration / 3600.0):
3✔
936
                    raise serializers.ValidationError(
3✔
937
                        _("Proposal {} does not have enough {} time allocated in semester {}").format(
938
                            data['proposal'], data['observation_type'], tak.semester)
939
                    )
940
            # validate the ipp debitting that will take place later
941
            if data['observation_type'] == RequestGroup.NORMAL:
3✔
942
                validate_ipp(data, total_duration_dict)
3✔
943
        except ObjectDoesNotExist:
3✔
944
            raise serializers.ValidationError(
3✔
945
                _("You do not have sufficient {} time allocated on the instrument you're requesting for this proposal.".format(
946
                    data['observation_type']
947
                ))
948
            )
949
        except TimeAllocationError as e:
3✔
950
            raise serializers.ValidationError(repr(e))
3✔
951

952
        return data
3✔
953

954
    def validate_requests(self, value):
3✔
955
        if not value:
3✔
956
            raise serializers.ValidationError(_('You must specify at least 1 request'))
3✔
957
        return value
3✔
958

959

960
class CadenceRequestGroupSerializer(RequestGroupSerializer):
3✔
961
    requests = import_string(settings.SERIALIZERS['requestgroups']['CadenceRequest'])(many=True)
3✔
962

963
    # override the validate method from the RequestGroupSerializer and use the Cadence Request serializer to
964
    # validate the cadence request
965
    def validate(self, data):
3✔
966
        if len(data['requests']) > 1:
3✔
967
            raise ValidationError('Cadence requestgroups may only contain a single request')
3✔
968

969
        return data
3✔
970

971

972
class PatternExpansionSerializer(serializers.Serializer):
3✔
973
    pattern = serializers.ChoiceField(choices=('line', 'grid', 'spiral'), required=True)
3✔
974
    num_points = serializers.IntegerField(required=False)
3✔
975
    point_spacing = serializers.FloatField(required=False)
3✔
976
    line_spacing = serializers.FloatField(required=False)
3✔
977
    orientation = serializers.FloatField(required=False, default=0.0)
3✔
978
    num_rows = serializers.IntegerField(required=False)
3✔
979
    num_columns = serializers.IntegerField(required=False)
3✔
980
    center = serializers.BooleanField(required=False, default=False)
3✔
981

982
    def validate(self, data):
3✔
983
        validated_data = super().validate(data)
3✔
984
        if 'num_points' not in validated_data and validated_data.get('pattern') in ['line', 'spiral']:
3✔
985
            raise serializers.ValidationError(_('Must specify num_points when selecting a line or spiral pattern'))
3✔
986
        if 'line_spacing' not in validated_data and 'point_spacing' in validated_data and validated_data.get('pattern') == 'grid':
3✔
987
            # Set a default line spacing equal to the point spacing if it is not specified
988
            validated_data['line_spacing'] = validated_data['point_spacing']
3✔
989
        if validated_data.get('pattern') == 'grid':
3✔
990
            if 'num_rows' not in validated_data or 'num_columns' not in validated_data:
3✔
991
                raise serializers.ValidationError(_('Must specify num_rows and num_columns when selecting a grid pattern'))
3✔
992
        return validated_data
3✔
993

994

995
class MosaicSerializer(PatternExpansionSerializer):
3✔
996
    request = import_string(settings.SERIALIZERS['requestgroups']['Request'])()
3✔
997
    pattern = serializers.ChoiceField(choices=settings.MOSAIC['valid_expansion_patterns'], required=True)
3✔
998
    point_overlap_percent = serializers.FloatField(required=False, validators=[MinValueValidator(0.0), MaxValueValidator(100.0)])
3✔
999
    line_overlap_percent = serializers.FloatField(required=False, validators=[MinValueValidator(0.0), MaxValueValidator(100.0)])
3✔
1000

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

1007
        return request
3✔
1008

1009
    def validate(self, data):
3✔
1010
        validated_data = super().validate(data)
3✔
1011
        # If point_overlap_percent is set, we will overwrite the point_spacing based on the requested
1012
        # instrument_type and its fov on horizontal axis. If line_overlap_percent is specified, we will
1013
        # do the same for the fov on the vertical axis - if it is not specified we will use the
1014
        # point_overlap_percent and overwrite the line_spacing value
1015
        if 'point_overlap_percent' in validated_data:
3✔
1016
            instrument_type = data['request']['configurations'][0]['instrument_type']
3✔
1017
            ccd_orientation = configdb.get_average_ccd_orientation(instrument_type)
3✔
1018
            pattern_orientation = validated_data.get('orientation', 0.0)
3✔
1019
            pattern_orientation = pattern_orientation % 360
3✔
1020
            # Decide to flip the point/line overlapped sense based on general orientation
1021
            if pattern_orientation < 45 or pattern_orientation > 315:
3✔
1022
                flip = False
3✔
1023
            elif pattern_orientation < 135:
3✔
1024
                flip = True
3✔
1025
            elif pattern_orientation < 225:
×
1026
                flip = False
×
1027
            elif pattern_orientation < 315:
×
UNCOV
1028
                flip = True
×
1029
            ccd_size = configdb.get_ccd_size(instrument_type)
3✔
1030
            pixel_scale = configdb.get_pixel_scale(instrument_type)
3✔
1031
            coso = cos(radians(ccd_orientation))
3✔
1032
            sino = sin(radians(ccd_orientation))
3✔
1033
            # Rotate the ccd dimensions by the ccd orientation - needed so our % overlap is in the correct frame
1034
            rotated_ccd_x = ccd_size['x'] * coso + ccd_size['y'] * sino
3✔
1035
            rotated_ccd_y = ccd_size['x'] * -sino + ccd_size['y'] * coso
3✔
1036
            if 'line_overlap_percent' not in validated_data:
3✔
1037
                validated_data['line_overlap_percent'] = validated_data['point_overlap_percent']
3✔
1038
            validated_data['point_spacing'] = abs(rotated_ccd_y) * pixel_scale * ((100.0 - validated_data['point_overlap_percent']) / 100.0)
3✔
1039
            validated_data['line_spacing'] = abs(rotated_ccd_x) * pixel_scale * ((100.0 - validated_data['line_overlap_percent']) / 100.0)
3✔
1040
            if flip:
3✔
1041
                # 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
1042
                temp = validated_data['line_spacing']
3✔
1043
                validated_data['line_spacing'] = validated_data['point_spacing']
3✔
1044
                validated_data['point_spacing'] = temp
3✔
1045
        elif 'point_spacing' not in validated_data:
3✔
1046
            # One of point_spacing or point_overlap_percent must be specified
1047
            raise serializers.ValidationError(_("Must specify one of point_spacing or point_overlap_percent"))
3✔
1048
        return validated_data
3✔
1049

1050

1051
class DitherSerializer(PatternExpansionSerializer):
3✔
1052
    configuration = import_string(settings.SERIALIZERS['requestgroups']['Configuration'])()
3✔
1053
    pattern = serializers.ChoiceField(choices=settings.DITHER['valid_expansion_patterns'], required=True)
3✔
1054
    point_spacing = serializers.FloatField(required=True)
3✔
1055

1056

1057
class DraftRequestGroupSerializer(serializers.ModelSerializer):
3✔
1058
    author = serializers.SlugRelatedField(
3✔
1059
        read_only=True,
1060
        slug_field='username',
1061
        default=serializers.CurrentUserDefault()
1062
    )
1063

1064
    class Meta:
3✔
1065
        model = DraftRequestGroup
3✔
1066
        fields = '__all__'
3✔
1067
        read_only_fields = ('author',)
3✔
1068

1069
    def validate(self, data):
3✔
1070
        if data['proposal'] not in self.context['request'].user.proposal_set.all():
3✔
1071
            raise serializers.ValidationError('You are not a member of that proposal')
3✔
1072
        return data
3✔
1073

1074
    def validate_content(self, data):
3✔
1075
        try:
3✔
1076
            json.loads(data)
3✔
1077
        except JSONDecodeError:
3✔
1078
            raise serializers.ValidationError('Content must be valid JSON')
3✔
1079
        return data
3✔
1080

1081

1082
class LastChangedSerializer(serializers.Serializer):
3✔
1083
    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