diff --git a/README.md b/README.md index 6832a36..42412ea 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ This project is configured using environment variables. | `OAUTH_TOKEN_URL` | OAuth2 token URL, set this to use OAuth2 authentication | `""` | | `OAUTH_PROFILE_URL` | Observation portal profile endpoint, used to retrieve details on user accounts | `""` | | `OAUTH_SERVER_KEY` | Observation portal server secret key to authenticate calls from the server | `""` | +| `HEROIC_API_URL` | HEROIC server api url, required for submitting your observatory updates to the HEROIC service | `""` | +| `HEROIC_API_TOKEN` | HEROIC server api token, required for submitting your observatory updates to the HEROIC service | `""` | +| `HEROIC_OBSERVATORY` | HEROIC server observatory code, required for submitting your observatory updates to the HEROIC service | `""` | +| `HEROIC_EXCLUDE_SITES` | Comma delimited list of site codes to ignore when sending updates to HEROIC | `""` | +| `HEROIC_EXCLUDE_TELESCOPES` | Comma delimited list of site.enclosure.telescope codes to ignore when sending updates to HEROIC | `""` | ## Local Development @@ -145,3 +150,7 @@ Return a specific camera's configuration Return all instruments that are in the SCHEDULABLE state GET /instruments/?state=SCHEDULABLE + +## Sending data to HEROIC + +HEROIC is a service provided by Scimma through the NSF that accepts and stores observatory information, including instrument configuration and telescope status. By default, no data will be sent to the HEROIC service. If you want to send your observatory updates to HEROIC, you must set all the `HEROIC_*` environment variables. You must login to the HEROIC server, retrieve your API token, and request that an Observatory is created for you with your account as the admin for that observatory. Afterwards, by setting the appropriate environment variables your configuration database should automatically send updates to HEROIC when updates are made through the API or admin interface. diff --git a/configdb/hardware/admin.py b/configdb/hardware/admin.py index d0602aa..c471c24 100644 --- a/configdb/hardware/admin.py +++ b/configdb/hardware/admin.py @@ -11,6 +11,7 @@ from reversion.admin import VersionAdmin from reversion.errors import RegistrationError +from configdb.hardware.heroic import update_heroic_instrument_capabilities from configdb.hardware.models import ( Site, Enclosure, GenericMode, ModeType, GenericModeGroup, Telescope, Instrument, Camera, CameraType, OpticalElementGroup, OpticalElement, InstrumentType, InstrumentCategory, ConfigurationType, ConfigurationTypeProperties @@ -165,12 +166,27 @@ class InstrumentAdmin(HardwareAdmin): def science_camera_codes(self, obj): return ','.join([science_camera.code for science_camera in obj.science_cameras.all()]) + def save_related(self, request, form, formsets, change): + # This is the best way to trigger on saving m2m admin relationships on the Instrument, like Science Cameras + finished = super().save_related(request, form, formsets, change) + update_heroic_instrument_capabilities(form.instance) + return finished + @admin.register(Camera) class CameraAdmin(HardwareAdmin): form = CameraAdminForm list_display = ('code', 'camera_type') search_fields = ('code',) + def save_related(self, request, form, formsets, change): + # This is the best way to trigger on saving m2m admin relationships on the Camera, like Optical Element Groups + old_oegs = set(form.instance.optical_element_groups.all()) + finished = super().save_related(request, form, formsets, change) + if old_oegs != set(form.instance.optical_element_groups.all()): + for instrument in form.instance.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + return finished + @admin.register(InstrumentType) class InstrumentTypeAdmin(HardwareAdmin): @@ -193,6 +209,30 @@ class GenericModeGroupAdmin(HardwareAdmin): search_fields = ('instrument_type', 'type') list_filter = ('type', 'instrument_type') + def save_model(self, request, obj, form, change): + old_instrument_type = None + if obj.pk: + old_obj = self.model.objects.get(pk=obj.pk) + if old_obj.instrument_type != obj.instrument_type: + old_instrument_type = old_obj.instrument_type + # Now update the model so the new model details are saved + finished = super().save_model(request, obj, form, change) + + if old_instrument_type: + # The instrument_type has changed so update heroic for the old instrument type + for instrument in old_instrument_type.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + return finished + + def save_related(self, request, form, formsets, change): + # This is the best way to trigger on saving m2m admin relationships on the GenericModeGroup, + # like when its members change + finished = super().save_related(request, form, formsets, change) + if form.instance.instrument_type: + for instrument in form.instance.instrument_type.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + return finished + @admin.register(GenericMode) class GenericModeAdmin(HardwareAdmin): @@ -200,6 +240,16 @@ class GenericModeAdmin(HardwareAdmin): list_display = ('name', 'code', 'overhead', 'schedulable') search_fields = ('name', 'code') + def save_related(self, request, form, formsets, change): + # This is the best way to trigger on saving m2m admin relationships on the GenericMode, + # like when its membersships change + finished = super().save_related(request, form, formsets, change) + for gmg in form.instance.genericmodegroup_set.all(): + if gmg.instrument_type: + for instrument in gmg.instrument_type.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + return finished + @admin.register(OpticalElementGroup) class OpticalElementGroupAdmin(HardwareAdmin): @@ -208,12 +258,31 @@ class OpticalElementGroupAdmin(HardwareAdmin): search_fields = ('name', 'type') list_filter = ('type',) + def save_related(self, request, form, formsets, change): + # This is the best way to trigger on saving m2m admin relationships on the OpticalElementGroup, + # like when its members change + finished = super().save_related(request, form, formsets, change) + for camera in form.instance.camera_set.all(): + for instrument in camera.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + return finished + @admin.register(OpticalElement) class OpticalElementAdmin(HardwareAdmin): list_display = ('name', 'code', 'schedulable') search_fields = ('name', 'code') + def save_related(self, request, form, formsets, change): + # This is the best way to trigger on saving m2m admin relationships on the OpticalElement, + # like when its memberships change + finished = super().save_related(request, form, formsets, change) + for oeg in form.instance.opticalelementgroup_set.all(): + for camera in oeg.camera_set.all(): + for instrument in camera.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + return finished + @admin.register(LogEntry) class LogEntryAdmin(admin.ModelAdmin): diff --git a/configdb/hardware/apps.py b/configdb/hardware/apps.py new file mode 100644 index 0000000..2a7412f --- /dev/null +++ b/configdb/hardware/apps.py @@ -0,0 +1,16 @@ +from django.apps import AppConfig +from django.conf import settings + + +def can_submit_to_heroic(): + return settings.HEROIC_API_URL and settings.HEROIC_API_TOKEN and settings.HEROIC_OBSERVATORY + + +class HardwareConfig(AppConfig): + name = 'configdb.hardware' + + def ready(self): + # Only load the heroic communication signals if heroic settings are set + if can_submit_to_heroic(): + import configdb.hardware.signals.handlers # noqa + super().ready() diff --git a/configdb/hardware/heroic.py b/configdb/hardware/heroic.py new file mode 100644 index 0000000..b35a62c --- /dev/null +++ b/configdb/hardware/heroic.py @@ -0,0 +1,214 @@ +from django.conf import settings +from urllib.parse import urljoin +import requests +import logging + +from configdb.hardware.models import Instrument, Telescope, Site +from configdb.hardware.apps import can_submit_to_heroic + + +logger = logging.getLogger() + + +def instrument_status_conversion(state: str): + ''' Converts instrument state to HEROIC instrument status + ''' + if state == 'DISABLED' or state == 'MANUAL': + return 'UNAVAILABLE' + elif state == 'SCHEDULABLE': + return 'SCHEDULABLE' + else: + return 'AVAILABLE' + + +def telescope_status_conversion(telescope: Telescope): + return 'SCHEDULABLE' if telescope.active and telescope.enclosure.active and telescope.enclosure.site.active else 'UNAVAILABLE' + + +def heroic_site_id(site: Site): + ''' Extract a HEROIC id for the site. + This concatenates the observatory.site for human readability + ''' + return f"{settings.HEROIC_OBSERVATORY}.{site.code}" + + +def heroic_telescope_id(telescope: Telescope): + ''' Extract a HEROIC id for the telescope + This concatenates the observatory.site.telescope for human readability + ''' + return f"{heroic_site_id(telescope.enclosure.site)}.{telescope.enclosure.code}-{telescope.code}" + + +def heroic_instrument_id(instrument: Instrument): + ''' Extract a HEROIC id for the instrument + This concatenates the observatory.site.telescope.instrument + for human-readability. + ''' + return f"{heroic_telescope_id(instrument.telescope)}.{instrument.code}" + + +def heroic_optical_element_groups(instrument: Instrument): + ''' Puts the optical element groups of an instrument in the format for reporting to HEROIC + ''' + optical_element_groups = {} + for camera in instrument.science_cameras.all(): + for optical_element_group in camera.optical_element_groups.all(): + optical_element_groups[optical_element_group.type] = {'options': []} + if optical_element_group.default: + optical_element_groups[optical_element_group.type]['default'] = optical_element_group.default.code + for optical_element in optical_element_group.optical_elements.all(): + optical_element_groups[optical_element_group.type]['options'].append({ + 'id': optical_element.code, + 'name': optical_element.name, + 'schedulable': optical_element.schedulable + }) + return optical_element_groups + + +def heroic_operation_modes(instrument: Instrument): + ''' Puts the generic mode groups of an instrument in the format for reporting to HEROIC + ''' + operation_modes = {} + for generic_mode_group in instrument.instrument_type.mode_types.all(): + operation_modes[generic_mode_group.type.id] = {'options': []} + if generic_mode_group.default: + operation_modes[generic_mode_group.type.id]['default'] = generic_mode_group.default.code + for mode in generic_mode_group.modes.all(): + operation_modes[generic_mode_group.type.id]['options'].append({ + 'id': mode.code, + 'name': mode.name, + 'schedulable': mode.schedulable + }) + return operation_modes + + +def instrument_to_heroic_instrument_capabilities(instrument: Instrument): + ''' Extracts the current instrument capabilities of an instrument to send to HEROIC + ''' + capabilities = { + 'instrument': heroic_instrument_id(instrument), + 'status': instrument_status_conversion(instrument.state), + 'optical_element_groups': heroic_optical_element_groups(instrument), + 'operation_modes': heroic_operation_modes(instrument) + } + return capabilities + + +def telescope_to_heroic_telescope_properties(telescope: Telescope): + ''' Extracts the current telescope properties of a telescope to send to HEROIC + ''' + telescope_payload = { + 'name': f"{telescope.name} - {telescope.enclosure.name}", + 'site': heroic_site_id(telescope.enclosure.site), + 'aperture': telescope.aperture, + 'latitude': telescope.lat, + 'longitude': telescope.long, + 'horizon': telescope.horizon, + 'negative_ha_limit': telescope.ha_limit_neg, + 'positive_ha_limit': telescope.ha_limit_pos, + 'zenith_blind_spot': telescope.zenith_blind_spot + } + return telescope_payload + + +def send_to_heroic(api_endpoint: str, payload: dict, update: bool = False): + ''' Function to send data to HEROIC API endpoints + ''' + headers = {'Authorization': f'Token {settings.HEROIC_API_TOKEN}'} + url = urljoin(settings.HEROIC_API_URL, api_endpoint) + if update: + response = requests.patch(url, headers=headers, json=payload) + else: + response = requests.post(url, headers=headers, json=payload) + logger.warning(response.json()) + response.raise_for_status() + + + +def create_heroic_instrument(instrument: Instrument): + ''' Create a new instrument payload and send it to HEROIC + ''' + if (instrument.telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES and str(instrument.telescope) not in settings.HEROIC_EXCLUDE_TELESCOPES): + instrument_payload = { + 'id': heroic_instrument_id(instrument), + 'name': f"{instrument.instrument_type.name} - {instrument.code}", + 'telescope': heroic_telescope_id(instrument.telescope), + 'available': True + } + try: + send_to_heroic('instruments/', instrument_payload) + except Exception as e: + logger.error(f'Failed to create heroic instrument {str(instrument)}: {repr(e)}') + + +def update_heroic_instrument_capabilities(instrument: Instrument): + ''' Send the current instrument capabilities of an instrument to HEROIC + if it is not DISABLED and heroic is set up in settings.py + ''' + if can_submit_to_heroic() and instrument.state != 'DISABLED' and instrument.telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES and str(instrument.telescope) not in settings.HEROIC_EXCLUDE_TELESCOPES: + capabilities = instrument_to_heroic_instrument_capabilities(instrument) + try: + send_to_heroic('instrument-capabilities/', capabilities) + except Exception as e: + logger.error(f'Failed to create heroic instrument {str(instrument)} capability update: {repr(e)}') + + +def create_heroic_telescope(telescope: Telescope): + ''' Create a new telescope payload and send it to HEROIC + ''' + if telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES and str(telescope) not in settings.HEROIC_EXCLUDE_TELESCOPES: + telescope_payload = telescope_to_heroic_telescope_properties(telescope) + telescope_payload['id'] = heroic_telescope_id(telescope) + telescope_payload['status'] = telescope_status_conversion(telescope) + if telescope_payload['status'] != 'SCHEDULABLE': + telescope_payload['reason'] = 'Telescope is currently marked as inactive to prevent usage' + try: + send_to_heroic('telescopes/', telescope_payload) + except Exception as e: + logger.error(f'Failed to create heroic telescope {str(telescope)}: {repr(e)}') + + +def update_heroic_telescope_properties(telescope: Telescope): + ''' Send updated telescope properties to HEROIC when they change + ''' + if telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES and str(telescope) not in settings.HEROIC_EXCLUDE_TELESCOPES: + telescope_update_payload = telescope_to_heroic_telescope_properties(telescope) + try: + send_to_heroic(f'telescopes/{heroic_telescope_id(telescope)}/', telescope_update_payload, update=True) + except Exception as e: + logger.error(f'Failed to update heroic telescope {str(telescope)}: {repr(e)}') + + +def site_to_heroic_site_properties(site: Site): + ''' Extracts the current site properties of a site to send to HEROIC + ''' + site_payload = { + 'name': site.name, + 'observatory': settings.HEROIC_OBSERVATORY, + 'elevation': site.elevation, + 'timezone': site.tz + } + return site_payload + + +def create_heroic_site(site: Site): + ''' Create a new site payload and send it to HEROIC + ''' + if site.code not in settings.HEROIC_EXCLUDE_SITES: + site_payload = site_to_heroic_site_properties(site) + site_payload['id'] = heroic_site_id(site) + try: + send_to_heroic('sites/', site_payload) + except Exception as e: + logger.error(f'Failed to create heroic site {str(site)}: {repr(e)}') + + +def update_heroic_site(site: Site): + ''' Send updated site properties to HEROIC when they change + ''' + if site.code not in settings.HEROIC_EXCLUDE_SITES: + site_payload = site_to_heroic_site_properties(site) + try: + send_to_heroic(f'sites/{heroic_site_id(site)}/', site_payload, update=True) + except Exception as e: + logger.error(f'Failed to update heroic site {str(site)}: {repr(e)}') diff --git a/configdb/hardware/serializers.py b/configdb/hardware/serializers.py index 2767d20..77b1025 100644 --- a/configdb/hardware/serializers.py +++ b/configdb/hardware/serializers.py @@ -6,6 +6,7 @@ InstrumentCategory ) from configdb.hardware.validator import OCSValidator +from configdb.hardware.heroic import update_heroic_instrument_capabilities class OpticalElementSerializer(serializers.ModelSerializer): @@ -15,6 +16,16 @@ class Meta: fields = ('id', 'name', 'code', 'schedulable') read_only_fields = ['id'] + def update(self, instance, validated_data): + instance = super().update(instance, validated_data) + # Update done so update HEROIC here - this catches optical elements name, code, or schedulability changes + if 'name' in validated_data or 'code' in validated_data or 'schedulable' in validated_data: + for oeg in instance.opticalelementgroup_set.all(): + for camera in oeg.camera_set.all(): + for instrument in camera.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + return instance + class OpticalElementNestedSerializer(OpticalElementSerializer): # This nested serializer allows us to choose an existing optical element for the group @@ -77,6 +88,25 @@ def create(self, validated_data): return optical_element_group + def update(self, instance, validated_data): + optical_elements = validated_data.pop('optical_elements', []) + optical_element_instances = validated_data.pop('optical_element_ids', []) + instance = super().update(instance, validated_data) + if (optical_elements or optical_element_instances): + instance.optical_elements.clear() # If we are updating optical elements, clear out old optical elements first + for optical_element_instance in optical_element_instances: + instance.optical_elements.add(optical_element_instance) + + for optical_element in optical_elements: + optical_element_instance, _ = OpticalElement.objects.get_or_create(code=optical_element.pop('code'), defaults=optical_element) + instance.optical_elements.add(optical_element_instance) + # Update done so update HEROIC here - this catches optical elements changes in the optical elements group + if optical_elements or optical_element_instances or 'default' in validated_data or 'type' in validated_data: + for camera in instance.camera_set.all(): + for instrument in camera.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + return instance + class ModeTypeSerializer(serializers.ModelSerializer): class Meta: @@ -108,6 +138,16 @@ def validate_validation_schema(self, value): return value + def update(self, instance, validated_data): + instance = super().update(instance, validated_data) + # Update done so update HEROIC here - this catches generic mode name, code, or schedulability updates + if 'name' in validated_data or 'code' in validated_data or 'schedulable' in validated_data: + for gmg in instance.genericmodegroup_set.all(): + if gmg.instrument_type: + for instrument in gmg.instrument_type.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + return instance + class GenericModeGroupSerializer(serializers.ModelSerializer): instrument_type = serializers.PrimaryKeyRelatedField( @@ -157,8 +197,37 @@ def create(self, validated_data): generic_mode_instance, _ = GenericMode.objects.get_or_create(**generic_mode) generic_mode_group.modes.add(generic_mode_instance) + # Update heroic when a new GenericModeGroup is created for the first time for an instrument_type + for instrument in generic_mode_group.instrument_type.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) return generic_mode_group + def update(self, instance, validated_data): + old_instrument_type = None + generic_modes = validated_data.pop('modes', []) + generic_mode_instances = validated_data.pop('mode_ids', []) + if 'instrument_type' in validated_data and validated_data['instrument_type'] != instance.instrument_type: + # In this special case, we need to update instruments of this old instrument type at the end + old_instrument_type = instance.instrument_type + instance = super().update(instance, validated_data) + if (generic_modes or generic_mode_instances): + instance.modes.clear() # If we are updating modes, clear out old modes first + for generic_mode_instance in generic_mode_instances: + instance.modes.add(generic_mode_instance) + + for generic_mode in generic_modes: + generic_mode_instance, _ = GenericMode.objects.get_or_create(**generic_mode) + instance.modes.add(generic_mode_instance) + # Update done so update HEROIC here - this catches generic mode changes in the generic mode group + if instance.instrument_type and (generic_modes or generic_mode_instances or 'default' in validated_data or 'type' in validated_data): + for instrument in instance.instrument_type.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + if old_instrument_type: + # Also update instruments of this old instrument type since they will have lost this generic mode group + for instrument in Instrument.objects.filter(instrument_type=old_instrument_type): + update_heroic_instrument_capabilities(instrument) + return instance + class CameraTypeSerializer(serializers.ModelSerializer): class Meta: @@ -171,12 +240,23 @@ class CameraSerializer(serializers.ModelSerializer): camera_type_id = serializers.IntegerField(write_only=True, help_text='Model ID number that corresponds to this camera\'s type') optical_element_groups = OpticalElementGroupSerializer(many=True, read_only=True, help_text='Optical element groups that this camera contains') + optical_element_group_ids = serializers.PrimaryKeyRelatedField(write_only=True, many=True, + queryset=OpticalElementGroup.objects.all(), source='optical_element_groups', + help_text='Model ID numbers for the optical element groups belonging to this camera') class Meta: fields = ('id', 'code', 'camera_type', 'camera_type_id', 'orientation', - 'optical_elements', 'optical_element_groups', 'host') + 'optical_elements', 'optical_element_groups', 'optical_element_group_ids', 'host') model = Camera + def update(self, instance, validated_data): + instance = super().update(instance, validated_data) + # Update done so update HEROIC here - this catches optical element group changes on the camera + if 'optical_element_groups' in validated_data or 'optical_element_group_ids' in validated_data: + for instrument in instance.instrument_set.all(): + update_heroic_instrument_capabilities(instrument) + return instance + class ConfigurationTypeSerializer(serializers.ModelSerializer): class Meta: @@ -265,6 +345,12 @@ class Meta: 'instrument_type_id', '__str__') model = Instrument + def update(self, instance, validated_data): + instance = super().update(instance, validated_data) + # Update done so update HEROIC here - this catches state or camera changes on the instrument + update_heroic_instrument_capabilities(instance) + return instance + class TelescopeSerializer(serializers.ModelSerializer): instrument_set = InstrumentSerializer(many=True, read_only=True, help_text='Set of instruments belonging to this telescope') diff --git a/configdb/hardware/signals/__init__.py b/configdb/hardware/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/configdb/hardware/signals/handlers.py b/configdb/hardware/signals/handlers.py new file mode 100644 index 0000000..c73e103 --- /dev/null +++ b/configdb/hardware/signals/handlers.py @@ -0,0 +1,33 @@ +''' These signals right now are only used for when HEROIC details are set in the settings + They are used to propagate model information to the HEROIC service +''' +from django.dispatch import receiver +from django.db.models.signals import pre_save + +from configdb.hardware.models import Instrument, Telescope, Site + +import configdb.hardware.heroic as heroic + + +@receiver(pre_save, sender=Instrument) +def on_save_instrument(sender, instance, *args, **kwargs): + if not instance.pk: + heroic.create_heroic_instrument(instance) + + +@receiver(pre_save, sender=Telescope) +def on_save_telescope(sender, instance, *args, **kwargs): + # Telescope save triggers updates to its properties or its creation to heroic + if instance.pk: + heroic.update_heroic_telescope_properties(instance) + else: + heroic.create_heroic_telescope(instance) + + +@receiver(pre_save, sender=Site) +def on_save_site(sender, instance, *args, **kwargs): + # Site save triggers updates to its properties or its creation to heroic + if instance.pk: + heroic.update_heroic_site(instance) + else: + heroic.create_heroic_site(instance) diff --git a/configdb/hardware/tests.py b/configdb/hardware/tests.py index 9887959..7b03edf 100644 --- a/configdb/hardware/tests.py +++ b/configdb/hardware/tests.py @@ -3,17 +3,19 @@ import time_machine from datetime import datetime from http import HTTPStatus -from django.test import TestCase +from django.test import TestCase, override_settings from django.test import Client from django.urls import reverse +from unittest.mock import patch from rest_framework.test import APITestCase from django.contrib.auth.models import User from mixer.backend.django import mixer -from .models import (Site, Instrument, Enclosure, Telescope, Camera, CameraType, InstrumentType, +from configdb.hardware.models import (Site, Instrument, Enclosure, Telescope, Camera, CameraType, InstrumentType, GenericMode, GenericModeGroup, ModeType, OpticalElement, OpticalElementGroup, ConfigurationType, ConfigurationTypeProperties, InstrumentCategory) -from .serializers import GenericModeSerializer, InstrumentTypeSerializer +from configdb.hardware.serializers import GenericModeSerializer, InstrumentTypeSerializer +from configdb.hardware.heroic import heroic_instrument_id class BaseHardwareTest(TestCase): @@ -142,6 +144,237 @@ def test_optical_elements_str(self): self.assertEqual(str(oeg), 'oeg_name - oeg_type: oe1,oe2') +@override_settings(HEROIC_API_URL='http://fake', HEROIC_API_TOKEN='123fake', HEROIC_OBSERVATORY='tst') +@patch('configdb.hardware.heroic.send_to_heroic') +class TestHeroicUpdates(APITestCase): + def setUp(self): + super().setUp() + self.site = mixer.blend(Site, code='tst') + self.enclosure = mixer.blend(Enclosure, site=self.site, code='doma') + self.telescope = mixer.blend(Telescope, enclosure=self.enclosure, code='1m0a', active=True) + self.camera_type = mixer.blend(CameraType) + self.instrument_type = mixer.blend(InstrumentType) + self.camera_type.save() + self.camera = mixer.blend(Camera, camera_type=self.camera_type) + self.instrument = mixer.blend(Instrument, autoguider_camera=self.camera, telescope=self.telescope, + instrument_type=self.instrument_type, science_cameras=[self.camera], + state=Instrument.SCHEDULABLE, code='myInst01') + self.user = mixer.blend(User) + self.client.force_login(self.user) + + def test_update_instrument_state_calls_out_to_heroic(self, mock_send): + instrument_update = { + 'state': Instrument.MANUAL + } + self.client.patch( + reverse('instrument-detail', args=(self.instrument.id,)), + data=instrument_update, format='json' + ) + expected_capabilities = { + 'instrument': heroic_instrument_id(self.instrument), + 'status': 'UNAVAILABLE', + 'optical_element_groups': {}, + 'operation_modes': {} + } + mock_send.assert_called_with('instrument-capabilities/', expected_capabilities) + + @override_settings(HEROIC_EXCLUDE_SITES=['tst']) + def test_update_instrument_state_on_excluded_site_does_not_call_out_to_heroic(self, mock_send): + instrument_update = { + 'state': Instrument.MANUAL + } + self.client.patch( + reverse('instrument-detail', args=(self.instrument.id,)), + data=instrument_update, format='json' + ) + mock_send.assert_not_called() + + @override_settings(HEROIC_EXCLUDE_TELESCOPES=['tst.doma.1m0a']) + def test_update_instrument_state_on_excluded_telescope_does_not_call_out_to_heroic(self, mock_send): + instrument_update = { + 'state': Instrument.MANUAL + } + self.client.patch( + reverse('instrument-detail', args=(self.instrument.id,)), + data=instrument_update, format='json' + ) + mock_send.assert_not_called() + + def test_update_instrument_cameras_calls_out_to_heroic(self, mock_send): + optical_element = mixer.blend(OpticalElement, name='myOE', code='myoe1', schedulable=True) + optical_element_group = mixer.blend(OpticalElementGroup, optical_elements=[optical_element], type='filters') + camera2 = mixer.blend(Camera, camera_type=self.camera_type, optical_element_groups=[optical_element_group]) + instrument_update = { + 'science_cameras_ids': [camera2.id] + } + self.client.patch( + reverse('instrument-detail', args=(self.instrument.id,)), + data=instrument_update, format='json' + ) + expected_capabilities = { + 'instrument': heroic_instrument_id(self.instrument), + 'status': 'SCHEDULABLE', + 'optical_element_groups': {'filters': {'options': [{'id': 'myoe1', 'name': 'myOE', 'schedulable': True}]}}, + 'operation_modes': {} + } + mock_send.assert_called_with('instrument-capabilities/', expected_capabilities) + + def test_update_cameras_optical_element_group_calls_out_to_heroic(self, mock_send): + optical_element = mixer.blend(OpticalElement, name='myOE', code='myoe1', schedulable=True) + optical_element_group = mixer.blend(OpticalElementGroup, optical_elements=[optical_element], type='filters') + camera_update = { + 'optical_element_group_ids': [optical_element_group.id] + } + self.client.patch( + reverse('camera-detail', args=(self.camera.id,)), + data=camera_update, format='json' + ) + expected_capabilities = { + 'instrument': heroic_instrument_id(self.instrument), + 'status': 'SCHEDULABLE', + 'optical_element_groups': {'filters': {'options': [{'id': 'myoe1', 'name': 'myOE', 'schedulable': True}]}}, + 'operation_modes': {} + } + mock_send.assert_called_with('instrument-capabilities/', expected_capabilities) + + def test_update_optical_element_of_group_calls_out_to_heroic(self, mock_send): + optical_element = mixer.blend(OpticalElement, name='myOE', code='myoe1', schedulable=True) + optical_element_group = mixer.blend(OpticalElementGroup, optical_elements=[optical_element], type='filters') + self.camera.optical_element_groups.add(optical_element_group) + self.camera.save() + optical_element2 = mixer.blend(OpticalElement, name='myOE2', code='myoe2', schedulable=True) + oeg_update = { + 'optical_element_ids': [optical_element2.id], + 'optical_elements': [{'code': optical_element2.code}] + } + self.client.patch( + reverse('opticalelementgroup-detail', args=(optical_element_group.id,)), + data=oeg_update, format='json' + ) + expected_capabilities = { + 'instrument': heroic_instrument_id(self.instrument), + 'status': 'SCHEDULABLE', + 'optical_element_groups': {'filters': {'options': [{'id': 'myoe2', 'name': 'myOE2', 'schedulable': True}]}}, + 'operation_modes': {} + } + mock_send.assert_called_with('instrument-capabilities/', expected_capabilities) + + def test_update_optical_element_alt_of_group_calls_out_to_heroic(self, mock_send): + optical_element = mixer.blend(OpticalElement, name='myOE', code='myoe1', schedulable=True) + optical_element_group = mixer.blend(OpticalElementGroup, optical_elements=[optical_element], type='filters') + self.camera.optical_element_groups.add(optical_element_group) + self.camera.save() + optical_element2 = mixer.blend(OpticalElement, name='myOE2', code='myoe2', schedulable=True) + oeg_update = { + 'optical_elements': [{'code': optical_element2.code}] + } + self.client.patch( + reverse('opticalelementgroup-detail', args=(optical_element_group.id,)), + data=oeg_update, format='json' + ) + expected_capabilities = { + 'instrument': heroic_instrument_id(self.instrument), + 'status': 'SCHEDULABLE', + 'optical_element_groups': {'filters': {'options': [{'id': 'myoe2', 'name': 'myOE2', 'schedulable': True}]}}, + 'operation_modes': {} + } + mock_send.assert_called_with('instrument-capabilities/', expected_capabilities) + + def test_update_optical_element_calls_out_to_heroic(self, mock_send): + optical_element = mixer.blend(OpticalElement, name='myOE', code='myoe1', schedulable=True) + optical_element_group = mixer.blend(OpticalElementGroup, optical_elements=[optical_element], type='filters') + self.camera.optical_element_groups.add(optical_element_group) + self.camera.save() + oe_update = { + 'name': 'myNewOeName', + 'schedulable': False + } + self.client.patch( + reverse('opticalelement-detail', args=(optical_element.id,)), + data=oe_update, format='json' + ) + expected_capabilities = { + 'instrument': heroic_instrument_id(self.instrument), + 'status': 'SCHEDULABLE', + 'optical_element_groups': {'filters': {'options': [{'id': 'myoe1', 'name': 'myNewOeName', 'schedulable': False}]}}, + 'operation_modes': {} + } + mock_send.assert_called_with('instrument-capabilities/', expected_capabilities) + + def test_create_generic_mode_group_of_instrument_type_calls_out_to_heroic(self, mock_send): + generic_mode1 = {'name': 'testMode1', 'code': 'tM1', 'schedulable': True} + generic_mode_group = {'type': 'readout', 'instrument_type': self.instrument_type.id, + 'modes': [generic_mode1]} + self.client.post(reverse('genericmodegroup-list'), data=generic_mode_group, format='json') + expected_capabilities = { + 'instrument': heroic_instrument_id(self.instrument), + 'status': 'SCHEDULABLE', + 'optical_element_groups': {}, + 'operation_modes': {'readout': {'options': [{'id': 'tM1', 'name': 'testMode1', 'schedulable': True}]}} + } + mock_send.assert_called_with('instrument-capabilities/', expected_capabilities) + + def test_update_generic_mode_group_calls_out_to_heroic(self, mock_send): + readout_mode = mixer.blend(ModeType, id='readout') + generic_mode1 = mixer.blend(GenericMode, name='testMode1', code='tM1', schedulable=True) + generic_mode_group = mixer.blend(GenericModeGroup, type=readout_mode, instrument_type=self.instrument_type, modes=[generic_mode1]) + generic_mode2 = mixer.blend(GenericMode, name='testMode2', code='tM2', schedulable=True) + gmg_update = { + 'mode_ids': [generic_mode2.id] + } + self.client.patch( + reverse('genericmodegroup-detail', args=(generic_mode_group.id,)), + data=gmg_update, format='json' + ) + expected_capabilities = { + 'instrument': heroic_instrument_id(self.instrument), + 'status': 'SCHEDULABLE', + 'optical_element_groups': {}, + 'operation_modes': {'readout': {'options': [{'id': 'tM2', 'name': 'testMode2', 'schedulable': True}]}} + } + mock_send.assert_called_with('instrument-capabilities/', expected_capabilities) + + def test_update_generic_mode_group_alt_calls_out_to_heroic(self, mock_send): + readout_mode = mixer.blend(ModeType, id='readout') + generic_mode1 = mixer.blend(GenericMode, name='testMode1', code='tM1', schedulable=True) + generic_mode_group = mixer.blend(GenericModeGroup, type=readout_mode, instrument_type=self.instrument_type, modes=[generic_mode1]) + generic_mode2 = mixer.blend(GenericMode, name='testMode2', code='tM2', schedulable=True) + gmg_update = { + 'modes': [{'code': generic_mode2.code}] + } + self.client.patch( + reverse('genericmodegroup-detail', args=(generic_mode_group.id,)), + data=gmg_update, format='json' + ) + expected_capabilities = { + 'instrument': heroic_instrument_id(self.instrument), + 'status': 'SCHEDULABLE', + 'optical_element_groups': {}, + 'operation_modes': {'readout': {'options': [{'id': 'tM2', 'name': 'testMode2', 'schedulable': True}]}} + } + mock_send.assert_called_with('instrument-capabilities/', expected_capabilities) + + def test_update_generic_mode_calls_out_to_heroic(self, mock_send): + readout_mode = mixer.blend(ModeType, id='readout') + generic_mode = mixer.blend(GenericMode, name='testMode1', code='tM1', schedulable=True) + mixer.blend(GenericModeGroup, type=readout_mode, instrument_type=self.instrument_type, modes=[generic_mode]) + gm_update = { + 'name': 'testModeNewName', + 'schedulable': False + } + self.client.patch( + reverse('genericmode-detail', args=(generic_mode.id,)), + data=gm_update, format='json' + ) + expected_capabilities = { + 'instrument': heroic_instrument_id(self.instrument), + 'status': 'SCHEDULABLE', + 'optical_element_groups': {}, + 'operation_modes': {'readout': {'options': [{'id': 'tM1', 'name': 'testModeNewName', 'schedulable': False}]}} + } + mock_send.assert_called_with('instrument-capabilities/', expected_capabilities) + + class TestCreationThroughAPI(APITestCase): def setUp(self): super().setUp() diff --git a/configdb/settings.py b/configdb/settings.py index b5e4409..7e574d6 100644 --- a/configdb/settings.py +++ b/configdb/settings.py @@ -62,7 +62,7 @@ def get_list_from_env(variable, default=None): 'reversion', 'rest_framework', 'rest_framework.authtoken', - 'configdb.hardware', + 'configdb.hardware.apps.HardwareConfig', 'corsheaders', 'django_extensions', ) @@ -157,6 +157,15 @@ def get_list_from_env(variable, default=None): ), } +# To submit instrument capability updates to the SCIMMA Heroic service +# You must first login to heroic and get your API token, and your account +# must be listed as the admin account for an observatory +HEROIC_API_URL = os.getenv('HEROIC_API_URL', '') +HEROIC_API_TOKEN = os.getenv('HEROIC_API_TOKEN', '') +HEROIC_OBSERVATORY = os.getenv('HEROIC_OBSERVATORY', '') +HEROIC_EXCLUDE_SITES = get_list_from_env('HEROIC_EXCLUDE_SITES', '') +HEROIC_EXCLUDE_TELESCOPES = get_list_from_env('HEROIC_EXCLUDE_TELESCOPES', '') + CORS_ORIGIN_ALLOW_ALL = True # This project now requires connection to an OAuth server for authenticating users to make changes