Skip to content

Commit 3d9313f

Browse files
authored
Merge pull request #1269 from TOMToolkit/feature/pass-user-from-view-to-facilities
Feature/pass user from view to facilities
2 parents 2488c49 + c46b408 commit 3d9313f

6 files changed

Lines changed: 414 additions & 28 deletions

File tree

docs/api/tom_observations/facilities.rst

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,16 @@ Blanco
3737
******
3838

3939
.. automodule:: tom_observations.facilities.blanco
40-
:members:
40+
:members:
41+
42+
43+
********************************
44+
Facilities as ``INSTALLED_APPS``
45+
********************************
46+
47+
The following Facilities are implemented as ``INSTALLED_APPS``. As such,
48+
each resides in its own git repository:
49+
50+
* `Neil Gehrels Swift Observatory (tom_swift) <https://github.com/TOMToolkit/tom_swift>`__
51+
* `European Southern Observatory (tom_eso) <https://github.com/TOMToolkit/tom_eso>`__
52+
* `Liverpool Telescope (tom_lt) <https://github.com/TOMToolkit/tom_lt>`__

docs/observing/observation_module.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,39 @@ Even if the facilities you observe at are not API-accessible, you
349349
can still add them to your TOM’s airmass plots to judge what targets to
350350
observe when.
351351

352+
How to add User-specific credentials to your Facility
353+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
354+
355+
Observation facilities typically require credentials (username and password or API key)
356+
to interact and submit observation requests. That is, you have to "log in" to use the Facility's
357+
API or client library.
358+
359+
A TOM can be configured to provide common Facility credentials for every User of the TOM.
360+
These are specified in the ``FACILITIES`` dictionary of ``settings.py``. In this case, the TOM
361+
administrator would be resposible for maintaining the appropriate credentials. All observation
362+
requests from the TOM would use those same credentials.
363+
364+
While you may write your Facility to use that mechanism, a few additional steps allow your
365+
Facility to provide for individual, User-specific credentials. Each TOM user can thus manage
366+
their own credentials and use them to submit their own observation requests.
367+
368+
In outline, your Facility must:
369+
370+
1. Create a FaciltyProfile model in your Facility's ``models.py``. (Your Facility is typically
371+
implmemented as an ``INSTALLED_APP`` and as such has a ``models.py`` module). This is described
372+
and demonstrated in the `tom_demoapp <https://github.com/TOMToolkit/tom_demoapp>`__ and documented
373+
`here <https://github.com/TOMToolkit/tom_demoapp/wiki/Integration-Points#profile-details>`__.
374+
375+
2. The fields of your Facility's Profile model would store the required credentials (for example,
376+
a username and password or an API-key). Sensitive data such as passwords and API-keys should be
377+
saved in an encrypted fashion and that is described
378+
:doc:`here <../customization/encrypted_model_fields>`.
379+
380+
3. Finally, your Facility class must access these credentials to intereact with your Facility.
381+
An example of this can be found in the TOMToolkit
382+
`ESOFacility <https://github.com/TOMToolkit/tom_eso/blob/6e3575c7e44cdb09f3df83740f2e9f522afbb603/tom_eso/eso.py#L378>`__.
383+
384+
352385
Happy developing!
353386

354387

tom_common/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ def __get__(self, instance, owner):
6565
if not isinstance(cipher, Fernet):
6666
raise AttributeError(
6767
f"A Fernet cipher must be set on the '{owner.__name__}' instance "
68-
f"as '_cipher' to access property '{self.property_name}'."
68+
f"as '_cipher' to access property '{self.property_name}'. "
69+
f"Please use session_utils.get_encrypted_field() instead of direct access."
6970
)
7071

7172
encrypted_value = getattr(instance, self.db_field_name)

tom_observations/facility.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from abc import ABC, abstractmethod
22
import copy
3+
from enum import Enum
34
import logging
45
import requests
56

@@ -8,13 +9,32 @@
89
from django import forms
910
from django.conf import settings
1011
from django.contrib.auth.models import Group
12+
from django.core.exceptions import ImproperlyConfigured
1113
from django.core.files.base import ContentFile
1214
from django.utils.module_loading import import_string
1315

1416
from tom_targets.models import Target
1517

1618
logger = logging.getLogger(__name__)
1719

20+
21+
class CredentialStatus(Enum):
22+
"""
23+
Enum representing the status of facility credentials.
24+
25+
This enum is used to track the state of credentials throughout the facility lifecycle,
26+
providing clear information about whether credentials are available, where they came from,
27+
and any validation issues.
28+
"""
29+
NOT_INITIALIZED = "not_initialized" # set_user() hasn't been called yet
30+
NO_PROFILE = "no_profile" # User has no Profile (raises ImproperlyConfigured)
31+
PROFILE_EMPTY = "profile_empty" # Profile exists but credentials empty
32+
USING_DEFAULTS = "using_defaults" # Using settings.FACILITIES defaults
33+
USING_USER_CREDS = "using_user_creds" # Using user's Profile credentials
34+
VALIDATION_FAILED_AUTH = "validation_failed_auth" # Credentials failed (401/403)
35+
VALIDATION_FAILED_NETWORK = "validation_failed_network" # Network/server issue
36+
37+
1838
DEFAULT_FACILITY_CLASSES = [
1939
'tom_observations.facilities.lco.LCOFacility',
2040
'tom_observations.facilities.gemini.GEMFacility',
@@ -67,8 +87,17 @@ class BaseObservationForm(forms.Form):
6787
observation_type = forms.CharField(required=False, max_length=50, widget=forms.HiddenInput())
6888

6989
def __init__(self, *args, **kwargs):
90+
# DEBUG: Log what parameters are being passed
91+
logger.debug(f'BaseObservationForm.__init__ kwargs: {kwargs}')
92+
93+
# Accept facility parameter but don't require it (for backward compatibility)
94+
facility = kwargs.pop('facility', None)
7095
self.validation_message = 'This observation is valid.'
7196
super().__init__(*args, **kwargs)
97+
98+
# Store facility reference if provided
99+
if facility is not None:
100+
self.facility = facility
72101
self.helper = FormHelper()
73102
if settings.TARGET_PERMISSIONS_ONLY:
74103
self.common_layout = Layout('facility', 'target_id', 'observation_type')
@@ -195,10 +224,91 @@ class BaseObservationFacility(ABC):
195224

196225
def __init__(self):
197226
self.user = None
227+
self.credential_status = CredentialStatus.NOT_INITIALIZED
198228

199229
def set_user(self, user):
200230
self.user = user
201231

232+
def _is_credential_empty(self, credential):
233+
"""
234+
Check if a credential is empty (None, empty string, or whitespace only).
235+
236+
Args:
237+
credential: The credential value to check
238+
239+
Returns:
240+
bool: True if credential is empty, False otherwise
241+
"""
242+
return credential is None or (isinstance(credential, str) and not credential.strip())
243+
244+
def _get_setting_credentials(self, facility_name, credential_keys):
245+
"""
246+
Safely get credentials from settings.FACILITIES.
247+
248+
Args:
249+
facility_name (str): Name of the facility in settings.FACILITIES
250+
credential_keys (list): List of credential key names to retrieve
251+
252+
Returns:
253+
dict: Dictionary mapping credential keys to their values
254+
255+
Raises:
256+
ImproperlyConfigured: If facility or required keys are missing from settings
257+
"""
258+
if not hasattr(settings, 'FACILITIES') or facility_name not in settings.FACILITIES:
259+
raise ImproperlyConfigured(
260+
f"No configuration found for '{facility_name}' in settings.FACILITIES. "
261+
f"Please add default credentials to settings.FACILITIES['{facility_name}']."
262+
)
263+
264+
facility_settings = settings.FACILITIES[facility_name]
265+
credentials = {}
266+
267+
for key in credential_keys:
268+
if key not in facility_settings:
269+
raise ImproperlyConfigured(
270+
f"Required credential key '{key}' not found in "
271+
f"settings.FACILITIES['{facility_name}']. "
272+
f"Please add '{key}' to the facility configuration."
273+
)
274+
credentials[key] = facility_settings[key]
275+
276+
return credentials
277+
278+
def _raise_no_profile_error(self, user, facility_name):
279+
"""
280+
Raise ImproperlyConfigured for missing user profile.
281+
282+
Args:
283+
user: Django User instance
284+
facility_name (str): Name of the facility
285+
286+
Raises:
287+
ImproperlyConfigured: Always raises with informative message
288+
"""
289+
raise ImproperlyConfigured(
290+
f"User '{user.username}' has no {facility_name}Profile configured. "
291+
f"Please create a {facility_name}Profile for this user in the admin interface."
292+
)
293+
294+
def _raise_no_defaults_error(self, user, facility_name):
295+
"""
296+
Raise ImproperlyConfigured when default credentials are needed but missing.
297+
298+
Args:
299+
user: Django User instance
300+
facility_name (str): Name of the facility
301+
302+
Raises:
303+
ImproperlyConfigured: Always raises with informative message
304+
"""
305+
raise ImproperlyConfigured(
306+
f"User '{user.username}' has no credentials configured and no default credentials "
307+
f"found in settings.FACILITIES['{facility_name}']. "
308+
f"Please configure either user credentials in {facility_name}Profile or "
309+
f"default credentials in settings."
310+
)
311+
202312
def all_data_products(self, observation_record):
203313
from tom_dataproducts.models import DataProduct
204314
products = {'saved': [], 'unsaved': []}
@@ -227,7 +337,27 @@ def all_data_products(self, observation_record):
227337
def get_form(self, observation_type):
228338
"""
229339
This method takes in an observation type and returns the form type that matches it.
340+
341+
Note: This method returns form classes, not instances, to support composite form creation
342+
in ObservationCreateView. Use create_form_instance() for direct form instantiation.
343+
"""
344+
345+
def create_form_instance(self, observation_type, **kwargs):
346+
"""
347+
Create a form instance with facility context injected.
348+
349+
The ObservationCreateView handles setting the user context on the facility instance
350+
via set_user() in its dispatch() method. Forms receive the facility instance and
351+
can query it for user-specific data (credentials, API clients, etc.) rather than
352+
handling business logic themselves.
353+
354+
:param observation_type: The type of observation form to create
355+
:param kwargs: Additional keyword arguments for form instantiation
356+
:return: Form instance configured for the observation type
230357
"""
358+
form_class = self.get_form(observation_type)
359+
kwargs['facility'] = self
360+
return form_class(**kwargs)
231361

232362
def get_form_classes_for_display(self, **kwargs):
233363
"""

0 commit comments

Comments
 (0)