From 24771f17ee6769b9f9d060cd75fa2eb90d44d2b7 Mon Sep 17 00:00:00 2001 From: Austin Riba Date: Wed, 19 Nov 2025 17:00:29 -0800 Subject: [PATCH 1/4] Initial POC for a facility that uses redirects --- tom_observations/facilities/lco_redirect.py | 75 +++++++++++++++++++++ tom_observations/tests/tests.py | 22 ++++++ tom_observations/urls.py | 3 +- tom_observations/views.py | 28 +++++++- 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tom_observations/facilities/lco_redirect.py diff --git a/tom_observations/facilities/lco_redirect.py b/tom_observations/facilities/lco_redirect.py new file mode 100644 index 000000000..65e26cf24 --- /dev/null +++ b/tom_observations/facilities/lco_redirect.py @@ -0,0 +1,75 @@ +import logging +import urllib.parse + +from crispy_forms.layout import HTML, Layout +from django.shortcuts import get_object_or_404 +from django.urls import reverse + +from tom_observations.facility import GenericObservationFacility, GenericObservationForm +from tom_targets.models import Target + +logger = logging.getLogger(__name__) + + +class LCORedirectForm(GenericObservationForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + target_id = self.initial.get('target_id') + target = get_object_or_404(Target, pk=target_id) + query_params = self.target_to_query_params(target) + redirect_url = reverse("tom_observations:callback") + f"?target_id={target.pk}&facility=LCO" + redirect_url = urllib.parse.quote_plus(redirect_url) + url = f"https://observe.lco.global/create?{query_params}&redirect_url={redirect_url}" + self.helper.layout = Layout( + HTML(f''' +

+ This plugin will redirect you to the LCO global observation portal to + create an observation for this target. + You will be redirected back to the TOM once the observation is sumbmitted. +

+ + Continue to lco.global + + Back + ''') + ) + + def target_to_query_params(self, target) -> str: + set_fields = {"target_" + k: v for k, v in target.as_dict().items() if v is not None} + return urllib.parse.urlencode(set_fields) + + +class LCORedirectFacility(GenericObservationFacility): + name = 'LCORedirect' + observation_forms = { + 'ALL': LCORedirectForm, + } + observation_types = [('Default', '')] + + def get_form(self, observation_type): + return LCORedirectForm + + def get_template_form(self, observation_type): + pass + + def submit_observation(self, observation_payload): + return + + def validate_observation(self, observation_payload): + return + + def get_observation_url(self, observation_id): + return + + def get_terminal_observing_states(self): + return [] + + def get_observing_sites(self): + return {} + + def get_observation_status(self, observation_id): + return + + def data_products(self, observation_id, product_id=None): + return [] diff --git a/tom_observations/tests/tests.py b/tom_observations/tests/tests.py index ac45f8dc0..e80e3063e 100644 --- a/tom_observations/tests/tests.py +++ b/tom_observations/tests/tests.py @@ -290,6 +290,28 @@ def test_add_existing_observation_duplicate(self): self.assertEqual(ObservationRecord.objects.filter(observation_id=obsr.observation_id).count(), 2) +@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility'], + TARGET_PERMISSIONS_ONLY=False) +class TestCallbackView(TestCase): + def setUp(self): + self.target = SiderealTargetFactory.create(permissions='PUBLIC') + self.user = User.objects.create_user(username='vincent_adultman', password='important') + self.client.force_login(self.user) + + def test_callback(self): + """ + The callback url is constructed by the OCS and the user is redirected to it after + the observation record is created. This tests that a corresponding ObvservationRecord is created + on the TOM side, just as if one was created using the built-in OCS form. + The view should redirect the user to the detail view of the observation record. + """ + callback_params = f"?target_id={self.target.id}&facility=FakeRoboticFacility&observation_id=1234" + url = reverse('tom_observations:callback') + callback_params + response = self.client.get(url) + observation = ObservationRecord.objects.get(target=self.target, facility='FakeRoboticFacility') + self.assertRedirects(response, reverse('tom_observations:detail', kwargs={'pk': observation.pk})) + + @override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility']) class TestFacilityStatusView(TestCase): def setUp(self): diff --git a/tom_observations/urls.py b/tom_observations/urls.py index b9773a81f..de409d553 100644 --- a/tom_observations/urls.py +++ b/tom_observations/urls.py @@ -5,7 +5,7 @@ ObservationGroupListView, ObservationListView, ObservationRecordCancelView, ObservationRecordDetailView, ObservationTemplateCreateView, ObservationTemplateDeleteView, ObservationTemplateListView, - ObservationTemplateUpdateView) + ObservationTemplateUpdateView, ObservationCallbackView) from tom_observations.api_views import ObservationRecordViewSet from tom_common.api_router import SharedAPIRootRouter @@ -31,4 +31,5 @@ path('/cancel/', ObservationRecordCancelView.as_view(), name='cancel'), path('/update/', ObservationRecordUpdateView.as_view(), name='update'), path('/', ObservationRecordDetailView.as_view(), name='detail'), + path('callback/', ObservationCallbackView.as_view(), name='callback'), ] diff --git a/tom_observations/views.py b/tom_observations/views.py index 140b03cdd..ab1a1b3ee 100644 --- a/tom_observations/views.py +++ b/tom_observations/views.py @@ -14,7 +14,7 @@ from django_filters import CharFilter, ChoiceFilter, DateTimeFromToRangeFilter, ModelMultipleChoiceFilter from django_filters import OrderingFilter, MultipleChoiceFilter, rest_framework from django_filters.views import FilterView -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse, reverse_lazy from django.utils.safestring import mark_safe from django.views.generic import View, TemplateView @@ -421,6 +421,32 @@ def get(self, request, *args, **kwargs): return redirect(reverse('tom_observations:detail', kwargs={'pk': obsr.id})) +class ObservationCallbackView(LoginRequiredMixin, View): + def get(self, request): + facility = request.GET.get('facility') + target_id = request.GET.get('target_id') + observation_id = request.GET.get('observation_id') + user = request.user + if not all([facility, target_id, observation_id]): + messages.error(self.request, 'Missing required parameters: facility, target_id, observation_id') + return redirect(reverse('tom_observations:list')) + target = get_object_or_404(Target, id=target_id) + observation, created = ObservationRecord.objects.get_or_create( + user=user, + facility=facility, + target=target, + observation_id=observation_id, + parameters=request.GET + ) + assign_perm('tom_observations.view_observationrecord', user, observation) + assign_perm('tom_observations.change_observationrecord', user, observation) + assign_perm('tom_observations.delete_observationrecord', user, observation) + if not created: + messages.warning(self.request, "Observation record for this target and facility already exists.") + + return redirect(reverse('tom_observations:detail', kwargs={'pk': observation.pk})) + + class AddExistingObservationView(LoginRequiredMixin, FormView): """ View for associating a pre-existing observation with a target. Requires authentication. From 28d94b4bcf136ca99b0ba9785c5f8fb14ad565fe Mon Sep 17 00:00:00 2001 From: Austin Riba Date: Thu, 20 Nov 2025 12:43:50 -0800 Subject: [PATCH 2/4] Add request to observation form initial in order to get host URL --- tom_observations/facilities/lco_redirect.py | 9 +++++++-- tom_observations/views.py | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tom_observations/facilities/lco_redirect.py b/tom_observations/facilities/lco_redirect.py index 65e26cf24..aba61511a 100644 --- a/tom_observations/facilities/lco_redirect.py +++ b/tom_observations/facilities/lco_redirect.py @@ -17,7 +17,12 @@ def __init__(self, *args, **kwargs): target_id = self.initial.get('target_id') target = get_object_or_404(Target, pk=target_id) query_params = self.target_to_query_params(target) - redirect_url = reverse("tom_observations:callback") + f"?target_id={target.pk}&facility=LCO" + request = self.initial.get('request', None) + if not request: + raise ValueError("LCORedirectForm requires request in initial data") + redirect_url = request.build_absolute_uri( + reverse("tom_observations:callback") + ) + f"?target_id={target.pk}&facility=LCO" redirect_url = urllib.parse.quote_plus(redirect_url) url = f"https://observe.lco.global/create?{query_params}&redirect_url={redirect_url}" self.helper.layout = Layout( @@ -25,7 +30,7 @@ def __init__(self, *args, **kwargs):

This plugin will redirect you to the LCO global observation portal to create an observation for this target. - You will be redirected back to the TOM once the observation is sumbmitted. + You will be redirected back to the TOM once the observation is submitted.

Continue to lco.global diff --git a/tom_observations/views.py b/tom_observations/views.py index ab1a1b3ee..83355597e 100644 --- a/tom_observations/views.py +++ b/tom_observations/views.py @@ -309,6 +309,7 @@ def get_initial(self): raise Exception('Must provide target_id') initial['target_id'] = self.get_target_id() initial['facility'] = self.get_facility() + initial['request'] = self.request initial.update(self.request.GET.dict()) return initial From d7baeb5b1d3cbdcab96fb4c9b06a769871702d2f Mon Sep 17 00:00:00 2001 From: Austin Riba Date: Thu, 20 Nov 2025 14:43:38 -0800 Subject: [PATCH 3/4] redirect_url -> redirect_uri --- tom_observations/facilities/lco_redirect.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tom_observations/facilities/lco_redirect.py b/tom_observations/facilities/lco_redirect.py index aba61511a..83199939f 100644 --- a/tom_observations/facilities/lco_redirect.py +++ b/tom_observations/facilities/lco_redirect.py @@ -20,11 +20,11 @@ def __init__(self, *args, **kwargs): request = self.initial.get('request', None) if not request: raise ValueError("LCORedirectForm requires request in initial data") - redirect_url = request.build_absolute_uri( + redirect_uri = request.build_absolute_uri( reverse("tom_observations:callback") ) + f"?target_id={target.pk}&facility=LCO" - redirect_url = urllib.parse.quote_plus(redirect_url) - url = f"https://observe.lco.global/create?{query_params}&redirect_url={redirect_url}" + redirect_uri = urllib.parse.quote_plus(redirect_uri) + url = f"https://observe.lco.global/create?{query_params}&redirect_uri={redirect_uri}" self.helper.layout = Layout( HTML(f'''

From 676dba7f5c0e60c1901d285503db6dbb2ae85c42 Mon Sep 17 00:00:00 2001 From: Austin Riba Date: Wed, 3 Dec 2025 13:46:28 -0800 Subject: [PATCH 4/4] Get observation portal redirect url from settings --- tom_observations/facilities/lco_redirect.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tom_observations/facilities/lco_redirect.py b/tom_observations/facilities/lco_redirect.py index 83199939f..6e0d8b498 100644 --- a/tom_observations/facilities/lco_redirect.py +++ b/tom_observations/facilities/lco_redirect.py @@ -2,6 +2,7 @@ import urllib.parse from crispy_forms.layout import HTML, Layout +from django.conf import settings from django.shortcuts import get_object_or_404 from django.urls import reverse @@ -24,7 +25,8 @@ def __init__(self, *args, **kwargs): reverse("tom_observations:callback") ) + f"?target_id={target.pk}&facility=LCO" redirect_uri = urllib.parse.quote_plus(redirect_uri) - url = f"https://observe.lco.global/create?{query_params}&redirect_uri={redirect_uri}" + portal_uri = self.observation_portal_uri() + url = f"{portal_uri}/create?{query_params}&redirect_uri={redirect_uri}" self.helper.layout = Layout( HTML(f'''

@@ -44,6 +46,9 @@ def target_to_query_params(self, target) -> str: set_fields = {"target_" + k: v for k, v in target.as_dict().items() if v is not None} return urllib.parse.urlencode(set_fields) + def observation_portal_uri(self) -> str: + return settings.FACILITIES.get('LCO', {}).get('portal_url', 'https://observe.lco.global') + class LCORedirectFacility(GenericObservationFacility): name = 'LCORedirect'