From 8df93e573e4a01e47b1a184fc40ec71aa224f215 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 4 Jun 2025 05:49:52 +0000 Subject: [PATCH 1/5] Added settings to enable sending new structures and updates to instrument capabilities to HEROIC --- configdb/hardware/admin.py | 69 ++++++++ configdb/hardware/apps.py | 16 ++ configdb/hardware/heroic.py | 209 +++++++++++++++++++++++++ configdb/hardware/serializers.py | 88 ++++++++++- configdb/hardware/signals/__init__.py | 0 configdb/hardware/signals/handlers.py | 33 ++++ configdb/hardware/tests.py | 217 +++++++++++++++++++++++++- configdb/settings.py | 9 +- 8 files changed, 636 insertions(+), 5 deletions(-) create mode 100644 configdb/hardware/apps.py create mode 100644 configdb/hardware/heroic.py create mode 100644 configdb/hardware/signals/__init__.py create mode 100644 configdb/hardware/signals/handlers.py 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..823b6c9 --- /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..8c99b10 --- /dev/null +++ b/configdb/hardware/heroic.py @@ -0,0 +1,209 @@ +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 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 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 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 + ''' + 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': + 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 + ''' + 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 + ''' + 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 + ''' + 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 + ''' + 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..f608f77 --- /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 propogate 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..b0d2514 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,215 @@ 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) + + 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..7d24968 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,13 @@ 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', '') + CORS_ORIGIN_ALLOW_ALL = True # This project now requires connection to an OAuth server for authenticating users to make changes From e97232327d7938823fc90e11af6aaf05e813fb46 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 23 Jun 2025 23:51:15 +0000 Subject: [PATCH 2/5] Remove whitespace --- configdb/hardware/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configdb/hardware/apps.py b/configdb/hardware/apps.py index 823b6c9..2a7412f 100644 --- a/configdb/hardware/apps.py +++ b/configdb/hardware/apps.py @@ -8,7 +8,7 @@ def can_submit_to_heroic(): 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(): From 464841c4b8448807c20d83e2100de123fb9e5f12 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 10 Jul 2025 04:12:50 +0000 Subject: [PATCH 3/5] Added excluded sites environment variable for HEROIC --- configdb/hardware/heroic.py | 83 ++++++++++++++------------- configdb/hardware/signals/handlers.py | 2 +- configdb/hardware/tests.py | 11 ++++ configdb/settings.py | 1 + 4 files changed, 57 insertions(+), 40 deletions(-) diff --git a/configdb/hardware/heroic.py b/configdb/hardware/heroic.py index 8c99b10..92cb93c 100644 --- a/configdb/hardware/heroic.py +++ b/configdb/hardware/heroic.py @@ -29,14 +29,14 @@ def heroic_site_id(site: Site): ''' Extract a HEROIC id for the site. This concatenates the observatory.site for human readability ''' - return settings.HEROIC_OBSERVATORY + '.' + site.code + 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 heroic_site_id(telescope.enclosure.site) + '.' + telescope.enclosure.code + '-' + telescope.code + return f"{heroic_site_id(telescope.enclosure.site)}.{telescope.enclosure.code}-{telescope.code}" def heroic_instrument_id(instrument: Instrument): @@ -44,7 +44,7 @@ def heroic_instrument_id(instrument: Instrument): This concatenates the observatory.site.telescope.instrument for human-readability. ''' - return heroic_telescope_id(instrument.telescope) + '.' + instrument.code + return f"{heroic_telescope_id(instrument.telescope)}.{instrument.code}" def heroic_optical_element_groups(instrument: Instrument): @@ -128,23 +128,24 @@ def send_to_heroic(api_endpoint: str, payload: dict, update: bool = False): def create_heroic_instrument(instrument: Instrument): ''' Create a new instrument payload and send it to HEROIC ''' - 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)}') + if (instrument.telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES): + 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': + if can_submit_to_heroic() and instrument.state != 'DISABLED' and instrument.telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES: capabilities = instrument_to_heroic_instrument_capabilities(instrument) try: send_to_heroic('instrument-capabilities/', capabilities) @@ -155,25 +156,27 @@ def update_heroic_instrument_capabilities(instrument: Instrument): def create_heroic_telescope(telescope: Telescope): ''' Create a new telescope payload and send it to HEROIC ''' - 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)}') + if telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES: + 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 ''' - 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)}') + if telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES: + 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): @@ -191,19 +194,21 @@ def site_to_heroic_site_properties(site: Site): def create_heroic_site(site: Site): ''' Create a new site payload and send it to HEROIC ''' - 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)}') + 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 ''' - 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)}') + 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/signals/handlers.py b/configdb/hardware/signals/handlers.py index f608f77..c73e103 100644 --- a/configdb/hardware/signals/handlers.py +++ b/configdb/hardware/signals/handlers.py @@ -1,5 +1,5 @@ ''' These signals right now are only used for when HEROIC details are set in the settings - They are used to propogate model information to the HEROIC service + They are used to propagate model information to the HEROIC service ''' from django.dispatch import receiver from django.db.models.signals import pre_save diff --git a/configdb/hardware/tests.py b/configdb/hardware/tests.py index b0d2514..776bfde 100644 --- a/configdb/hardware/tests.py +++ b/configdb/hardware/tests.py @@ -178,6 +178,17 @@ def test_update_instrument_state_calls_out_to_heroic(self, mock_send): } 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() + 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') diff --git a/configdb/settings.py b/configdb/settings.py index 7d24968..fba07f2 100644 --- a/configdb/settings.py +++ b/configdb/settings.py @@ -163,6 +163,7 @@ def get_list_from_env(variable, default=None): 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', '') CORS_ORIGIN_ALLOW_ALL = True From 81ec6a16a2678d4af97121eaa58ef112558148d4 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 28 Jul 2025 23:40:01 +0000 Subject: [PATCH 4/5] Add exlclude heroic telescopes option env variable --- configdb/hardware/heroic.py | 8 ++++---- configdb/hardware/tests.py | 11 +++++++++++ configdb/settings.py | 1 + 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/configdb/hardware/heroic.py b/configdb/hardware/heroic.py index 92cb93c..b35a62c 100644 --- a/configdb/hardware/heroic.py +++ b/configdb/hardware/heroic.py @@ -128,7 +128,7 @@ def send_to_heroic(api_endpoint: str, payload: dict, update: bool = False): 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): + 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}", @@ -145,7 +145,7 @@ 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: + 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) @@ -156,7 +156,7 @@ def update_heroic_instrument_capabilities(instrument: Instrument): 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: + 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) @@ -171,7 +171,7 @@ def create_heroic_telescope(telescope: Telescope): 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: + 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) diff --git a/configdb/hardware/tests.py b/configdb/hardware/tests.py index 776bfde..7b03edf 100644 --- a/configdb/hardware/tests.py +++ b/configdb/hardware/tests.py @@ -189,6 +189,17 @@ def test_update_instrument_state_on_excluded_site_does_not_call_out_to_heroic(se ) 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') diff --git a/configdb/settings.py b/configdb/settings.py index fba07f2..7e574d6 100644 --- a/configdb/settings.py +++ b/configdb/settings.py @@ -164,6 +164,7 @@ def get_list_from_env(variable, default=None): 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 From 1cf7de9a82ddb75b01cb2567b476cf4320cc0bae Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 28 Jul 2025 23:47:18 +0000 Subject: [PATCH 5/5] Added new env variables to readme --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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.