diff --git a/docs/managing_data/tom_direct_sharing.rst b/docs/managing_data/tom_direct_sharing.rst index ee04cb73e..bc0edb2ca 100644 --- a/docs/managing_data/tom_direct_sharing.rst +++ b/docs/managing_data/tom_direct_sharing.rst @@ -1,32 +1,74 @@ Sharing Data with Other TOMs ############################ -TOM Toolkit does not yet support direct sharing between TOMs, however we hope to add this functionality soon. - - -.. Configuring your TOM to submit data to another TOM: -.. *************************************************** -.. -.. You will need to add a ``DATA_SHARING`` configuration dictionary to your ``settings.py`` that gives the credentials -.. for the various TOMs with which you wish to share data. -.. -.. .. code:: python -.. -.. # Define the valid data sharing destinations for your TOM. -.. DATA_SHARING = { -.. 'tom-demo-dev': { -.. 'DISPLAY_NAME': os.getenv('TOM_DEMO_DISPLAY_NAME', 'TOM Demo Dev'), -.. 'BASE_URL': os.getenv('TOM_DEMO_BASE_URL', 'http://tom-demo-dev.lco.gtn/'), -.. 'USERNAME': os.getenv('TOM_DEMO_USERNAME', 'set TOM_DEMO_USERNAME value in environment'), -.. 'PASSWORD': os.getenv('TOM_DEMO_PASSWORD', 'set TOM_DEMO_PASSWORD value in environment'), -.. }, -.. 'localhost-tom': { -.. # for testing; share with yourself -.. 'DISPLAY_NAME': os.getenv('LOCALHOST_TOM_DISPLAY_NAME', 'Local'), -.. 'BASE_URL': os.getenv('LOCALHOST_TOM_BASE_URL', 'http://127.0.0.1:8000/'), -.. 'USERNAME': os.getenv('LOCALHOST_TOM_USERNAME', 'set LOCALHOST_TOM_USERNAME value in environment'), -.. 'PASSWORD': os.getenv('LOCALHOST_TOM_PASSWORD', 'set LOCALHOST_TOM_PASSWORD value in environment'), -.. } -.. -.. } -.. \ No newline at end of file +TOM Toolkit supports direct data sharing between TOMs. + + +Permissions: +************ +To save data to a destination TOM your TOM will need to have access to a user account on that TOM with the correct +permissions. This is handled by your TOM's administrator as described below. + +.. warning:: Any user who has permission to access the relevant target or data in your TOM will have permission to + submit that data to the destination TOM once DATA_SHARING is configured. + + +Configuring your TOM to submit data to another TOM: +*************************************************** + +You will need to add a ``DATA_SHARING`` configuration dictionary to your ``settings.py`` that gives the credentials +for the various TOMs with which you wish to share data. This should be the same ``DATA_SHARING`` dictionary that is used +to :doc:`/managing_data/stream_pub_sub` such as `Hermes `_. + +.. code:: python + + # Define the valid data sharing destinations for your TOM. + DATA_SHARING = { + 'not-my-tom': { + # For sharing data with another TOM + 'DISPLAY_NAME': os.getenv('NOT_MY_TOM_DISPLAY_NAME', 'Not My Tom'), + 'BASE_URL': os.getenv('NOT_MY_TOM_BASE_URL', 'http://notmytom.com/'), + 'USERNAME': os.getenv('NOT_MY_TOM_USERNAME', 'set NOT_MY_TOM_USERNAME value in environment'), + 'PASSWORD': os.getenv('NOT_MY_TOM_PASSWORD', 'set NOT_MY_TOM_PASSWORD value in environment'), + }, + 'localhost-tom': { + # for testing; share with yourself + 'DISPLAY_NAME': os.getenv('LOCALHOST_TOM_DISPLAY_NAME', 'Local'), + 'BASE_URL': os.getenv('LOCALHOST_TOM_BASE_URL', 'http://127.0.0.1:8000/'), + 'USERNAME': os.getenv('LOCALHOST_TOM_USERNAME', 'set LOCALHOST_TOM_USERNAME value in environment'), + 'PASSWORD': os.getenv('LOCALHOST_TOM_PASSWORD', 'set LOCALHOST_TOM_PASSWORD value in environment'), + } + } + +Receiving Shared Data: +********************** + +Reduced Datums: +--------------- +When your TOM receives a new ``ReducedDatum`` from another TOM it will be saved to your TOM's database with its source +set to the name of the TOM that submitted it. Currently, only Photometry data can be directly shared between +TOMS and a ``Target`` with a matching name or alias must exist in both TOMS for sharing to take place. + +Data Products: +-------------- +When your TOM receives a new ``DataProduct`` from another TOM it will be saved to your TOM's database / storage and run +through the appropriate :doc:`data_processor ` pipeline. Only data products +associated with a ``Target`` with a name or alias that matches that of a target in the destination TOM will be shared. + +Targets: +-------- +When your TOM receives a new ``Target`` from another TOM it will be saved to your TOM's database. If the target's name +or alias doesn't match that of a target that already exists in the database, a new target will be created and added to a +new ``TargetList`` called "Imported from ". + +Target Lists: +------------- +When your TOM receives a new ``TargetList`` from another TOM it will be saved to your TOM's database. If the targets in +the ``TargetList`` are also shared, but already exist in the destination TOM, they will be added to the new +``TargetList``. + + + + + + diff --git a/setup.py b/setup.py index f546ee590..d652e9af5 100644 --- a/setup.py +++ b/setup.py @@ -54,7 +54,8 @@ 'specutils~=1.8', ], extras_require={ - 'test': ['factory_boy>=3.2.1,<3.4.0'], + 'test': ['factory_boy>=3.2.1,<3.4.0', + 'responses~=0.23'], 'docs': [ 'recommonmark~=0.7', 'sphinx>=4,<8', diff --git a/tom_base/settings.py b/tom_base/settings.py index fe8754ce3..8eef5e1fb 100644 --- a/tom_base/settings.py +++ b/tom_base/settings.py @@ -237,30 +237,30 @@ } # Configuration for the TOM/Kafka Stream receiving data from this TOM -DATA_SHARING = { - 'hermes': { - 'DISPLAY_NAME': os.getenv('HERMES_DISPLAY_NAME', 'Hermes'), - 'BASE_URL': os.getenv('HERMES_BASE_URL', 'https://hermes.lco.global/'), - 'CREDENTIAL_USERNAME': os.getenv('SCIMMA_CREDENTIAL_USERNAME', - 'set SCIMMA_CREDENTIAL_USERNAME value in environment'), - 'CREDENTIAL_PASSWORD': os.getenv('SCIMMA_CREDENTIAL_PASSWORD', - 'set SCIMMA_CREDENTIAL_PASSWORD value in environment'), - 'USER_TOPICS': ['hermes.test', 'tomtoolkit.test'] - }, - 'tom-demo-dev': { - 'DISPLAY_NAME': os.getenv('TOM_DEMO_DISPLAY_NAME', 'TOM Demo Dev'), - 'BASE_URL': os.getenv('TOM_DEMO_BASE_URL', 'http://tom-demo-dev.lco.gtn/'), - 'USERNAME': os.getenv('TOM_DEMO_USERNAME', 'set TOM_DEMO_USERNAME value in environment'), - 'PASSWORD': os.getenv('TOM_DEMO_PASSWORD', 'set TOM_DEMO_PASSWORD value in environment'), - }, - 'localhost-tom': { - # for testing; share with yourself - 'DISPLAY_NAME': os.getenv('LOCALHOST_TOM_DISPLAY_NAME', 'Local'), - 'BASE_URL': os.getenv('LOCALHOST_TOM_BASE_URL', 'http://127.0.0.1:8000/'), - 'USERNAME': os.getenv('LOCALHOST_TOM_USERNAME', 'set LOCALHOST_TOM_USERNAME value in environment'), - 'PASSWORD': os.getenv('LOCALHOST_TOM_PASSWORD', 'set LOCALHOST_TOM_PASSWORD value in environment'), - } -} +# DATA_SHARING = { +# 'hermes': { +# 'DISPLAY_NAME': os.getenv('HERMES_DISPLAY_NAME', 'Hermes'), +# 'BASE_URL': os.getenv('HERMES_BASE_URL', 'https://hermes.lco.global/'), +# 'CREDENTIAL_USERNAME': os.getenv('SCIMMA_CREDENTIAL_USERNAME', +# 'set SCIMMA_CREDENTIAL_USERNAME value in environment'), +# 'CREDENTIAL_PASSWORD': os.getenv('SCIMMA_CREDENTIAL_PASSWORD', +# 'set SCIMMA_CREDENTIAL_PASSWORD value in environment'), +# 'USER_TOPICS': ['hermes.test', 'tomtoolkit.test'] +# }, +# 'tom-demo-dev': { +# 'DISPLAY_NAME': os.getenv('TOM_DEMO_DISPLAY_NAME', 'TOM Demo Dev'), +# 'BASE_URL': os.getenv('TOM_DEMO_BASE_URL', 'http://tom-demo-dev.lco.gtn/'), +# 'USERNAME': os.getenv('TOM_DEMO_USERNAME', 'set TOM_DEMO_USERNAME value in environment'), +# 'PASSWORD': os.getenv('TOM_DEMO_PASSWORD', 'set TOM_DEMO_PASSWORD value in environment'), +# }, +# 'localhost-tom': { +# # for testing; share with yourself +# 'DISPLAY_NAME': os.getenv('LOCALHOST_TOM_DISPLAY_NAME', 'Local'), +# 'BASE_URL': os.getenv('LOCALHOST_TOM_BASE_URL', 'http://127.0.0.1:8000/'), +# 'USERNAME': os.getenv('LOCALHOST_TOM_USERNAME', 'set LOCALHOST_TOM_USERNAME value in environment'), +# 'PASSWORD': os.getenv('LOCALHOST_TOM_PASSWORD', 'set LOCALHOST_TOM_PASSWORD value in environment'), +# } +# } TOM_CADENCE_STRATEGIES = [ 'tom_observations.cadences.retry_failed_observations.RetryFailedObservationsStrategy', diff --git a/tom_dataproducts/alertstreams/hermes.py b/tom_dataproducts/alertstreams/hermes.py index d2589af22..cd1c2742a 100644 --- a/tom_dataproducts/alertstreams/hermes.py +++ b/tom_dataproducts/alertstreams/hermes.py @@ -136,7 +136,7 @@ def get_hermes_topics(**kwargs): response = requests.get(url=submit_url, headers=headers) topics = response.json()['writable_topics'] - except KeyError: + except (KeyError, requests.exceptions.JSONDecodeError): topics = settings.DATA_SHARING['hermes']['USER_TOPICS'] return topics diff --git a/tom_dataproducts/api_views.py b/tom_dataproducts/api_views.py index 8f59138ae..5661098b8 100644 --- a/tom_dataproducts/api_views.py +++ b/tom_dataproducts/api_views.py @@ -4,7 +4,7 @@ from guardian.shortcuts import assign_perm, get_objects_for_user from rest_framework import status from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin -from rest_framework.parsers import MultiPartParser +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet @@ -12,7 +12,7 @@ from tom_dataproducts.data_processor import run_data_processor from tom_dataproducts.filters import DataProductFilter from tom_dataproducts.models import DataProduct, ReducedDatum -from tom_dataproducts.serializers import DataProductSerializer +from tom_dataproducts.serializers import DataProductSerializer, ReducedDatumSerializer class DataProductViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, GenericViewSet, PermissionListMixin): @@ -38,6 +38,7 @@ def create(self, request, *args, **kwargs): response = super().create(request, *args, **kwargs) if response.status_code == status.HTTP_201_CREATED: + response.data['message'] = 'Data product successfully uploaded.' dp = DataProduct.objects.get(pk=response.data['id']) try: run_hook('data_product_post_upload', dp) @@ -68,3 +69,29 @@ def get_queryset(self): ) else: return get_objects_for_user(self.request.user, 'tom_dataproducts.view_dataproduct') + + +class ReducedDatumViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, GenericViewSet, PermissionListMixin): + """ + Viewset for ReducedDatum objects. Supports list, create, and delete. + + To view supported query parameters, please use the OPTIONS endpoint, which can be accessed through the web UI. + + **Please note that ``groups`` are an accepted query parameters for the ``CREATE`` endpoint. The groups parameter + will specify which ``groups`` can view the created ``DataProduct``. If no ``groups`` are specified, the + ``ReducedDatum`` will only be visible to the user that created the ``DataProduct``. Make sure to check your + ``groups``!!** + """ + queryset = ReducedDatum.objects.all() + serializer_class = ReducedDatumSerializer + filter_backends = (drf_filters.DjangoFilterBackend,) + permission_required = 'tom_dataproducts.view_reduceddatum' + parser_classes = [FormParser, JSONParser] + + def create(self, request, *args, **kwargs): + response = super().create(request, *args, **kwargs) + + if response.status_code == status.HTTP_201_CREATED: + response.data['message'] = 'Data successfully uploaded.' + + return response diff --git a/tom_dataproducts/forms.py b/tom_dataproducts/forms.py index db39c1e75..43fbbf309 100644 --- a/tom_dataproducts/forms.py +++ b/tom_dataproducts/forms.py @@ -5,39 +5,9 @@ from tom_dataproducts.models import DataProductGroup, DataProduct from tom_observations.models import ObservationRecord from tom_targets.models import Target -from tom_dataproducts.alertstreams.hermes import get_hermes_topics +from tom_dataproducts.sharing import get_sharing_destination_options -def get_sharing_destination_options(): - """ - Build the Display options and headers for the dropdown form for choosing sharing topics. - Customize for a different selection experience. - :return: Tuple: Possible Destinations and their Display Names - """ - choices = [] - try: - for destination, details in settings.DATA_SHARING.items(): - new_destination = [details.get('DISPLAY_NAME', destination)] - if details.get('USER_TOPICS', None): - # If topics exist for a destination (Such as HERMES) give topics as sub-choices - # for non-selectable Destination - if destination == "hermes": - destination_topics = get_hermes_topics() - else: - destination_topics = details['USER_TOPICS'] - topic_list = [(f'{destination}:{topic}', topic) for topic in destination_topics] - new_destination.append(tuple(topic_list)) - else: - # Otherwise just use destination as option - new_destination.insert(0, destination) - choices.append(tuple(new_destination)) - except AttributeError: - pass - return tuple(choices) - - -DESTINATION_OPTIONS = get_sharing_destination_options() - DATA_TYPE_OPTIONS = (('photometry', 'Photometry'), ('spectroscopy', 'Spectroscopy')) @@ -82,7 +52,7 @@ def __init__(self, *args, **kwargs): class DataShareForm(forms.Form): - share_destination = forms.ChoiceField(required=True, choices=DESTINATION_OPTIONS, label="Destination") + share_destination = forms.ChoiceField(required=True, choices=[], label="Destination") share_title = forms.CharField(required=False, label="Title") share_message = forms.CharField(required=False, label="Message", widget=forms.Textarea()) share_authors = forms.CharField(required=False, widget=forms.HiddenInput()) @@ -97,4 +67,4 @@ class DataShareForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['share_destination'].choices = DESTINATION_OPTIONS + self.fields['share_destination'].choices = get_sharing_destination_options() diff --git a/tom_dataproducts/models.py b/tom_dataproducts/models.py index d8fbef1ae..6e2664471 100644 --- a/tom_dataproducts/models.py +++ b/tom_dataproducts/models.py @@ -331,7 +331,7 @@ class ReducedDatum(models.Model): """ target = models.ForeignKey(Target, null=False, on_delete=models.CASCADE) - data_product = models.ForeignKey(DataProduct, null=True, on_delete=models.CASCADE) + data_product = models.ForeignKey(DataProduct, null=True, blank=True, on_delete=models.CASCADE) data_type = models.CharField( max_length=100, default='' @@ -352,3 +352,12 @@ def save(self, *args, **kwargs): else: raise ValidationError('Not a valid DataProduct type.') return super().save() + + def validate_unique(self, *args, **kwargs): + super().validate_unique(*args, **kwargs) + model_dict = self.__dict__.copy() + del model_dict['_state'] + del model_dict['id'] + obs = ReducedDatum.objects.filter(**model_dict) + if obs: + raise ValidationError('Data point already exists.') diff --git a/tom_dataproducts/serializers.py b/tom_dataproducts/serializers.py index 223747567..7d6ba9993 100644 --- a/tom_dataproducts/serializers.py +++ b/tom_dataproducts/serializers.py @@ -18,6 +18,8 @@ class Meta: class ReducedDatumSerializer(serializers.ModelSerializer): + target = TargetFilteredPrimaryKeyRelatedField(queryset=Target.objects.all()) + class Meta: model = ReducedDatum fields = ( @@ -26,9 +28,31 @@ class Meta: 'source_name', 'source_location', 'timestamp', - 'value' + 'value', + 'target' ) + def create(self, validated_data): + """DRF requires explicitly handling writeable nested serializers, + here we pop the groups data and save it using its serializer. + """ + groups = validated_data.pop('groups', []) + + rd = ReducedDatum(**validated_data) + rd.full_clean() + rd.save() + + # Save groups for this target + group_serializer = GroupSerializer(data=groups, many=True) + if group_serializer.is_valid() and settings.TARGET_PERMISSIONS_ONLY is False: + for group in groups: + group_instance = Group.objects.get(pk=group['id']) + assign_perm('tom_dataproducts.view_dataproduct', group_instance, rd) + assign_perm('tom_dataproducts.change_dataproduct', group_instance, rd) + assign_perm('tom_dataproducts.delete_dataproduct', group_instance, rd) + + return rd + class DataProductSerializer(serializers.ModelSerializer): target = TargetFilteredPrimaryKeyRelatedField(queryset=Target.objects.all()) diff --git a/tom_dataproducts/sharing.py b/tom_dataproducts/sharing.py new file mode 100644 index 000000000..d85c3524c --- /dev/null +++ b/tom_dataproducts/sharing.py @@ -0,0 +1,256 @@ +import requests +import os + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.contrib import messages + +from tom_targets.models import Target +from tom_dataproducts.models import DataProduct, ReducedDatum +from tom_dataproducts.alertstreams.hermes import publish_photometry_to_hermes, BuildHermesMessage, get_hermes_topics +from tom_dataproducts.serializers import DataProductSerializer, ReducedDatumSerializer + + +def share_data_with_hermes(share_destination, form_data, product_id=None, target_id=None, selected_data=None): + """ + Serialize and share data with Hermes (hermes.lco.global) + :param share_destination: Topic to share data to. (e.g. 'hermes.test') + :param form_data: Sharing Form data + :param product_id: DataProduct ID (if provided) + :param target_id: Target ID (if provided) + :param selected_data: List of ReducedDatum IDs (if provided) + :return: + """ + # Query relevant Reduced Datums Queryset + accepted_data_types = ['photometry'] + if product_id: + product = DataProduct.objects.get(pk=product_id) + target = product.target + reduced_datums = ReducedDatum.objects.filter(data_product=product) + elif selected_data: + reduced_datums = ReducedDatum.objects.filter(pk__in=selected_data) + target = reduced_datums[0].target + elif target_id: + target = Target.objects.get(pk=target_id) + data_type = form_data.get('data_type', 'photometry') + reduced_datums = ReducedDatum.objects.filter(target=target, data_type=data_type) + else: + reduced_datums = ReducedDatum.objects.none() + target = Target.objects.none() + + reduced_datums.filter(data_type__in=accepted_data_types) + + # Build and submit hermes table from Reduced Datums + hermes_topic = share_destination.split(':')[1] + destination = share_destination.split(':')[0] + message_info = BuildHermesMessage(title=form_data.get('share_title', + f"Updated data for {target.name} from " + f"{getattr(settings, 'TOM_NAME','TOM Toolkit')}."), + submitter=form_data.get('submitter'), + authors=form_data.get('share_authors', None), + message=form_data.get('share_message', None), + topic=hermes_topic + ) + # Run ReducedDatums Queryset through sharing protocols to make sure they are safe to share. + filtered_reduced_datums = check_for_share_safe_datums(destination, reduced_datums, topic=hermes_topic) + if filtered_reduced_datums.count() > 0: + response = publish_photometry_to_hermes(message_info, filtered_reduced_datums) + else: + return {'message': f'ERROR: No valid data to share. (Check Sharing Protocol. Note that data types must be in ' + f'{accepted_data_types})'} + return response + + +def share_data_with_tom(share_destination, form_data, product_id=None, target_id=None, selected_data=None): + """ + Serialize and share data with another TOM + :param share_destination: TOM to share data to as described in settings.DATA_SHARING. (e.g. 'mytom') + :param form_data: Sharing Form data + :param product_id: DataProduct ID (if provided) + :param target_id: Target ID (if provided) + :param selected_data: List of ReducedDatum IDs (if provided) + :return: + """ + # Build destination TOM headers and URL information + try: + destination_tom_base_url = settings.DATA_SHARING[share_destination]['BASE_URL'] + username = settings.DATA_SHARING[share_destination]['USERNAME'] + password = settings.DATA_SHARING[share_destination]['PASSWORD'] + except KeyError as err: + raise ImproperlyConfigured(f'Check DATA_SHARING configuration for {share_destination}: Key {err} not found.') + auth = (username, password) + headers = {'Content-Type': 'application/json', 'Accept': 'application/json'} + + dataproducts_url = destination_tom_base_url + 'api/dataproducts/' + targets_url = destination_tom_base_url + 'api/targets/' + reduced_datums_url = destination_tom_base_url + 'api/reduceddatums/' + reduced_datums = ReducedDatum.objects.none() + + # If a DataProduct is provided, share that DataProduct + if product_id: + product = DataProduct.objects.get(pk=product_id) + target = product.target + serialized_data = DataProductSerializer(product).data + # Find matching target in destination TOM + destination_target_id, target_search_response = get_destination_target(target, targets_url, headers, auth) + if destination_target_id is None: + return {'message': 'ERROR: No matching target found.'} + elif isinstance(destination_target_id, list) and len(destination_target_id) > 1: + return {'message': 'ERROR: Multiple targets with matching name found in destination TOM.'} + serialized_data['target'] = destination_target_id + # TODO: this should be updated when tom_dataproducts is updated to use django.core.storage + dataproduct_filename = os.path.join(settings.MEDIA_ROOT, product.data.name) + # Save DataProduct in Destination TOM + with open(dataproduct_filename, 'rb') as dataproduct_filep: + files = {'file': (product.data.name, dataproduct_filep, 'text/csv')} + headers = {'Media-Type': 'multipart/form-data'} + response = requests.post(dataproducts_url, data=serialized_data, files=files, headers=headers, auth=auth) + elif selected_data or target_id: + # If ReducedDatums are provided, share those ReducedDatums + if selected_data: + reduced_datums = ReducedDatum.objects.filter(pk__in=selected_data) + targets = set(reduced_datum.target for reduced_datum in reduced_datums) + target_dict = {} + for target in targets: + # get destination Target + destination_target_id, target_search_response = get_destination_target(target, + targets_url, + headers, + auth) + if isinstance(destination_target_id, list) and len(destination_target_id) > 1: + return {'message': 'ERROR: Multiple targets with matching name found in destination TOM.'} + target_dict[target.name] = destination_target_id + if all(value is None for value in target_dict.values()): + return {'message': 'ERROR: No matching targets found.'} + else: + # If Target is provided, share all ReducedDatums for that Target + # (Will not create New Target in Destination TOM) + target = Target.objects.get(pk=target_id) + reduced_datums = ReducedDatum.objects.filter(target=target) + destination_target_id, target_search_response = get_destination_target(target, targets_url, headers, auth) + if destination_target_id is None: + return {'message': 'ERROR: No matching target found.'} + elif isinstance(destination_target_id, list) and len(destination_target_id) > 1: + return {'message': 'ERROR: Multiple targets with matching name found in destination TOM.'} + target_dict = {target.name: destination_target_id} + response_codes = [] + reduced_datums = check_for_share_safe_datums(share_destination, reduced_datums) + if not reduced_datums: + return {'message': 'ERROR: No valid data to share.'} + for datum in reduced_datums: + if target_dict[datum.target.name]: + serialized_data = ReducedDatumSerializer(datum).data + serialized_data['target'] = target_dict[datum.target.name] + serialized_data['data_product'] = '' + if not serialized_data['source_name']: + serialized_data['source_name'] = settings.TOM_NAME + serialized_data['source_location'] = "TOM-TOM Direct Sharing" + response = requests.post(reduced_datums_url, json=serialized_data, headers=headers, auth=auth) + response_codes.append(response.status_code) + failed_data_count = response_codes.count(500) + if failed_data_count < len(response_codes): + return {'message': f'{len(response_codes)-failed_data_count} of {len(response_codes)} ' + 'datums successfully saved.'} + else: + return {'message': 'ERROR: No valid data shared. These data may already exist in target TOM.'} + else: + return {'message': 'ERROR: No valid data to share.'} + + return response + + +def get_destination_target(target, targets_url, headers, auth): + """ + Retrieve the target ID from a destination TOM that is a fuzzy match the given target name and aliases + :param target: Target Model + :param targets_url: Destination API URL for TOM Target List + :param headers: TOM API headers + :param auth: TOM API authorization + :return: + """ + # Create coma separated list of target names plus aliases that can be recognized and parsed by the TOM API Filter + target_names = ','.join(map(str, target.names)) + target_response = requests.get(f'{targets_url}?name_fuzzy={target_names}', headers=headers, auth=auth) + target_response_json = target_response.json() + try: + if target_response_json['results']: + if len(target_response_json['results']) > 1: + return target_response_json['results'], target_response + destination_target_id = target_response_json['results'][0]['id'] + return destination_target_id, target_response + else: + return None, target_response + except KeyError: + return None, target_response + + +def check_for_share_safe_datums(destination, reduced_datums, **kwargs): + """ + Custom sharing protocols used to determine when data is shared with a destination. + This example prevents sharing if a datum has already been published to the given Hermes topic. + :param destination: sharing destination string + :param reduced_datums: selected input datums + :return: queryset of reduced datums to be shared + """ + return reduced_datums + # if 'hermes' in destination: + # message_topic = kwargs.get('topic', None) + # # Remove data points previously shared to the given topic + # filtered_datums = reduced_datums.exclude(Q(message__exchange_status='published') + # & Q(message__topic=message_topic)) + # else: + # filtered_datums = reduced_datums + # return filtered_datums + + +def check_for_save_safe_datums(): + return + + +def get_sharing_destination_options(): + """ + Build the Display options and headers for the dropdown form for choosing sharing topics. + Customize for a different selection experience. + :return: Tuple: Possible Destinations and their Display Names + """ + choices = [] + try: + for destination, details in settings.DATA_SHARING.items(): + new_destination = [details.get('DISPLAY_NAME', destination)] + if details.get('USER_TOPICS', None): + # If topics exist for a destination (Such as HERMES) give topics as sub-choices + # for non-selectable Destination + if destination == "hermes": + destination_topics = get_hermes_topics() + else: + destination_topics = details['USER_TOPICS'] + topic_list = [(f'{destination}:{topic}', topic) for topic in destination_topics] + new_destination.append(tuple(topic_list)) + else: + # Otherwise just use destination as option + new_destination.insert(0, destination) + choices.append(tuple(new_destination)) + except AttributeError: + pass + return tuple(choices) + + +def sharing_feedback_handler(response, request): + """ + Handle the response from a sharing request and prepare a message to the user + :return: + """ + try: + if 'message' in response.json(): + publish_feedback = response.json()['message'] + else: + publish_feedback = f"ERROR: {response.text}" + except AttributeError: + publish_feedback = response['message'] + except ValueError: + publish_feedback = f"ERROR: Returned Response code {response.status_code}" + if "ERROR" in publish_feedback.upper(): + messages.error(request, publish_feedback) + else: + messages.success(request, publish_feedback) + return diff --git a/tom_dataproducts/templates/tom_dataproducts/partials/photometry_datalist_for_target.html b/tom_dataproducts/templates/tom_dataproducts/partials/photometry_datalist_for_target.html index 953af3b88..1a2e499f5 100644 --- a/tom_dataproducts/templates/tom_dataproducts/partials/photometry_datalist_for_target.html +++ b/tom_dataproducts/templates/tom_dataproducts/partials/photometry_datalist_for_target.html @@ -46,25 +46,25 @@ {% endfor %} -
-
- Share Selected Data -
- {% if sharing_destinations %} -
-
- {% bootstrap_field target_data_share_form.share_destination %} -
-
- -
-
- {% else %} - Not Configured - {% endif %} - - + {% if not target_share %} +
+
+ Share Selected Data +
+ {% if sharing_destinations %} +
+
+ {% bootstrap_field target_data_share_form.share_destination %} +
+
+ +
+
+ {% else %} + Not Configured + {% endif %}
+ {% endif %}