Skip to content

Commit dfb72d8

Browse files
authored
Merge pull request #1357 from TOMToolkit/redirect-facility
Initial POC for a facility that uses redirects
2 parents 54eeea9 + f584421 commit dfb72d8

7 files changed

Lines changed: 219 additions & 15 deletions

File tree

docs/observing/customize_ocs_facility.rst

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Now add some code to this file to create a new observation module:
7474
name = 'CustomOCS'
7575
observation_forms = {
7676
'Instrument1': Instrument1ObservationForm,
77-
'Spectra': SpectraObservationForm
77+
'Spectra': SpectraObservationForm
7878
}
7979
8080
So what does the above code do?
@@ -156,7 +156,7 @@ by subclassing the base class of the full OCS observation form:
156156
# The init method is where you will define fields, since the field names are
157157
# set based on the number of configurations and instrument configurations our
158158
# form supports. You can also remove base fields here if you don't want them
159-
# in your form.
159+
# in your form.
160160
for j in range(self.facility_settings.get_setting('max_configurations')):
161161
for i in range(self.facility_settings.get_setting('max_instrument_configs')):
162162
self.fields[f'c_{j+1}_ic_{i+1}_defocus'] = forms.IntegerField(
@@ -192,8 +192,8 @@ by subclassing the base class of the full OCS observation form:
192192
return Instrument1InstrumentConfigLayout
193193
194194
def _build_instrument_config(self, instrument_type, configuration_id, id):
195-
# This is called when submitting or validating the form, and it constructs the
196-
# payload to send to the OCS observation portal. You can get the payload with
195+
# This is called when submitting or validating the form, and it constructs the
196+
# payload to send to the OCS observation portal. You can get the payload with
197197
# base fields and then add your new fields in here.
198198
instrument_config = super()._build_instrument_config(instrument_type, configuration_id, id)
199199
if self.cleaned_data.get(f'c_{j+1}_ic_{i+1}_readout_mode'):
@@ -251,8 +251,8 @@ the full OCS observation form: ``tom_observations.facilities.ocs.OCSFullObservat
251251
),
252252
css_class='form-row'
253253
)
254-
)
255-
254+
)
255+
256256
def get_final_ic_items(self, config_instance, instance):
257257
# This piece of layout will be added at the end of the base Instrument Config
258258
# Layout. There is also a method that could be overridden to add to the beginning,
@@ -319,7 +319,7 @@ the full OCS observation form: ``tom_observations.facilities.ocs.OCSFullObservat
319319
return SpectrographConfigurationLayout
320320
321321
def _build_acquisition_config(self, configuration_id):
322-
# This is called when submitting or validating the form, and it constructs the
322+
# This is called when submitting or validating the form, and it constructs the
323323
# acquisition config payload. Here we will add our extra fields into the payload
324324
acquisition_config = super()._build_acquisition_config(configuration_id)
325325
if self.cleaned_data.get(f'c_{configuration_id}_acquisition_mode'):
@@ -337,7 +337,7 @@ the full OCS observation form: ``tom_observations.facilities.ocs.OCSFullObservat
337337
return acquisition_config
338338
339339
The above code should define a form which only has spectrograph instruments, and adds three new
340-
fields to the `acquisition_config` section of the form.
340+
fields to the `acquisition_config` section of the form.
341341
342342
Now that you have defined both new forms, your new OCS-based facility module should be complete!
343343
Try reloading your TOM and navigating to the details page for a specific Target. You should see
@@ -403,7 +403,7 @@ class, start by subclassing ``OCSSettings`` like this:
403403
'elevation': 1804
404404
},
405405
}
406-
406+
407407
def get_weather_urls(self):
408408
# Returns a dictionary of sites with weather urls for retrieving weather data for each site
409409
return {
@@ -420,7 +420,7 @@ class, start by subclassing ``OCSSettings`` like this:
420420
name = 'CustomOCS'
421421
observation_forms = {
422422
'Instrument1': Instrument1ObservationForm,
423-
'Spectra': SpectraObservationForm
423+
'Spectra': SpectraObservationForm
424424
}
425425
426426
def __init__(self, facility_settings=CustomOCSSettings('CustomOCS')):
@@ -432,3 +432,34 @@ class. Please review
432432
`the base OCSSettings class <https://github.com/TOMToolkit/tom_base/blob/dev/tom_observations/facilities/ocs.py#L23>`__
433433
to see what other behaviour can be customized, including certain fields `help_text` or certain archive
434434
data configuration information.
435+
436+
Redirect Faciltiies
437+
~~~~~~~~~~~~~~~~~~~
438+
439+
Facilities that subclass `BaseRedirectObservationFacility` work differently than other facilities
440+
in that they do not provide any forms or validation logic. When a user decides to submit an observation
441+
using a redirect facility they are redirected to an outside URL where they will create an observation
442+
and be redirected back to the TOM when they are finished.
443+
444+
In order to create a Redirect Facility, you need to subclass `BaseRedirectObservationFacility` and implement the
445+
`get_redirect_url()` method. Here is a simplified example from the `LcoRedirectFacility` class:
446+
447+
448+
.. code-block:: python
449+
450+
class LCORedirectFacility(BaseRedirectObservationFacility):
451+
name = "LCORedirect"
452+
453+
def redirect_url(self, target_id, callback_url):
454+
target = get_object_or_404(Target, pk=target_id)
455+
query_params = self.target_to_query_params(target)
456+
url = f"https://observe.lco.global/create?{query_params}&redirect_uri={callback_url}"
457+
458+
return url
459+
460+
461+
The outside website that the facility redirects to must also return the user to the specified callback_url
462+
that is passed in as part of the call to `redirect_url`. In addition, it needs to add a `observation_id`
463+
parameter to this URL which is the ID of the newly created observation on the facility side. The `target_id`
464+
and `facility` parameters must also be present, and should match the values passed in by the original
465+
callback_url.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import logging
2+
import urllib.parse
3+
4+
from django.conf import settings
5+
from django.shortcuts import get_object_or_404
6+
7+
from tom_observations.facility import BaseRedirectObservationFacility
8+
from tom_targets.models import Target
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class LCORedirectFacility(BaseRedirectObservationFacility):
14+
name = "LCORedirect"
15+
observation_types = [("Default", "")]
16+
17+
def target_to_query_params(self, target) -> str:
18+
set_fields = {
19+
"target_" + k: v for k, v in target.as_dict().items() if v is not None
20+
}
21+
return urllib.parse.urlencode(set_fields)
22+
23+
def observation_portal_url(self) -> str:
24+
return settings.FACILITIES.get("LCO", {}).get(
25+
"portal_url", "https://observe.lco.global"
26+
)
27+
28+
def redirect_url(self, target_id, callback_url):
29+
target = get_object_or_404(Target, pk=target_id)
30+
query_params = self.target_to_query_params(target)
31+
callback_url = urllib.parse.quote_plus(callback_url)
32+
portal_url = self.observation_portal_url()
33+
url = f"{portal_url}/create?{query_params}&redirect_uri={callback_url}"
34+
35+
return url
36+
37+
def get_observation_url(self, observation_id):
38+
return ""
39+
40+
def get_terminal_observing_states(self):
41+
return []
42+
43+
def get_observing_sites(self):
44+
return {}
45+
46+
def get_observation_status(self, observation_id):
47+
return None
48+
49+
def data_products(self, observation_id, product_id=None):
50+
return []

tom_observations/facility.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ class BaseObservationFacility(ABC):
221221
"""
222222
name = 'BaseObservation'
223223
observation_forms = {}
224+
is_redirect = False
224225

225226
def __init__(self):
226227
self.user = None
@@ -591,3 +592,44 @@ class BaseManualObservationFacility(BaseObservationFacility):
591592
This specific class is intended for use with classical-style manual facilities.
592593
"""
593594
name = 'BaseManual' # rename in concrete subclasses
595+
596+
597+
class BaseRedirectObservationFacility(BaseObservationFacility):
598+
"""
599+
A Redirect Facility is a facility which delegates observation creation to
600+
an external website. A RedirectFacility does not need to provide a UI for
601+
observation creation, meaning it does not include a Form. It does still
602+
implement other methods inherited from BaseObservationFacility, such as those
603+
related to fetching status, data products, etc.
604+
605+
Implementations will need to provide the redirect_url method. This constructs
606+
a URL that the user will be redirected to. The arguments the target id and a
607+
callback URL. The external website should be able to redirect the user back
608+
to the callback URL in order to complete the observation creation process
609+
on the TOM side, by fetching the observation either by API endpoint or
610+
some other means, and storing it in the TOM database.
611+
612+
The passed in callback URL will contain the following query parameters:
613+
- target_id: The ID of the target for which the observation was created.
614+
- facility: The name of the facility that created the observation.
615+
616+
The remote facility is responsible for adding these query params:
617+
- observation_id: The ID of the observation that was created.
618+
619+
"""
620+
is_redirect = True
621+
622+
def redirect_url(self, target_id: str, callback_url: str):
623+
raise NotImplementedError("Must implement redirect_url")
624+
625+
def get_form(self, observation_type):
626+
raise TypeError("RedirectFacility does not provide a form for observation creation")
627+
628+
def get_template_form(self, observation_type):
629+
raise TypeError("RedirectFacility does not provide a form for observation creation")
630+
631+
def submit_observation(self, observation_payload):
632+
raise TypeError("RedirectFacility does not submit observations on remote facilities")
633+
634+
def validate_observation(self, observation_payload):
635+
raise TypeError("RedirectFacility does no observation creation or validation")
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1-
{% for facility in facilities %}
2-
<a href="{% url 'tom_observations:create' facility=facility %}?target_id={{ target.id }}" class="btn btn-outline-primary">{{ facility }}</a>
3-
{% endfor %}
1+
{% for facility, facility_class in facilities.items %}
2+
{% if facility_class.is_redirect %}
3+
<a href="{% url 'tom_observations:redirect' facility=facility %}?target_id={{ target.id }}" class="btn btn-outline-primary">{{ facility }}</a>
4+
{% else %}
5+
<a href="{% url 'tom_observations:create' facility=facility %}?target_id={{ target.id }}" class="btn btn-outline-primary">{{ facility }}</a>
6+
{% endif %}
7+
{% endfor %}

tom_observations/tests/tests.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,28 @@ def test_add_existing_observation_duplicate(self):
290290
self.assertEqual(ObservationRecord.objects.filter(observation_id=obsr.observation_id).count(), 2)
291291

292292

293+
@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility'],
294+
TARGET_PERMISSIONS_ONLY=False)
295+
class TestCallbackView(TestCase):
296+
def setUp(self):
297+
self.target = SiderealTargetFactory.create(permissions='PUBLIC')
298+
self.user = User.objects.create_user(username='vincent_adultman', password='important')
299+
self.client.force_login(self.user)
300+
301+
def test_callback(self):
302+
"""
303+
The callback url is constructed by the OCS and the user is redirected to it after
304+
the observation record is created. This tests that a corresponding ObvservationRecord is created
305+
on the TOM side, just as if one was created using the built-in OCS form.
306+
The view should redirect the user to the detail view of the observation record.
307+
"""
308+
callback_params = f"?target_id={self.target.id}&facility=FakeRoboticFacility&observation_id=1234"
309+
url = reverse('tom_observations:callback') + callback_params
310+
response = self.client.get(url)
311+
observation = ObservationRecord.objects.get(target=self.target, facility='FakeRoboticFacility')
312+
self.assertRedirects(response, reverse('tom_observations:detail', kwargs={'pk': observation.pk}))
313+
314+
293315
@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility'])
294316
class TestFacilityStatusView(TestCase):
295317
def setUp(self):

tom_observations/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
ObservationGroupListView, ObservationListView, ObservationRecordCancelView,
66
ObservationRecordDetailView, ObservationTemplateCreateView,
77
ObservationTemplateDeleteView, ObservationTemplateListView,
8-
ObservationTemplateUpdateView)
8+
ObservationTemplateUpdateView, ObservationCallbackView, ObservationRedirectView)
99
from tom_observations.api_views import ObservationRecordViewSet
1010
from tom_common.api_router import SharedAPIRootRouter
1111

@@ -28,7 +28,9 @@
2828
path('groups/list/', ObservationGroupListView.as_view(), name='group-list'),
2929
path('groups/<int:pk>/delete/', ObservationGroupDeleteView.as_view(), name='group-delete'),
3030
path('<str:facility>/create/', ObservationCreateView.as_view(), name='create'),
31+
path('<str:facility>/redirect/', ObservationRedirectView.as_view(), name='redirect'),
3132
path('<int:pk>/cancel/', ObservationRecordCancelView.as_view(), name='cancel'),
3233
path('<int:pk>/update/', ObservationRecordUpdateView.as_view(), name='update'),
3334
path('<int:pk>/', ObservationRecordDetailView.as_view(), name='detail'),
35+
path('callback/', ObservationCallbackView.as_view(), name='callback'),
3436
]

tom_observations/views.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from django_filters import CharFilter, ChoiceFilter, DateTimeFromToRangeFilter, ModelMultipleChoiceFilter
1515
from django_filters import OrderingFilter, MultipleChoiceFilter, rest_framework
1616
from django_filters.views import FilterView
17-
from django.shortcuts import redirect
17+
from django.shortcuts import get_object_or_404, redirect
1818
from django.urls import reverse, reverse_lazy
1919
from django.utils.safestring import mark_safe
2020
from django.views.generic import View, TemplateView
@@ -328,6 +328,7 @@ def get_initial(self):
328328
raise Exception('Must provide target_id')
329329
initial['target_id'] = self.get_target_id()
330330
initial['facility'] = self.get_facility()
331+
initial['request'] = self.request
331332
initial.update(self.request.GET.dict())
332333
return initial
333334

@@ -435,6 +436,22 @@ def form_valid(self, form):
435436
)
436437

437438

439+
class ObservationRedirectView(LoginRequiredMixin, View):
440+
"""
441+
This view redirects the user to an outside facility using the URL
442+
provided by the facility's redirect_url method (must be a RedirectFacility)
443+
"""
444+
def get(self, request, *args, **kwargs):
445+
facility_name = self.kwargs['facility']
446+
facility_instance = get_service_class(facility_name)()
447+
target_id = request.GET.get("target_id")
448+
callback_url = request.build_absolute_uri(
449+
reverse("tom_observations:callback")
450+
) + f"?target_id={target_id}&facility={facility_name}"
451+
452+
return redirect(facility_instance.redirect_url(target_id, callback_url))
453+
454+
438455
class ObservationRecordUpdateView(LoginRequiredMixin, UpdateView):
439456
"""
440457
This view allows for the updating of the observation id, which will eventually be expanded to more fields.
@@ -467,6 +484,42 @@ def get(self, request, *args, **kwargs):
467484
return redirect(reverse('tom_observations:detail', kwargs={'pk': obsr.id}))
468485

469486

487+
class ObservationCallbackView(LoginRequiredMixin, View):
488+
"""
489+
This is the view that handles the user returning **from** the facility back to the TOM.
490+
The query parameters must include facility, target_id and observation_id as these are
491+
the required parameters for creating an ObservationRecord. Once an ObservationRecord object
492+
is created, permissions are assigned to it for the current user, and the user is redirected
493+
to the observation detail page.
494+
"""
495+
def get(self, request):
496+
facility = request.GET.get('facility')
497+
target_id = request.GET.get('target_id')
498+
observation_id = request.GET.get('observation_id')
499+
user = request.user
500+
if not target_id:
501+
messages.error(self.request, 'Missing required parameter: target_id')
502+
return redirect(reverse('tom_observations:list'))
503+
elif not all([facility, observation_id]):
504+
messages.error(self.request, 'Missing required parameters: facility, observation_id')
505+
return redirect(reverse('targets:detail', kwargs={'pk': target_id}))
506+
target = get_object_or_404(Target, id=target_id)
507+
observation, created = ObservationRecord.objects.get_or_create(
508+
user=user,
509+
facility=facility,
510+
target=target,
511+
observation_id=observation_id,
512+
parameters=request.GET
513+
)
514+
assign_perm('tom_observations.view_observationrecord', user, observation)
515+
assign_perm('tom_observations.change_observationrecord', user, observation)
516+
assign_perm('tom_observations.delete_observationrecord', user, observation)
517+
if not created:
518+
messages.warning(self.request, "Observation record for this target and facility already exists.")
519+
520+
return redirect(reverse('tom_observations:detail', kwargs={'pk': observation.pk}))
521+
522+
470523
class AddExistingObservationView(LoginRequiredMixin, FormView):
471524
"""
472525
View for associating a pre-existing observation with a target. Requires authentication.

0 commit comments

Comments
 (0)