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..bd1088c0a 100644 --- a/docs/observing/observation_module.rst +++ b/docs/observing/observation_module.rst @@ -105,6 +105,28 @@ like this: This means our new observation facility module has been successfully loaded. +Adding a facility from an app +----------------------------- + +A facility implemented as a Django reusable app can advertise itself +to a TOM without the editing ``settings.py``, which simplifies it's installation. +To do this, implement the ``observation_facilities()`` integration point +on the app's ``AppConfig`` subclass: + +.. code:: python + + class MyAppConfig(AppConfig): + name = 'myapp' + + def observation_facilities(self): + return [{'class': f'{self.name}.myfacility.MyObservationFacility'}] + +Facilities from both ``settings.TOM_FACILITY_CLASSES`` and the apps implementing +the integration point are merged by ``tom_observations.facility.get_service_classes()``. +So, if your facility implements the ``observation_facilities()`` integration point, +adding the app to ``INSTALLED_APPS`` is all that is required for installation. +See ``tom_demoapp`` for a worked example. + BaseRoboticObservationFacility and BaseRoboticObservationForm ------------------------------------------------------------- @@ -126,6 +148,47 @@ 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 detail page +--------------------------------- + +A facility may set ``detail_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' + detail_url_name = 'myapp:facility-detail' + +``detail_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. +See ``tom_demoapp`` for a worked example, including composing the namespace from the +AppConfig's ``url_namespace`` attribute. + +For ``detail_url_name`` to resolve, the app's URLs must be mounted in the TOM under that +namespace. A reusable app mounts its own ``urls.py`` through the ``include_url_paths()`` +AppConfig integration point -- ``tom_common`` collects these paths from every installed +app, so no TOM ``urls.py`` edits are required: + +.. code:: python + + class MyAppConfig(AppConfig): + name = 'myapp' + url_prefix = 'myapp' # URL path prefix for this app's pages: HOST/myapp/... + url_namespace = 'myapp' # the namespace half of detail_url_name + + def include_url_paths(self): + return [ + path(f'{self.url_prefix}/', include(f'{self.name}.urls', namespace=self.url_namespace)), + ] + +(``url_prefix`` and ``url_namespace`` are TOM plugin conventions, not Django AppConfig +attributes.) The included ``urls.py`` must set ``app_name`` -- Django requires it when +``include()`` is called with ``namespace=`` -- and must contain a ``path()`` whose +``name=`` is the second half of ``detail_url_name``. + Implementing observation submission ----------------------------------- diff --git a/tom_dataservices/dataservices.py b/tom_dataservices/dataservices.py index eca37b705..182a3d5cd 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). @@ -84,8 +84,9 @@ class QueryServiceError(Exception): class DataService(ABC): - """ - Base class for all Data Services. Data Services are classes that are responsible for querying external services + """Base class for all Data Services. + + Data Services are classes that are responsible for querying external services and returning data. """ # Recognizable name for the DataService (Gaia, TNS, etc) @@ -123,11 +124,11 @@ def query_service(self, query_parameters, **kwargs): """Takes in the serialized data from the query form and actually submits the query to the service""" def pre_query_validation(self, query_parameters): - """Same thing as query_service, but a dry run""" + """Same thing as query_service, but a dry run.""" raise NotImplementedError(f'pre_query_validation method has not been implemented for {self.name}') def build_query_parameters(self, parameters, **kwargs): - """Builds the query parameters from the form data""" + """Builds the query parameters from the form data.""" raise NotImplementedError(f'build_query_parameters method has not been implemented for {self.name}') # Include this method if you wish for the TOM to be able to query data for an individual Target. @@ -200,7 +201,7 @@ def get_credentials(cls, **kwargs): @classmethod def urls(cls, **kwargs) -> dict: - """Dictionary of URLS for the DataService""" + """Dictionary of URLS for the DataService.""" return {'base_url': cls.base_url, 'info_url': cls.info_url} @classmethod @@ -387,6 +388,7 @@ def to_target(self, target_result=None, **kwargs): def create_target_from_query(self, target_result, **kwargs): """Create a new target from a single instance of the target results. + :param target_result: dictionary describing target details based on query result :returns: target object :rtype: `Target` @@ -420,7 +422,8 @@ def to_aliases(self, target, alias_results: List, **kwargs) -> List: return new_aliases def create_aliases_from_query(self, alias_results: List, **kwargs) -> List: - """Create a new target name from the query results + """Create a new target name from the query results. + This method should be over ridden with a method that creates a list of TargetName objects: `TargetName(name=alias)` that will be saved as part of the `Target.save(extras=extras, names=aliases)` call. :param query_result: list of dictionaries describing target details based on query result diff --git a/tom_observations/apps.py b/tom_observations/apps.py index a4c446f7f..9cfa8c2de 100644 --- a/tom_observations/apps.py +++ b/tom_observations/apps.py @@ -3,3 +3,14 @@ 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_demoapp`` for example). + """ + return [{'partial': 'tom_observations/partials/navbar_facilities_list.html', + 'context': 'tom_observations.templatetags.observation_extras.observation_facilities_list'}] diff --git a/tom_observations/facility.py b/tom_observations/facility.py index 8b878dfa4..328b83c60 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 @@ -19,8 +20,7 @@ class CredentialStatus(Enum): - """ - Enum representing the status of facility credentials. + """Enum representing the status of facility credentials. This enum is used to track the state of credentials throughout the facility lifecycle, providing clear information about whether credentials are available, where they came from, @@ -48,33 +48,70 @@ 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`` + 2. ``observation_facilities()`` AppConfig integration point (see ``tom_demoapp`` for example). + + Returns: + dict: {facility_name: FacilityClass} + """ try: TOM_FACILITY_CLASSES = settings.TOM_FACILITY_CLASSES except AttributeError: TOM_FACILITY_CLASSES = DEFAULT_FACILITY_CLASSES service_choices = {} + # 1 get the facilities from settings.py for service in TOM_FACILITY_CLASSES: try: clazz = import_string(service) except (ImportError, AttributeError) as e: raise ImportError(f'Could not import {service}: {e}') service_choices[clazz.name] = clazz + + # 2 add the faciliites from apps implementing the integration point + for app in apps.get_app_configs(): + observation_facilities_hook = getattr(app, 'observation_facilities', None) + if observation_facilities_hook is None: + continue # this app doesn't implement the integration point + for facility in observation_facilities_hook() or []: # `or []` tolerates a hook returning None + try: + clazz = import_string(facility['class']) + except KeyError: + # the integration point returned a mal-formed conifguration dict + logger.warning(f'WARNING: observation_facilities() entry from {app.name} is missing ' + f'the required "class" key: {facility!r}. Facility skipped.') + continue + except ImportError as e: + # the class couldn't be imported + 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 def get_service_class(name): + """Return the single, named facility class. + + Note: Implementation gets all the facilities and returns the named one. + """ available_classes = get_service_classes() try: return available_classes[name] except KeyError: - raise ImportError('Could not a find a facility with that name. Did you add it to TOM_FACILITY_CLASSES?') + raise ImportError(f'Could not find a facility named {name}. Add it to settings.TOM_FACILITY_CLASSES or ' + f'implement the observation_facilities() integration point in the AppConfig subclass.') class BaseObservationForm(forms.Form): - """ - This is the class that is responsible for displaying the observation request form. + """Class that is responsible for displaying the observation request form. + This form is meant to be subclassed by more specific BaseForm classes that represent a form for a particular type of facility. For implementing your own form, please look to the other BaseObservationForms. @@ -114,6 +151,7 @@ def __init__(self, *args, **kwargs): def layout(self) -> Layout: """Define (and return) a crispy_forms.Layout for the fields of your subclass. + It will be inserted after the common_layout and before the button_layout, as defined above in __init__(), where self.helper.layout is assigned. @@ -131,8 +169,8 @@ def button_layout(self): ) def get_validation_message(self): - """ Override this or self.validation_message to return a validation message that is shown when - the Validate button is clicked and the form is valid + """Override this or self.validation_message to return a validation message that is shown when + the Validate button is clicked and the form is valid """ return self.validation_message @@ -180,8 +218,8 @@ class BaseRoboticObservationForm(BaseObservationForm): class BaseManualObservationForm(BaseObservationForm): - """ - This is the class that is responsible for displaying the observation request form. + """Base class for observation request forms. + Facility classes that provide a form should subclass this form. It provides some base shared functionality. Extra fields are provided below. The layout is handled by Django crispy forms which allows customizability of the @@ -213,17 +251,15 @@ def layout(self): class BaseObservationFacility(ABC): - """ - This is the class that is responsible for defining the base facility class. - This form is meant to be subclassed by more specific BaseFacility classes that represent a - form for a particular type of facility. For implementing your own form, please look to - the other BaseObservationFacilities. - """ + """Base class for observation facilities.""" name = 'BaseObservation' observation_forms = {} is_redirect = False button_label = "" button_tooltip = "" + #: Namespaced URL name of this facility's detail page. + #: None means no detail page and no Facilities nav-bar menu item. + detail_url_name: str | None = None def __init__(self): self.user = None @@ -279,8 +315,7 @@ def _get_setting_credentials(self, facility_name, credential_keys): return credentials def _raise_no_profile_error(self, user, facility_name): - """ - Raise ImproperlyConfigured for missing user profile. + """Raise ImproperlyConfigured for missing user profile. Args: user: Django User instance @@ -295,8 +330,7 @@ def _raise_no_profile_error(self, user, facility_name): ) def _raise_no_defaults_error(self, user, facility_name): - """ - Raise ImproperlyConfigured when default credentials are needed but missing. + """Raise ImproperlyConfigured when default credentials are needed but missing. Args: user: Django User instance @@ -338,16 +372,14 @@ def all_data_products(self, observation_record): @abstractmethod def get_form(self, observation_type): - """ - This method takes in an observation type and returns the form type that matches it. + """This method takes in an observation type and returns the form type that matches it. Note: This method returns form classes, not instances, to support composite form creation in ObservationCreateView. Use create_form_instance() for direct form instantiation. """ def create_form_instance(self, observation_type, **kwargs): - """ - Create a form instance with facility context injected. + """Create a form instance with facility context injected. The ObservationCreateView handles setting the user context on the facility instance via set_user() in its dispatch() method. Forms receive the facility instance and @@ -363,8 +395,7 @@ def create_form_instance(self, observation_type, **kwargs): return form_class(**kwargs) def get_form_classes_for_display(self, **kwargs): - """ - This method returns a dictionary of the format: + """This method returns a dictionary of the format: {'OBSERVATION_TYPE': FacilityFormClass} @@ -384,66 +415,58 @@ def get_facility_context_data(self, **kwargs): # TODO: consider making submit_observation create ObservationRecords as well @abstractmethod def submit_observation(self, observation_payload): - """ - This method takes in the serialized data from the form and actually + """This method takes in the serialized data from the form and actually submits the observation to the remote api """ @abstractmethod def validate_observation(self, observation_payload): - """ - Same thing as submit_observation, but a dry run. You can - skip this in different modules by just using "pass" + """Validate an observation request through the facility's API, + but don't submit the request (i.e. a "dry-run"). + You can skip this in different modules by just using "pass" Typically called by the ObservationForm.is_valid() method. """ def get_flux_constant(self): - """ - Returns the astropy quantity that a facility uses for its spectral flux conversion. - """ + """Returns the astropy quantity that a facility uses for its spectral flux conversion.""" def get_wavelength_units(self): - """ - Returns the astropy units that a facility uses for its spectral wavelengths - """ + """Returns the astropy units that a facility uses for its spectral wavelengths.""" def is_fits_facility(self, header): - """ - Returns True if the FITS header is from this facility based on valid keywords and associated - values, False otherwise. + """Returns True if the FITS header is from this facility. + + Return value is based on valid keywords and associated values. """ return False def get_start_end_keywords(self): - """ - Returns the keywords representing the start and end of an observation window for a facility. Defaults to - ``start`` and ``end``. + """Returns the keywords representing the start and end of an observation window for a facility. + + Defaults to ``start`` and ``end``. """ return 'start', 'end' @abstractmethod def get_terminal_observing_states(self): - """ - Returns the states for which an observation is not expected - to change. - """ + """Returns the states for which an observation is not expected to change.""" @abstractmethod def get_observing_sites(self): - """ - Return an iterable of dictionaries that contain the information - necessary to be used in the planning (visibility) tool. The - iterable should contain dictionaries each that contain sitecode, + """Return an iterable of dictionaries that contain the information + necessary to be used in the planning (visibility) tool. + + The returned iterable should contain dictionaries each that contain sitecode, latitude, longitude and elevation. This is the static information about a site. """ def get_facility_weather_urls(self): - """ - Returns a dictionary containing a URL for weather information - for each site in the Facility SITES. This is intended to be useful - in observation planning. + """Returns a dictionary containing a URL for weather information + for each site in the Facility SITES. + + This is intended to be useful in observation planning. `facility_weather = {'code': 'XYZ', 'sites': [ site_dict, ... ]}` where @@ -453,9 +476,9 @@ def get_facility_weather_urls(self): return {} def get_facility_status(self): - """ - Returns a dictionary describing the current availability of the Facility - telescopes. This is intended to be useful in observation planning. + """Returns a dictionary describing the current availability of the Facility telescopes. + + This is intended to be useful in observation planning. The top-level (Facility) dictionary has a list of sites. Each site is represented by a site dictionary which has a list of telescopes. Each telescope has an identifier (code) and an status string. @@ -473,8 +496,8 @@ def get_facility_status(self): return {} def cancel_observation(self, observation_id): - """ - Takes an observation id and submits a request to the observatory that the observation be cancelled. + """Takes an observation id and submits a request to the observation facility + that the observation be cancelled. If the cancellation was successful, return True. Otherwise, return False. """ @@ -482,10 +505,10 @@ def cancel_observation(self, observation_id): @abstractmethod def get_observation_url(self, observation_id): - """ - Takes an observation id and return the url for which a user - can view the observation at an external location. In this case, - we return a URL to the LCO observation portal's observation + """Takes an observation id and returns the url with which a user can view the observation + at the observation facility. + + For example, we return a URL to the LCO observation portal's observation record page. """ @@ -493,11 +516,11 @@ def get_date_obs_from_fits_header(self, header): return None def get_button_label(self): - """ The label that will appear on observe button""" + """The label that will appear on observe button.""" return self.button_label or self.name def get_button_tooltip(self): - """ The tooltip that will appear on observe button""" + """The tooltip that will appear on observe button.""" return self.button_tooltip 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..313167ec3 --- /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 whose class sets #} +{# detail_url_name (see BaseObservationFacility). Context comes from #} +{# observation_extras.observation_facilities_list. #} +{% if observation_facilities %} +