diff --git a/tom_dataproducts/alertstreams/hermes.py b/tom_dataproducts/alertstreams/hermes.py index c20b107f8..18f4bc50c 100644 --- a/tom_dataproducts/alertstreams/hermes.py +++ b/tom_dataproducts/alertstreams/hermes.py @@ -209,7 +209,7 @@ def publish_to_hermes(message_info, datums, targets=Target.objects.none(), **kwa response = requests.post(url=submit_url, json=alert, headers=headers) response.raise_for_status() # Only mark the datums as shared if the sharing was successful - hermes_alert = AlertStreamMessage(topic=message_info.topic, exchange_status='published') + hermes_alert = AlertStreamMessage(topic=message_info.topic, message_id=response.json().get('uuid'), exchange_status='published') hermes_alert.save() for tomtoolkit_photometry in datums: tomtoolkit_photometry.message.add(hermes_alert) diff --git a/tom_dataproducts/serializers.py b/tom_dataproducts/serializers.py index 7d6ba9993..7c1d92c0f 100644 --- a/tom_dataproducts/serializers.py +++ b/tom_dataproducts/serializers.py @@ -8,7 +8,7 @@ from tom_observations.models import ObservationRecord from tom_observations.serializers import ObservationRecordFilteredPrimaryKeyRelatedField from tom_targets.models import Target -from tom_targets.serializers import TargetFilteredPrimaryKeyRelatedField +from tom_targets.fields import TargetFilteredPrimaryKeyRelatedField class DataProductGroupSerializer(serializers.ModelSerializer): diff --git a/tom_dataproducts/sharing.py b/tom_dataproducts/sharing.py index b9be43fd6..b0458b9d1 100644 --- a/tom_dataproducts/sharing.py +++ b/tom_dataproducts/sharing.py @@ -17,6 +17,29 @@ from tom_dataproducts.serializers import DataProductSerializer, ReducedDatumSerializer +def share_data_with_destination(share_destination, reduced_datum): + """ + Triggered by PersistentShare when new ReducedDatums are created. Shares that ReducedDatum to the sharing destination. + :param share_destination: Topic or location to share data to from `DATA_SHARING` settings + :param reduced_datum: ReducedDatum instance to share + """ + if 'HERMES' in share_destination.upper(): + hermes_topic = share_destination.split(':')[1] + destination = share_destination.split(':')[0] + filtered_reduced_datums = check_for_share_safe_datums( + destination, ReducedDatum.objects.filter(pk=reduced_datum.pk), topic=hermes_topic) + sharing = getattr(settings, "DATA_SHARING", {}) + message = BuildHermesMessage(title=f"Updated data for {reduced_datum.target.name} from " + f"{getattr(settings, 'TOM_NAME', 'TOM Toolkit')}.", + authors=sharing.get('hermes', {}).get('DEFAULT_AUTHORS', None), + message=None, + topic=hermes_topic + ) + publish_to_hermes(message, filtered_reduced_datums) + else: + share_data_with_tom(share_destination, None, None, None, selected_data=[reduced_datum.pk]) + + def share_target_list_with_hermes(share_destination, form_data, selected_targets=None, include_all_data=False): """ Serialize and share a set of selected targets and their data with Hermes diff --git a/tom_targets/admin.py b/tom_targets/admin.py index ac5362edc..53dafbc2c 100644 --- a/tom_targets/admin.py +++ b/tom_targets/admin.py @@ -1,5 +1,7 @@ +import functools from django.contrib import admin -from .models import Target, TargetList, TargetExtra +from .models import Target, TargetList, TargetExtra, PersistentShare +from .forms import PersistentShareForm class TargetExtraInline(admin.TabularInline): @@ -17,6 +19,26 @@ class TargetListAdmin(admin.ModelAdmin): model = TargetList +class PersistentShareAdmin(admin.ModelAdmin): + model = PersistentShare + form = PersistentShareForm + raw_id_fields = ( + 'target', + 'user' + ) + + def get_form(self, request, obj=None, change=False, **kwargs): + Form = super().get_form(request, obj=obj, change=change, **kwargs) + # This line is needed because the ModelAdmin uses the form to get its fields if fields is passed as None + # In that case, a partial will not work, so just return the base form. The partial is necessary to filter + # On the targets a user has access to. + if kwargs.get('fields') == None: + return Form + return functools.partial(Form, user=request.user) + + admin.site.register(Target, TargetAdmin) admin.site.register(TargetList, TargetListAdmin) + +admin.site.register(PersistentShare, PersistentShareAdmin) diff --git a/tom_targets/base_models.py b/tom_targets/base_models.py index 9801d2118..f785ea43c 100644 --- a/tom_targets/base_models.py +++ b/tom_targets/base_models.py @@ -403,6 +403,7 @@ class Meta: permissions = ( ('view_target', 'View Target'), ('add_target', 'Add Target'), + ('share_target', 'Share Target'), ('change_target', 'Change Target'), ('delete_target', 'Delete Target'), ) @@ -573,5 +574,6 @@ def give_user_access(self, user): :return: """ assign_perm('tom_targets.view_target', user, self) + assign_perm('tom_targets.share_target', user, self) assign_perm('tom_targets.change_target', user, self) assign_perm('tom_targets.delete_target', user, self) diff --git a/tom_targets/fields.py b/tom_targets/fields.py new file mode 100644 index 000000000..6077b49fe --- /dev/null +++ b/tom_targets/fields.py @@ -0,0 +1,15 @@ +from guardian.shortcuts import get_objects_for_user +from rest_framework import serializers + + +class TargetFilteredPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + # This PrimaryKeyRelatedField subclass is used to implement get_queryset based on the permissions of the user + # submitting the request. The pattern was taken from this StackOverflow answer: https://stackoverflow.com/a/32683066 + + def get_queryset(self): + request = self.context.get('request', None) + queryset = super().get_queryset() + if not (request and queryset): + return None + return get_objects_for_user(request.user, 'tom_targets.change_target') + diff --git a/tom_targets/forms.py b/tom_targets/forms.py index 559d87457..c23fc7600 100644 --- a/tom_targets/forms.py +++ b/tom_targets/forms.py @@ -3,11 +3,11 @@ from astropy import units as u from django.forms import ValidationError, inlineformset_factory from django.conf import settings -from django.contrib.auth.models import Group -from guardian.shortcuts import assign_perm, get_groups_with_perms, remove_perm +from django.contrib.auth.models import Group, User +from guardian.shortcuts import assign_perm, get_groups_with_perms, remove_perm, get_objects_for_user from tom_dataproducts.sharing import get_sharing_destination_options -from .models import Target, TargetExtra, TargetName, TargetList +from .models import Target, TargetExtra, TargetName, TargetList, PersistentShare from tom_targets.base_models import (SIDEREAL_FIELDS, NON_SIDEREAL_FIELDS, REQUIRED_SIDEREAL_FIELDS, REQUIRED_NON_SIDEREAL_FIELDS, REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME, IGNORE_FIELDS) @@ -241,3 +241,31 @@ class TargetMergeForm(forms.Form): 'hx-target': '#id_target_merge_fields', # replace name_select element }) ) + + +class PersistentShareForm(forms.ModelForm): + destination = forms.ChoiceField(choices=[], label='Share Destination', required=True) + target = forms.ModelChoiceField(queryset=Target.objects.all(), label='Target', initial=0, required=True) + + class Meta: + model = PersistentShare + fields = '__all__' + + def __init__(self, *args, **kwargs): + try: + self.target_id = kwargs.pop('target_id') + except KeyError: + self.target_id = None + try: + self.user = kwargs.pop('user') + except KeyError: + self.user = None + super().__init__(*args, **kwargs) + self.fields['destination'].choices = get_sharing_destination_options() + if self.target_id: + self.fields['target'].queryset = Target.objects.filter(pk=self.target_id) + else: + if self.user: + self.fields['target'].queryset = get_objects_for_user(self.user, f'{Target._meta.app_label}.share_target') + else: + self.fields['target'].queryset = Target.objects.none() diff --git a/tom_targets/migrations/0022_persistentshare.py b/tom_targets/migrations/0022_persistentshare.py new file mode 100644 index 000000000..98ee3cecc --- /dev/null +++ b/tom_targets/migrations/0022_persistentshare.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.13 on 2024-11-22 22:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tom_targets', '0021_rename_target_basetarget_alter_basetarget_options'), + ] + + operations = [ + migrations.CreateModel( + name='PersistentShare', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('destination', models.CharField(help_text='The sharing destination, as it appears in your DATA_SHARING settings dict', max_length=200)), + ('created', models.DateTimeField(auto_now_add=True, help_text='The time which this PersistentShare was created in the TOM database.')), + ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tom_targets.basetarget')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-created',), + 'unique_together': {('target', 'destination')}, + }, + ), + ] diff --git a/tom_targets/migrations/0023_alter_basetarget_options.py b/tom_targets/migrations/0023_alter_basetarget_options.py new file mode 100644 index 000000000..78eb632c6 --- /dev/null +++ b/tom_targets/migrations/0023_alter_basetarget_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.13 on 2024-12-05 01:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tom_targets', '0022_persistentshare'), + ] + + operations = [ + migrations.AlterModelOptions( + name='basetarget', + options={'permissions': (('view_target', 'View Target'), ('add_target', 'Add Target'), ('share_target', 'Share Target'), ('change_target', 'Change Target'), ('delete_target', 'Delete Target')), 'verbose_name': 'target'}, + ), + ] diff --git a/tom_targets/models.py b/tom_targets/models.py index 49fa4404e..942ab9485 100644 --- a/tom_targets/models.py +++ b/tom_targets/models.py @@ -3,6 +3,7 @@ import logging from django.conf import settings +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import models from django.utils.module_loading import import_string @@ -193,3 +194,32 @@ class Meta: def __str__(self): return self.name + + +class PersistentShare(models.Model): + """ + Class representing a persistent share setup between a sharing destination and a Target + + :param target: The ``Target`` you want to share + + :param user: The ``User`` that created this PersistentShare, for accountability purposes. + + :param destination: The sharing destination, as it appears in your TOM's DATA_SHARING settings dict + :type destination: str + + :param created: The time at which this PersistentShare was created + :type created: datetime + """ + target = models.ForeignKey(BaseTarget, on_delete=models.CASCADE) + user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) + destination = models.CharField(max_length=200, help_text='The sharing destination, as it appears in your DATA_SHARING settings dict') + created = models.DateTimeField( + auto_now_add=True, help_text='The time which this PersistentShare was created in the TOM database.' + ) + + class Meta: + ordering = ('-created',) + unique_together = ['target', 'destination'] + + def __str__(self): + return f'{self.target}-{self.destination}' diff --git a/tom_targets/serializers.py b/tom_targets/serializers.py index a589a7bc9..dc21563eb 100644 --- a/tom_targets/serializers.py +++ b/tom_targets/serializers.py @@ -3,8 +3,10 @@ from rest_framework import serializers from tom_common.serializers import GroupSerializer -from tom_targets.models import Target, TargetExtra, TargetName, TargetList +from tom_targets.models import Target, TargetExtra, TargetName, TargetList, PersistentShare from tom_targets.validators import RequiredFieldsTogetherValidator +from tom_targets.fields import TargetFilteredPrimaryKeyRelatedField +from tom_dataproducts.sharing import get_sharing_destination_options class TargetNameSerializer(serializers.ModelSerializer): @@ -181,13 +183,10 @@ def update(self, instance, validated_data): return instance -class TargetFilteredPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): - # This PrimaryKeyRelatedField subclass is used to implement get_queryset based on the permissions of the user - # submitting the request. The pattern was taken from this StackOverflow answer: https://stackoverflow.com/a/32683066 +class PersistentShareSerializer(serializers.ModelSerializer): + destination = serializers.ChoiceField(choices=get_sharing_destination_options(), required=True) + target = TargetFilteredPrimaryKeyRelatedField(queryset=Target.objects.all(), required=True) - def get_queryset(self): - request = self.context.get('request', None) - queryset = super().get_queryset() - if not (request and queryset): - return None - return get_objects_for_user(request.user, 'tom_targets.change_target') + class Meta: + model = PersistentShare + fields = ('id', 'target', 'destination', 'user', 'created') diff --git a/tom_targets/signals/__init__.py b/tom_targets/signals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tom_targets/signals/handlers.py b/tom_targets/signals/handlers.py new file mode 100644 index 000000000..ca24f2a18 --- /dev/null +++ b/tom_targets/signals/handlers.py @@ -0,0 +1,17 @@ +from django.dispatch import receiver +from django.db.models.signals import post_save + +from tom_dataproducts.models import ReducedDatum +from tom_dataproducts.sharing import share_data_with_destination +from tom_targets.models import PersistentShare + + +@receiver(post_save, sender=ReducedDatum) +def cb_dataproduct_post_save(sender, instance, *args, **kwargs): + # When a new dataproduct is created or updated, check for any persistentshare instances on that target + # and if they exist, attempt to share the new data + target = instance.target + persistentshares = PersistentShare.objects.filter(target=target) + for persistentshare in persistentshares: + share_destination = persistentshare.destination + share_data_with_destination(share_destination, instance) diff --git a/tom_targets/templates/tom_targets/partials/create_persistent_share.html b/tom_targets/templates/tom_targets/partials/create_persistent_share.html new file mode 100644 index 000000000..7f025b56f --- /dev/null +++ b/tom_targets/templates/tom_targets/partials/create_persistent_share.html @@ -0,0 +1,73 @@ +{% load bootstrap4 targets_extras static %} + +
+ \ No newline at end of file diff --git a/tom_targets/templates/tom_targets/partials/persistent_share_table.html b/tom_targets/templates/tom_targets/partials/persistent_share_table.html new file mode 100644 index 000000000..b2ee43789 --- /dev/null +++ b/tom_targets/templates/tom_targets/partials/persistent_share_table.html @@ -0,0 +1,52 @@ +Target | +Share Destination | +Creator | +Delete? | +
---|---|---|---|
+ {{ persistentshare.target.names|join:", " }} + | +{{ persistentshare.destination }} | +{{ persistentshare.user.username }} | ++ {% if target %} + + {% else %} + + {% endif %} + | + {% endfor %} +