From 2a6158c9f707cee41a12a4b1f0bdbd363da696c9 Mon Sep 17 00:00:00 2001 From: "William (Lindy) Lindstrom" Date: Fri, 24 Jul 2026 14:49:44 -0700 Subject: [PATCH 01/18] correct doc string typo --- tom_dataservices/dataservices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tom_dataservices/dataservices.py b/tom_dataservices/dataservices.py index eca37b705..57673e7c1 100644 --- a/tom_dataservices/dataservices.py +++ b/tom_dataservices/dataservices.py @@ -24,7 +24,7 @@ def get_data_service_classes(): """ Imports the Dataservice class from relevant apps and generates a list of data service names. - Each dataservice class should be contained in a list of dictionaries in an app's apps.py `dataservices` method. + Each dataservice class should be contained in a list of dictionaries in an app's apps.py `data_services` method. Each dataservice dictionary should contain a 'class' key with the dot separated path to the dataservice class (typically an extension of DataService). From f0f432600c936b775a3137897fc045ab761e2fe9 Mon Sep 17 00:00:00 2001 From: "William (Lindy) Lindstrom" Date: Fri, 24 Jul 2026 14:53:52 -0700 Subject: [PATCH 02/18] explain in settings.py tmpl that INSTALLED_APPS can add facilities with the new AppConfig.observation_facilities() integration point. So, facility.get_service_classes collects facilities from the new integration point and `settings.TOM_FACILITY_CLASSES` combined. --- tom_setup/templates/tom_setup/settings.tmpl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tom_setup/templates/tom_setup/settings.tmpl b/tom_setup/templates/tom_setup/settings.tmpl index 9a98a0ec7..d97f35ace 100644 --- a/tom_setup/templates/tom_setup/settings.tmpl +++ b/tom_setup/templates/tom_setup/settings.tmpl @@ -237,6 +237,9 @@ DATA_PROCESSORS = { 'spectroscopy': 'tom_dataproducts.processors.spectroscopy_processor.SpectroscopyProcessor', } +# Facilities listed here are available to your TOM. In addition, any app in INSTALLED_APPS +# that implements the observation_facilities() AppConfig integration point (e.g. tom_keck, +# tom_cfht) contributes its facilities automatically and does not need to be listed here. TOM_FACILITY_CLASSES = [ 'tom_observations.facilities.lco_redirect.LCORedirectFacility', 'tom_observations.facilities.gemini.GEMFacility', From 2058c8614a815cb746304aae9c9fb8d723455e92 Mon Sep 17 00:00:00 2001 From: "William (Lindy) Lindstrom" Date: Fri, 24 Jul 2026 14:57:10 -0700 Subject: [PATCH 03/18] add Facilities nav-bar item via TomObaservations integration point --- tom_observations/apps.py | 12 +++++ .../partials/navbar_facilities_list.html | 15 ++++++ .../templatetags/observation_extras.py | 54 ++++++++++++++++++- 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tom_observations/templates/tom_observations/partials/navbar_facilities_list.html diff --git a/tom_observations/apps.py b/tom_observations/apps.py index a4c446f7f..6bbc6ae0f 100644 --- a/tom_observations/apps.py +++ b/tom_observations/apps.py @@ -3,3 +3,15 @@ class TomObservationsConfig(AppConfig): name = 'tom_observations' + + def nav_items(self): + """ + Integration point for adding items to the navbar. + This method should return a list of partial templates to be included in the navbar. + + Here, the "Facilities" dropdown menu, listing the facilities contributed by installed + apps via the observation_facilities() integration point (see + tom_observations.facility.get_service_classes()). + """ + return [{'partial': 'tom_observations/partials/navbar_facilities_list.html', + 'context': 'tom_observations.templatetags.observation_extras.observation_facilities_list'}] diff --git a/tom_observations/templates/tom_observations/partials/navbar_facilities_list.html b/tom_observations/templates/tom_observations/partials/navbar_facilities_list.html new file mode 100644 index 000000000..98944740e --- /dev/null +++ b/tom_observations/templates/tom_observations/partials/navbar_facilities_list.html @@ -0,0 +1,15 @@ +{# "Facilities" navbar dropdown: one menu item per facility contributed via the #} +{# observation_facilities() AppConfig integration point. Context comes from #} +{# observation_extras.observation_facilities_list. #} +{% if observation_facilities %} + +{% endif %} diff --git a/tom_observations/templatetags/observation_extras.py b/tom_observations/templatetags/observation_extras.py index ec09b24f4..12888545e 100644 --- a/tom_observations/templatetags/observation_extras.py +++ b/tom_observations/templatetags/observation_extras.py @@ -1,9 +1,12 @@ from datetime import datetime, timedelta +import logging from urllib.parse import urlencode from django import forms, template +from django.apps import apps from django.conf import settings -from django.urls import reverse +from django.urls import NoReverseMatch, reverse +from django.utils.module_loading import import_string from guardian.shortcuts import get_objects_for_user from plotly import offline import plotly.graph_objs as go @@ -16,9 +19,58 @@ from tom_targets.models import Target +logger = logging.getLogger(__name__) + register = template.Library() +@register.inclusion_tag('tom_observations/partials/navbar_facilities_list.html', takes_context=True) +def observation_facilities_list(context: dict) -> dict: + """ + Returns the list of app-contributed observation facilities used to generate the + "Facilities" navbar dropdown links. + + Facilities are gathered from installed apps implementing the observation_facilities() + AppConfig integration point (see tom_observations.facility.get_service_classes()). + In addition to the 'class' key consumed by get_service_classes(), each facility + dictionary may include an optional 'url' key: the namespaced URL name of that + facility's landing page, to which its navbar menu item will link. A facility without + a 'url' is registration-only: it gets no navbar menu item (and if no facility + supplies a 'url', the "Facilities" dropdown is not displayed at all). + """ + facilities = [] + for app in apps.get_app_configs(): + try: + observation_facilities = app.observation_facilities() + except AttributeError: + continue # this app doesn't implement the integration point + for facility in observation_facilities: + # a facility without a 'url' key declares no landing page; it is registered by + # get_service_classes() but deliberately gets no navbar menu item + url_name = facility.get('url') + if url_name is None: + continue + # resolve the facility class for its display name, and the landing page URL + # for the link target; skip (with a warning) anything that doesn't resolve, + # so one bad entry doesn't take out the whole navbar + try: + clazz = import_string(facility['class']) + except ImportError as e: + logger.warning(f'WARNING: Could not import facility class for {app.name} from ' + f'{facility["class"]}: {e}') + continue + try: + url = reverse(url_name) + except NoReverseMatch as e: + logger.warning(f'WARNING: Could not resolve landing page URL for facility {clazz.name} ' + f'of {app.name}: {e}') + continue + facilities.append({'name': clazz.name, 'url': url}) + + context['observation_facilities'] = facilities + return context + + @register.inclusion_tag('tom_observations/partials/update_status_button.html', takes_context=True) def update_status_button(context): """ From 2e86f2be954e76f8678a6ca1dd4ac53555e1ba94 Mon Sep 17 00:00:00 2001 From: "William (Lindy) Lindstrom" Date: Fri, 24 Jul 2026 14:58:52 -0700 Subject: [PATCH 04/18] collect facilities from both settings and installed apps --- tom_observations/facility.py | 46 ++++++++++++- tom_observations/tests/tests.py | 112 +++++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/tom_observations/facility.py b/tom_observations/facility.py index 8b878dfa4..1a4f14004 100644 --- a/tom_observations/facility.py +++ b/tom_observations/facility.py @@ -7,6 +7,7 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import ButtonHolder, Layout, Submit, Div, HTML from django import forms +from django.apps import apps from django.conf import settings from django.contrib.auth.models import Group from django.core.exceptions import ImproperlyConfigured @@ -48,7 +49,32 @@ class CredentialStatus(Enum): AUTO_THUMBNAILS = False -def get_service_classes(): +def get_service_classes() -> dict: + """Return a dictionary mapping facility name to facility class for all known facilities. + + Facilities come from two sources, combined here: + 1. ``settings.TOM_FACILITY_CLASSES`` (falling back to ``DEFAULT_FACILITY_CLASSES``), the + traditional explicit configuration mechanism. + 2. The ``observation_facilities()`` AppConfig integration point: any INSTALLED_APP whose + AppConfig defines an ``observation_facilities()`` method contributes its facilities + automatically, with no settings changes required. (This is analogous to the + ``data_services()`` integration point consumed by + ``tom_dataservices.dataservices.get_data_service_classes()``.) + + ``observation_facilities()`` should return a list of dictionaries, each with a ``class`` key + whose value is the dot-separated path to the facility class. Entries may carry additional + keys for other consumers of the integration point — e.g. an optional ``url`` key naming the + facility's landing page for the navbar "Facilities" menu (see + ``tom_observations.templatetags.observation_extras.observation_facilities_list``) — but only + ``class`` is consumed here. + + FOR EXAMPLE: + [{'class': 'tom_keck.keck.KeckFacility'}] + + Returns: + dict: mapping of ``Facility.name`` to facility class. A facility appearing in both + sources (same ``name``) is only included once; the app-supplied class wins. + """ try: TOM_FACILITY_CLASSES = settings.TOM_FACILITY_CLASSES except AttributeError: @@ -61,6 +87,24 @@ def get_service_classes(): except (ImportError, AttributeError) as e: raise ImportError(f'Could not import {service}: {e}') service_choices[clazz.name] = clazz + + # Combine the settings-declared facilities with those contributed by installed apps + # via the observation_facilities() AppConfig integration point. + for app in apps.get_app_configs(): + try: + observation_facilities = app.observation_facilities() + except AttributeError: + continue # this app doesn't implement the integration point + for facility in observation_facilities: + try: + clazz = import_string(facility['class']) + except ImportError as e: + logger.warning(f'WARNING: Could not import facility class for {app.name} from ' + f'{facility["class"]}.\n' + f'{e}') + continue + service_choices[clazz.name] = clazz + return service_choices diff --git a/tom_observations/tests/tests.py b/tom_observations/tests/tests.py index 8db26478c..dc1c7aa6f 100644 --- a/tom_observations/tests/tests.py +++ b/tom_observations/tests/tests.py @@ -6,6 +6,7 @@ from django.contrib.auth.models import User from django.contrib.messages import get_messages from django.forms import ValidationError +from django.template.loader import render_to_string from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone @@ -15,8 +16,10 @@ from astropy.time import Time from .factories import ObservingRecordFactory, ObservationTemplateFactory, SiderealTargetFactory, TargetNameFactory +from tom_observations.facility import get_service_classes +from tom_observations.templatetags.observation_extras import observation_facilities_list from tom_observations.utils import get_astroplan_sun_and_time, get_sidereal_visibility -from tom_observations.tests.utils import FakeRoboticFacility +from tom_observations.tests.utils import FakeManualFacility, FakeRoboticFacility from tom_observations.models import ObservationRecord, ObservationGroup, ObservationTemplate from tom_targets.models import Target from guardian.shortcuts import assign_perm @@ -497,3 +500,110 @@ def test_get_visibility_sidereal(self, mock_facility): self.assertEqual(len(airmass_data), len(expected_airmass)) for i, expected_airmass_value in enumerate(expected_airmass): self.assertAlmostEqual(airmass_data[i], expected_airmass_value, places=3) + + +class TestGetServiceClasses(TestCase): + """ + Tests for the observation_facilities() AppConfig integration point as consumed by + tom_observations.facility.get_service_classes(). + """ + + def _fake_app_config(self, facilities: list) -> mock.Mock: + """Return a mock AppConfig whose observation_facilities() returns the given list.""" + app_config = mock.Mock() + app_config.name = 'fake_facility_app' + app_config.observation_facilities.return_value = facilities + return app_config + + @override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility']) + def test_app_contributed_facilities_merged_with_settings(self): + fake_app_config = self._fake_app_config([{'class': 'tom_observations.tests.utils.FakeManualFacility'}]) + with mock.patch('tom_observations.facility.apps.get_app_configs', return_value=[fake_app_config]): + service_classes = get_service_classes() + self.assertEqual(service_classes, + {'FakeRoboticFacility': FakeRoboticFacility, 'FakeManualFacility': FakeManualFacility}) + + @override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility']) + def test_apps_without_integration_point_are_skipped(self): + # mock.Mock(spec=[]) raises AttributeError for observation_facilities(), behaving + # like a regular AppConfig that doesn't implement the integration point + with mock.patch('tom_observations.facility.apps.get_app_configs', return_value=[mock.Mock(spec=[])]): + service_classes = get_service_classes() + self.assertEqual(service_classes, {'FakeRoboticFacility': FakeRoboticFacility}) + + @override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility']) + def test_facility_in_both_sources_is_deduplicated(self): + fake_app_config = self._fake_app_config([{'class': 'tom_observations.tests.utils.FakeRoboticFacility'}]) + with mock.patch('tom_observations.facility.apps.get_app_configs', return_value=[fake_app_config]): + service_classes = get_service_classes() + self.assertEqual(service_classes, {'FakeRoboticFacility': FakeRoboticFacility}) + + @override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility']) + def test_unimportable_app_facility_is_skipped_with_warning(self): + fake_app_config = self._fake_app_config([{'class': 'no.such.module.NoSuchFacility'}]) + with mock.patch('tom_observations.facility.apps.get_app_configs', return_value=[fake_app_config]), \ + self.assertLogs('tom_observations.facility', level='WARNING'): + service_classes = get_service_classes() + self.assertEqual(service_classes, {'FakeRoboticFacility': FakeRoboticFacility}) + + +class TestObservationFacilitiesNavbar(TestCase): + """ + Tests for the "Facilities" navbar dropdown: the observation_facilities_list templatetag + and its navbar_facilities_list.html partial. + """ + + def _fake_app_config(self, facilities: list) -> mock.Mock: + """Return a mock AppConfig whose observation_facilities() returns the given list.""" + app_config = mock.Mock() + app_config.name = 'fake_facility_app' + app_config.observation_facilities.return_value = facilities + return app_config + + def test_responding_app_facility_is_listed(self): + # 'home' is a URL name that always resolves, standing in for a facility landing page + fake_app_config = self._fake_app_config( + [{'class': 'tom_observations.tests.utils.FakeRoboticFacility', 'url': 'home'}]) + with mock.patch('tom_observations.templatetags.observation_extras.apps.get_app_configs', + return_value=[fake_app_config]): + context = observation_facilities_list({}) + self.assertEqual(context['observation_facilities'], + [{'name': 'FakeRoboticFacility', 'url': reverse('home')}]) + + def test_no_responding_apps_yields_empty_context(self): + with mock.patch('tom_observations.templatetags.observation_extras.apps.get_app_configs', + return_value=[mock.Mock(spec=[])]): + context = observation_facilities_list({}) + self.assertEqual(context['observation_facilities'], []) + + def test_dropdown_hidden_when_no_facilities(self): + html = render_to_string('tom_observations/partials/navbar_facilities_list.html', + {'observation_facilities': []}) + self.assertNotIn('Facilities', html) + + def test_facility_with_unresolvable_url_is_skipped_with_warning(self): + fake_app_config = self._fake_app_config( + [{'class': 'tom_observations.tests.utils.FakeRoboticFacility', 'url': 'no-such-url-name'}]) + with mock.patch('tom_observations.templatetags.observation_extras.apps.get_app_configs', + return_value=[fake_app_config]), \ + self.assertLogs('tom_observations.templatetags.observation_extras', level='WARNING'): + context = observation_facilities_list({}) + self.assertEqual(context['observation_facilities'], []) + + def test_facility_without_url_gets_no_navbar_item_and_no_warning(self): + # omitting 'url' declares a facility with no landing page (e.g. tom_lt): it is + # registered by get_service_classes() but deliberately absent from the navbar, + # and that absence is not a misconfiguration worth warning about + fake_app_config = self._fake_app_config([{'class': 'tom_observations.tests.utils.FakeRoboticFacility'}]) + with mock.patch('tom_observations.templatetags.observation_extras.apps.get_app_configs', + return_value=[fake_app_config]), \ + mock.patch('tom_observations.templatetags.observation_extras.logger') as mock_logger: + context = observation_facilities_list({}) + self.assertEqual(context['observation_facilities'], []) + mock_logger.warning.assert_not_called() + + def test_index_page_has_no_facilities_dropdown(self): + # tom_base's own test project has no app implementing observation_facilities(), + # so the rendered navbar should not contain the Facilities dropdown at all + response = self.client.get(reverse('home')) + self.assertNotContains(response, '>Facilities<') From 4bce18a7aaab6199f154b6f09ea7a4fb830f6283 Mon Sep 17 00:00:00 2001 From: "William (Lindy) Lindstrom" Date: Mon, 3 Aug 2026 16:22:15 -0700 Subject: [PATCH 05/18] simplify comment --- tom_setup/templates/tom_setup/settings.tmpl | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tom_setup/templates/tom_setup/settings.tmpl b/tom_setup/templates/tom_setup/settings.tmpl index d97f35ace..ae30eb60c 100644 --- a/tom_setup/templates/tom_setup/settings.tmpl +++ b/tom_setup/templates/tom_setup/settings.tmpl @@ -237,9 +237,8 @@ DATA_PROCESSORS = { 'spectroscopy': 'tom_dataproducts.processors.spectroscopy_processor.SpectroscopyProcessor', } -# Facilities listed here are available to your TOM. In addition, any app in INSTALLED_APPS -# that implements the observation_facilities() AppConfig integration point (e.g. tom_keck, -# tom_cfht) contributes its facilities automatically and does not need to be listed here. +# Facilities listed appear on your TOM's TargetDetail page. INSTALLED_APPS that implement the +# observation_facilities() AppConfig integration point do not need to be listed here. TOM_FACILITY_CLASSES = [ 'tom_observations.facilities.lco_redirect.LCORedirectFacility', 'tom_observations.facilities.gemini.GEMFacility', From dd5e8104791973731a1940faf569c93061732dae Mon Sep 17 00:00:00 2001 From: "William (Lindy) Lindstrom" Date: Mon, 3 Aug 2026 16:39:15 -0700 Subject: [PATCH 06/18] make index_url_name a Facility class attribute this is better than having it be an integration point dictionary item --- tom_observations/facility.py | 25 ++------ .../templatetags/observation_extras.py | 64 +++++++------------ 2 files changed, 29 insertions(+), 60 deletions(-) diff --git a/tom_observations/facility.py b/tom_observations/facility.py index 1a4f14004..9cdedc6be 100644 --- a/tom_observations/facility.py +++ b/tom_observations/facility.py @@ -53,27 +53,11 @@ def get_service_classes() -> dict: """Return a dictionary mapping facility name to facility class for all known facilities. Facilities come from two sources, combined here: - 1. ``settings.TOM_FACILITY_CLASSES`` (falling back to ``DEFAULT_FACILITY_CLASSES``), the - traditional explicit configuration mechanism. - 2. The ``observation_facilities()`` AppConfig integration point: any INSTALLED_APP whose - AppConfig defines an ``observation_facilities()`` method contributes its facilities - automatically, with no settings changes required. (This is analogous to the - ``data_services()`` integration point consumed by - ``tom_dataservices.dataservices.get_data_service_classes()``.) - - ``observation_facilities()`` should return a list of dictionaries, each with a ``class`` key - whose value is the dot-separated path to the facility class. Entries may carry additional - keys for other consumers of the integration point — e.g. an optional ``url`` key naming the - facility's landing page for the navbar "Facilities" menu (see - ``tom_observations.templatetags.observation_extras.observation_facilities_list``) — but only - ``class`` is consumed here. - - FOR EXAMPLE: - [{'class': 'tom_keck.keck.KeckFacility'}] + 1. ``settings.TOM_FACILITY_CLASSES`` + 2. ``observation_facilities()`` AppConfig integration point (see ``tom_demoapp`` for example). Returns: - dict: mapping of ``Facility.name`` to facility class. A facility appearing in both - sources (same ``name``) is only included once; the app-supplied class wins. + dict: {facility_name: FacilityClass} """ try: TOM_FACILITY_CLASSES = settings.TOM_FACILITY_CLASSES @@ -268,6 +252,9 @@ class BaseObservationFacility(ABC): is_redirect = False button_label = "" button_tooltip = "" + #: Namespaced URL name of this facility's index page. + #: None means no index page and no Facilities nav-bar menu item. + index_url_name: str | None = None def __init__(self): self.user = None diff --git a/tom_observations/templatetags/observation_extras.py b/tom_observations/templatetags/observation_extras.py index 12888545e..8a3a4ca63 100644 --- a/tom_observations/templatetags/observation_extras.py +++ b/tom_observations/templatetags/observation_extras.py @@ -3,10 +3,8 @@ from urllib.parse import urlencode from django import forms, template -from django.apps import apps from django.conf import settings from django.urls import NoReverseMatch, reverse -from django.utils.module_loading import import_string from guardian.shortcuts import get_objects_for_user from plotly import offline import plotly.graph_objs as go @@ -27,47 +25,31 @@ @register.inclusion_tag('tom_observations/partials/navbar_facilities_list.html', takes_context=True) def observation_facilities_list(context: dict) -> dict: """ - Returns the list of app-contributed observation facilities used to generate the - "Facilities" navbar dropdown links. - - Facilities are gathered from installed apps implementing the observation_facilities() - AppConfig integration point (see tom_observations.facility.get_service_classes()). - In addition to the 'class' key consumed by get_service_classes(), each facility - dictionary may include an optional 'url' key: the namespaced URL name of that - facility's landing page, to which its navbar menu item will link. A facility without - a 'url' is registration-only: it gets no navbar menu item (and if no facility - supplies a 'url', the "Facilities" dropdown is not displayed at all). + Returns the facilities linked from the "Facilities" navbar dropdown. + + A facility from ``get_service_classes()`` is listed when its class sets ``index_url_name``. """ - facilities = [] - for app in apps.get_app_configs(): + navbar_facilities = [] + for facility_class in get_service_classes().values(): + index_url_name = getattr(facility_class, 'index_url_name', None) + if not index_url_name: + continue # registration-only facility: no index page, so no menu item + + # make sure we can reverse the URL try: - observation_facilities = app.observation_facilities() - except AttributeError: - continue # this app doesn't implement the integration point - for facility in observation_facilities: - # a facility without a 'url' key declares no landing page; it is registered by - # get_service_classes() but deliberately gets no navbar menu item - url_name = facility.get('url') - if url_name is None: - continue - # resolve the facility class for its display name, and the landing page URL - # for the link target; skip (with a warning) anything that doesn't resolve, - # so one bad entry doesn't take out the whole navbar - try: - clazz = import_string(facility['class']) - except ImportError as e: - logger.warning(f'WARNING: Could not import facility class for {app.name} from ' - f'{facility["class"]}: {e}') - continue - try: - url = reverse(url_name) - except NoReverseMatch as e: - logger.warning(f'WARNING: Could not resolve landing page URL for facility {clazz.name} ' - f'of {app.name}: {e}') - continue - facilities.append({'name': clazz.name, 'url': url}) - - context['observation_facilities'] = facilities + url = reverse(index_url_name) + except NoReverseMatch as e: + logger.warning(f'WARNING: Could not resolve index page URL for facility ' + f'{facility_class.name}: {e}') + continue + navbar_facilities.append( + { + "name": facility_class.name, + "url": url, + } + ) + + context['observation_facilities'] = navbar_facilities return context From e5e5d5f4fc76e1a31f86400f7e0e7b8328a4cf50 Mon Sep 17 00:00:00 2001 From: "William (Lindy) Lindstrom" Date: Mon, 3 Aug 2026 16:40:37 -0700 Subject: [PATCH 07/18] update doc for observation_facilities integration point --- docs/common/customsettings.rst | 3 ++ docs/observing/observation_module.rst | 40 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/docs/common/customsettings.rst b/docs/common/customsettings.rst index 912024159..733c0d667 100644 --- a/docs/common/customsettings.rst +++ b/docs/common/customsettings.rst @@ -181,6 +181,9 @@ A list of observation facility classes to make available to your TOM. If you have written or downloaded a custom observation facility you would add the class to this list to make your TOM load it. +INSTALLED_APPS that implement the ``observation_facilities()`` AppConfig integration +point do not need to be listed here. + `TOM_LATEX_PROCESSORS <#tom-latex-processors>`__ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/observing/observation_module.rst b/docs/observing/observation_module.rst index 85b3fcf15..433f59d26 100644 --- a/docs/observing/observation_module.rst +++ b/docs/observing/observation_module.rst @@ -105,6 +105,25 @@ like this: This means our new observation facility module has been successfully loaded. +Adding a facility from an app +----------------------------- + +A reusable app can contribute its facilities without the TOM editing ``settings.py``. +Implement ``observation_facilities()`` on the app's ``AppConfig``: + +.. code:: python + + class MyAppConfig(AppConfig): + name = 'myapp' + + def observation_facilities(self): + return [{'class': f'{self.name}.myfacility.MyObservationFacility'}] + +Facilities from both routes are merged by +``tom_observations.facility.get_service_classes()``, so adding the app to +``INSTALLED_APPS`` is all that is required of the TOM. See ``tom_demoapp`` for a +worked example. + BaseRoboticObservationFacility and BaseRoboticObservationForm ------------------------------------------------------------- @@ -126,6 +145,27 @@ use. The ``BaseRoboticObservationForm`` class, just like the previous super class, contains logic and layout that all observation facility form classes should contain. +Linking to a facility index page +-------------------------------- + +A facility may set ``index_url_name`` to the namespaced Django URL name of a page +describing the facility. Facilities that set it appear in the navbar **Facilities** +dropdown, linked to that page: + +.. code:: python + + class MyObservationFacility(BaseRoboticObservationFacility): + name = 'MyFacility' + index_url_name = 'myapp:facility-index' + +``index_url_name`` is optional and is omitted from the minimal example above. A facility +that leaves it unset is still fully registered -- it has an observe button and observation +forms -- but gets no menu item. If no facility sets it, the dropdown is not displayed. + +The namespace is the one the facility's URLs are deployed under. For an app, that is the +``namespace`` argument its ``include_url_paths()`` passes to ``include()``, which is not +necessarily the app's name: ``tom_demoapp`` is deployed under ``demoapp``. + Implementing observation submission ----------------------------------- From 9cb13994c01785970684510ae098e9a5e80357c0 Mon Sep 17 00:00:00 2001 From: "William (Lindy) Lindstrom" Date: Mon, 3 Aug 2026 16:41:08 -0700 Subject: [PATCH 08/18] improve docstring with tom_demoapp reference --- tom_observations/apps.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tom_observations/apps.py b/tom_observations/apps.py index 6bbc6ae0f..7b3934e24 100644 --- a/tom_observations/apps.py +++ b/tom_observations/apps.py @@ -10,8 +10,7 @@ def nav_items(self): This method should return a list of partial templates to be included in the navbar. Here, the "Facilities" dropdown menu, listing the facilities contributed by installed - apps via the observation_facilities() integration point (see - tom_observations.facility.get_service_classes()). + apps via the observation_facilities() integration point (see ``tom_demoapp`` for example). """ return [{'partial': 'tom_observations/partials/navbar_facilities_list.html', 'context': 'tom_observations.templatetags.observation_extras.observation_facilities_list'}] From 0ad385f87ecf08bf0832ea72a18cff2fe3214962 Mon Sep 17 00:00:00 2001 From: "William (Lindy) Lindstrom" Date: Mon, 3 Aug 2026 17:47:12 -0700 Subject: [PATCH 09/18] update for index_url_name class attribute (not int point config) --- .../tom_observations/partials/navbar_facilities_list.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tom_observations/templates/tom_observations/partials/navbar_facilities_list.html b/tom_observations/templates/tom_observations/partials/navbar_facilities_list.html index 98944740e..283d44617 100644 --- a/tom_observations/templates/tom_observations/partials/navbar_facilities_list.html +++ b/tom_observations/templates/tom_observations/partials/navbar_facilities_list.html @@ -1,6 +1,6 @@ -{# "Facilities" navbar dropdown: one menu item per facility contributed via the #} -{# observation_facilities() AppConfig integration point. Context comes from #} -{# observation_extras.observation_facilities_list. #} +{# "Facilities" navbar dropdown: one menu item per facility whose class sets #} +{# index_url_name (see BaseObservationFacility). Context comes from #} +{# observation_extras.observation_facilities_list. #} {% if observation_facilities %}