diff --git a/config/settings/base.py b/config/settings/base.py index bb8df71ebc3ede1a11ef5c34dd86977352033fe5..e6a773ad7a8439558199e161a998535d95ae553c 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -78,6 +78,7 @@ THIRD_PARTY_APPS = [ "shibboleth", "taggit", "django_elasticsearch_dsl", + "mptt", ] LOCAL_APPS = [ @@ -353,3 +354,11 @@ RUNSCRIPT_SCRIPT_DIR = "djangoscripts" BLEACH_ALLOWED_TAGS = [] BLEACH_STRIP_TAGS = False BLEACH_STRIP_COMMENTS = True + +# actstream +ACTSTREAM_SETTINGS = { + "MANAGER": "core.managers.DiscussDataActionManager", + "FETCH_RELATIONS": True, + "USE_JSONFIELD": False, + "GFK_FETCH_DEPTH": 1, +} diff --git a/discuss_data/core/managers.py b/discuss_data/core/managers.py new file mode 100644 index 0000000000000000000000000000000000000000..955c39af92e72973559f75c220bd47ca7366f66f --- /dev/null +++ b/discuss_data/core/managers.py @@ -0,0 +1,68 @@ +from datetime import datetime + +from django.contrib.contenttypes.models import ContentType + +from actstream.managers import ActionManager, stream +from actstream.registry import check + +from django.apps import apps +from django.db.models import Q + + +class DiscussDataActionManager(ActionManager): + @stream + def mystream(self, obj, verb="posted", time=None): + if time is None: + time = datetime.now() + return obj.actor_actions.filter(verb=verb, timestamp__lte=time) + + @stream + def dataset_access_requests_sent(self, sender, recipient=None): + notification_content_type = ContentType.objects.get( + app_label="ddcomments", model="notification" + ) + dataset_content_type = ContentType.objects.get( + app_label="dddatasets", model="dataset" + ) + filters = {"action_object_content_type": notification_content_type.id} + filters["target_content_type"] = dataset_content_type.id + if recipient: # optional recipient + filters["target"] = recipient + # Actions where sender is the actor with extra filters applied + return sender.actor_actions.filter(**filters) + + @stream + def access_requests_received(self, datasets): + notification_content_type = ContentType.objects.get( + app_label="ddcomments", model="notification" + ) + dataset_content_type = ContentType.objects.get( + app_label="dddatasets", model="dataset" + ) + + return self.filter( + action_object_content_type=notification_content_type.id, + target_content_type=dataset_content_type.id, + target_object_id__in=datasets, + ) + + def everything(self, *args, **kwargs): + """ + Return all actions (public=True and False) + """ + return self.filter(*args, **kwargs) + + @stream + def any_everything(self, obj, **kwargs): + """ + Stream of most recent actions where obj is the actor OR target OR action_object. + Includes also Actions which are not public + """ + check(obj) + ctype = ContentType.objects.get_for_model(obj) + return self.everything( + Q(actor_content_type=ctype, actor_object_id=obj.pk,) + | Q(target_content_type=ctype, target_object_id=obj.pk,) + | Q(action_object_content_type=ctype, action_object_object_id=obj.pk,), + **kwargs, + ) diff --git a/discuss_data/core/templatetags/core_tags.py b/discuss_data/core/templatetags/core_tags.py index f0b013b0972e5410999d002ad88120f44eb7e4d5..bc72ea07de373bfdf63b9555bfd06f6c1177ff75 100644 --- a/discuss_data/core/templatetags/core_tags.py +++ b/discuss_data/core/templatetags/core_tags.py @@ -1,6 +1,15 @@ from django.urls import resolve from django import template + from discuss_data.ddusers.models import User +from discuss_data.dddatasets.models import DataSet + +from discuss_data.ddcomments.forms import ( + NotificationForm, + CommentForm, +) + +from discuss_data.ddcomments.models import Notification register = template.Library() DEFAULT_USER_IMAGE = "/static/images/user_default.png" @@ -51,3 +60,94 @@ def get_value(dictionary, key): return dictionary[key] except Exception: return None + + +@register.filter +def get_file_icon(mime_str): + """ + Returns the fa icon string for the mime group + """ + if mime_str == "application/zip": + return "fa-file" + if mime_str == "archive/pdf" or mime_str == "application/pdf": + return "fa-file-pdf" + + try: + kind, ext = mime_str.split("/") + return "fa-file-" + kind + except Exception: + return "fa-file" + + +@register.filter +def user_has_restricted_access(dataset, user): + return dataset.user_has_ra_view_right(user) + + +@register.filter +def user_has_admin_right(dataset, user): + return dataset.user_has_admin_right(user) + + +@register.filter +def prepare_notification_form(comment, user): + if comment.notification_type == Notification.ACCESS_REQUEST: + recipient_uuid = comment.owner.uuid + else: + if comment.owner: + recipient_uuid = comment.recipient.uuid + else: + recipient_uuid = comment.owner.uuid + parent_uuid = None + if comment.level > 0: + if not comment.get_next_sibling(): + if comment.parent: + parent_uuid = comment.parent.uuid + initial = { + "recipient_uuid": recipient_uuid, + "parent_uuid": parent_uuid, + } + return NotificationForm(initial=initial) + else: + if comment.is_leaf_node(): + parent_uuid = comment.uuid + initial = { + "recipient_uuid": recipient_uuid, + "parent_uuid": parent_uuid, + } + return NotificationForm(initial=initial) + return None + + +@register.filter +def prepare_comment_form(comment, user): + parent_uuid = None + if comment.level > 0: + if not comment.get_next_sibling(): + if comment.parent: + parent_uuid = comment.parent.uuid + initial = { + "parent_uuid": parent_uuid, + } + return CommentForm(initial=initial) + else: + if comment.is_leaf_node(): + parent_uuid = comment.uuid + initial = { + "parent_uuid": parent_uuid, + } + return CommentForm(initial=initial) + return None + + +@register.filter +def get_parent_comment(comment): + parent = None + if comment.level > 0: + if not comment.get_next_sibling(): + if comment.parent: + parent = comment.parent + else: + if comment.is_leaf_node(): + parent = comment + return parent diff --git a/discuss_data/core/views.py b/discuss_data/core/views.py index c5ddb6b7dcaac448a297c150e7b91853ec6305a3..fbe5bbaa96cd22413c822d1771f9f7c3dfbdadf6 100644 --- a/discuss_data/core/views.py +++ b/discuss_data/core/views.py @@ -131,7 +131,7 @@ def core_search_view(request, search_index, objects_on_page): else: # default search index is user_index - template = "ddusers/search_results.html" + template = "ddusers/_search_results.html" queryset = index_search( "user_index", query, countries=countries, categories=categories ) @@ -218,7 +218,6 @@ def landing_page(request): def check_perms(permission, user, dd_obj): - logger.debug("start checks_perms") try: if dd_obj.owner == user: logger.debug("is owner") @@ -234,6 +233,7 @@ def check_perms(permission, user, dd_obj): else: logger.debug("%s lacks perm %s on %s" % (user, permission, dd_obj)) return False + return False def check_perms_403(permission, user, dd_obj): diff --git a/discuss_data/ddcomments/apps.py b/discuss_data/ddcomments/apps.py index 9e33001529deb4a7a26473180e5119beb9c1f4c7..6aa0e9133fc0a4a6e5aa4097b4bbbe03a2d03c06 100644 --- a/discuss_data/ddcomments/apps.py +++ b/discuss_data/ddcomments/apps.py @@ -8,3 +8,4 @@ class DdcommentsConfig(AppConfig): from actstream import registry registry.register(self.get_model("Comment")) + registry.register(self.get_model("Notification")) diff --git a/discuss_data/ddcomments/forms.py b/discuss_data/ddcomments/forms.py index 119cd57d48b390344486f539337b304fb2534855..f52bbdee7cb5df45bb1e5ed9df2f818a82e49da3 100644 --- a/discuss_data/ddcomments/forms.py +++ b/discuss_data/ddcomments/forms.py @@ -1,3 +1,5 @@ +from django import forms +from django.contrib import messages from django.forms import ModelForm from crispy_forms.helper import FormHelper @@ -13,14 +15,26 @@ from crispy_forms.layout import ( ) from crispy_forms.bootstrap import FormActions, TabHolder, Tab -from .models import Comment +from .models import ( + Comment, + Notification, +) -UPDATED_MSG = """ - {% if updated %}{% endif %} - {% if err %} {% endif %}""" +MSG = """ +{% if messages %} + {% for message in messages %} + {% if message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} + + {% endif %} + {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %} + + {% endif %} + {% endfor %} +{% endif %} +""" ADD_EDIT_HEADING = ( "

{% if new %}Add{% else %}Edit{% endif %} {{ target|title }}


" @@ -39,10 +53,51 @@ class CommentForm(ModelForm): helper.form_tag = False helper.form_show_labels = False + helper.layout = Layout( + Div(Div("text", css_class="col-md-12", rows="3",), css_class="row",), + ) + + +class NotificationForm(ModelForm): + recipient_uuid = forms.UUIDField() + parent_uuid = forms.UUIDField(required=False) + + class Meta: + model = Notification + fields = [ + "text", + "recipient_uuid", + "parent_uuid", + ] + + helper = FormHelper() + helper.form_tag = False + helper.form_show_labels = False + + helper.layout = Layout( + Field("recipient_uuid", type="hidden"), + Field("parent_uuid", type="hidden"), + Div( + Div("text", css_class="col-md-12 notification", rows="3",), css_class="row", + ), + FormActions(Submit("save", "{{ btn_text }}")), + ) + + +class AccessRequestForm(ModelForm): + class Meta: + model = Notification + fields = [ + "text", + ] + + helper = FormHelper() + helper.form_tag = False + helper.form_show_labels = False + helper.layout = Layout( Div( - Div("text", css_class="col-md-12", rows="3",), - css_class="row", - # Field('text', css_class='col-md-12 row', rows='5', ), + Div("text", css_class="col-md-12 notification", rows="3",), css_class="row", ), + FormActions(Submit("save", "{{ btn_text }}"),), ) diff --git a/discuss_data/ddcomments/migrations/0009_notification.py b/discuss_data/ddcomments/migrations/0009_notification.py new file mode 100644 index 0000000000000000000000000000000000000000..4773ff4c22084291019c06a338614ad79e05c449 --- /dev/null +++ b/discuss_data/ddcomments/migrations/0009_notification.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.11 on 2020-05-12 07:05 + +from django.db import migrations, models +import django_bleach.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0008_auto_20200506_1402'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False)), + ('text', django_bleach.models.BleachField()), + ('read', models.BooleanField()), + ('deleted', models.BooleanField()), + ('notification_type', models.CharField(choices=[('USER', 'user'), ('CUR', 'curator')], default='USER', max_length=4)), + ], + ), + ] diff --git a/discuss_data/ddcomments/migrations/0010_auto_20200513_1225.py b/discuss_data/ddcomments/migrations/0010_auto_20200513_1225.py new file mode 100644 index 0000000000000000000000000000000000000000..78273a63c75dae5b6a0ce1232bc5c854c15f4202 --- /dev/null +++ b/discuss_data/ddcomments/migrations/0010_auto_20200513_1225.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.11 on 2020-05-13 12:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0009_notification'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='deleted', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='notification', + name='read', + field=models.BooleanField(default=False), + ), + ] diff --git a/discuss_data/ddcomments/migrations/0011_ddcomment.py b/discuss_data/ddcomments/migrations/0011_ddcomment.py new file mode 100644 index 0000000000000000000000000000000000000000..cf498da8795fef29633dfec7ebbf998f0b5f5dd2 --- /dev/null +++ b/discuss_data/ddcomments/migrations/0011_ddcomment.py @@ -0,0 +1,44 @@ +# Generated by Django 2.2.11 on 2020-05-18 09:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_bleach.models +import mptt.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('ddcomments', '0010_auto_20200513_1225'), + ] + + operations = [ + migrations.CreateModel( + name='DDComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False)), + ('comment_type', models.CharField(choices=[('PUB', 'public'), ('PRI', 'private'), ('CUR', 'curator'), ('PERM', 'permanent'), ('AR', 'access request')], default='PUB', max_length=4)), + ('doi', models.CharField(blank=True, max_length=200)), + ('text', django_bleach.models.BleachField()), + ('date_added', models.DateTimeField(auto_now_add=True)), + ('date_edited', models.DateTimeField(auto_now=True)), + ('object_id', models.IntegerField()), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.ContentType')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='comment_owner', to=settings.AUTH_USER_MODEL)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='ddcomments.DDComment')), + ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='comment_recipient', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/discuss_data/ddcomments/migrations/0012_auto_20200520_0802.py b/discuss_data/ddcomments/migrations/0012_auto_20200520_0802.py new file mode 100644 index 0000000000000000000000000000000000000000..6c68f37b283e04826dd3c31d57a2985492535ead --- /dev/null +++ b/discuss_data/ddcomments/migrations/0012_auto_20200520_0802.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.11 on 2020-05-20 08:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0011_ddcomment'), + ] + + operations = [ + migrations.AddField( + model_name='comment', + name='deleted', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='comment', + name='read', + field=models.BooleanField(default=False), + ), + ] diff --git a/discuss_data/ddcomments/migrations/0013_delete_notification.py b/discuss_data/ddcomments/migrations/0013_delete_notification.py new file mode 100644 index 0000000000000000000000000000000000000000..7a4d803b2222fe7127737da7b6fb700461efd718 --- /dev/null +++ b/discuss_data/ddcomments/migrations/0013_delete_notification.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2.11 on 2020-05-20 09:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0012_auto_20200520_0802'), + ] + + operations = [ + migrations.DeleteModel( + name='Notification', + ), + ] diff --git a/discuss_data/ddcomments/migrations/0014_auto_20200520_0917.py b/discuss_data/ddcomments/migrations/0014_auto_20200520_0917.py new file mode 100644 index 0000000000000000000000000000000000000000..27d3717d0159eae63235769a5b87ed54e0db524b --- /dev/null +++ b/discuss_data/ddcomments/migrations/0014_auto_20200520_0917.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.11 on 2020-05-20 09:17 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('ddcomments', '0013_delete_notification'), + ] + + operations = [ + migrations.RenameModel( + old_name='DDComment', + new_name='Notification', + ), + ] diff --git a/discuss_data/ddcomments/migrations/0015_auto_20200520_0919.py b/discuss_data/ddcomments/migrations/0015_auto_20200520_0919.py new file mode 100644 index 0000000000000000000000000000000000000000..c0c9711c42a2a201805654613fce06d72c508f7d --- /dev/null +++ b/discuss_data/ddcomments/migrations/0015_auto_20200520_0919.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2020-05-20 09:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0014_auto_20200520_0917'), + ] + + operations = [ + migrations.RenameField( + model_name='notification', + old_name='comment_type', + new_name='notification_type', + ), + ] diff --git a/discuss_data/ddcomments/migrations/0016_notification_permanent.py b/discuss_data/ddcomments/migrations/0016_notification_permanent.py new file mode 100644 index 0000000000000000000000000000000000000000..beb2868a4a7965ab8e286c20a3c99f81f9fabfcb --- /dev/null +++ b/discuss_data/ddcomments/migrations/0016_notification_permanent.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2020-05-20 11:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0015_auto_20200520_0919'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='permanent', + field=models.BooleanField(default=False), + ), + ] diff --git a/discuss_data/ddcomments/migrations/0017_auto_20200520_1134.py b/discuss_data/ddcomments/migrations/0017_auto_20200520_1134.py new file mode 100644 index 0000000000000000000000000000000000000000..48ba5b13c41054a158eb478343383ea346baa561 --- /dev/null +++ b/discuss_data/ddcomments/migrations/0017_auto_20200520_1134.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2020-05-20 11:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0016_notification_permanent'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('PUB', 'public'), ('PRI', 'private'), ('CUR', 'curator'), ('AR', 'access request')], default='PUB', max_length=4), + ), + ] diff --git a/discuss_data/ddcomments/migrations/0018_remove_notification_doi.py b/discuss_data/ddcomments/migrations/0018_remove_notification_doi.py new file mode 100644 index 0000000000000000000000000000000000000000..148352109224f6943712a159223979ca6bda969c --- /dev/null +++ b/discuss_data/ddcomments/migrations/0018_remove_notification_doi.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.11 on 2020-05-20 11:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0017_auto_20200520_1134'), + ] + + operations = [ + migrations.RemoveField( + model_name='notification', + name='doi', + ), + ] diff --git a/discuss_data/ddcomments/migrations/0019_auto_20200520_1139.py b/discuss_data/ddcomments/migrations/0019_auto_20200520_1139.py new file mode 100644 index 0000000000000000000000000000000000000000..26c3e1b836a459dd2feac3600334a9c42bc7d95a --- /dev/null +++ b/discuss_data/ddcomments/migrations/0019_auto_20200520_1139.py @@ -0,0 +1,59 @@ +# Generated by Django 2.2.11 on 2020-05-20 11:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_bleach.models +import mptt.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('ddcomments', '0018_remove_notification_doi'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='notification_owner', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='notification', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_children', to='ddcomments.Notification'), + ), + migrations.AlterField( + model_name='notification', + name='recipient', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='notification_recipient', to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='Comment2', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False)), + ('doi', models.CharField(blank=True, max_length=200)), + ('notification_type', models.CharField(choices=[('PUB', 'public'), ('PRI', 'private'), ('CUR', 'curator'), ('PERM', 'permanent')], default='PUB', max_length=4)), + ('text', django_bleach.models.BleachField()), + ('date_added', models.DateTimeField(auto_now_add=True)), + ('date_edited', models.DateTimeField(auto_now=True)), + ('permanent', models.BooleanField(default=False)), + ('object_id', models.IntegerField()), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.ContentType')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='comment_owner', to=settings.AUTH_USER_MODEL)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comment_children', to='ddcomments.Comment2')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/discuss_data/ddcomments/migrations/0020_auto_20200520_1410.py b/discuss_data/ddcomments/migrations/0020_auto_20200520_1410.py new file mode 100644 index 0000000000000000000000000000000000000000..cd5b57c6a045c64a98b681bcac5e46840e0ea134 --- /dev/null +++ b/discuss_data/ddcomments/migrations/0020_auto_20200520_1410.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2020-05-20 14:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0019_auto_20200520_1139'), + ] + + operations = [ + migrations.RenameField( + model_name='comment2', + old_name='notification_type', + new_name='comment_type', + ), + ] diff --git a/discuss_data/ddcomments/migrations/0021_delete_comment.py b/discuss_data/ddcomments/migrations/0021_delete_comment.py new file mode 100644 index 0000000000000000000000000000000000000000..ef4c40041e087608d1b61cf07223f277d4f06ad9 --- /dev/null +++ b/discuss_data/ddcomments/migrations/0021_delete_comment.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2.11 on 2020-05-20 14:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0020_auto_20200520_1410'), + ] + + operations = [ + migrations.DeleteModel( + name='Comment', + ), + ] diff --git a/discuss_data/ddcomments/migrations/0022_auto_20200520_1436.py b/discuss_data/ddcomments/migrations/0022_auto_20200520_1436.py new file mode 100644 index 0000000000000000000000000000000000000000..0263df0ccc1a5fa264f03c4a924c84f387bbfb58 --- /dev/null +++ b/discuss_data/ddcomments/migrations/0022_auto_20200520_1436.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.11 on 2020-05-20 14:36 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('ddcomments', '0021_delete_comment'), + ] + + operations = [ + migrations.RenameModel( + old_name='Comment2', + new_name='Comment', + ), + ] diff --git a/discuss_data/ddcomments/migrations/0023_comment_deleted.py b/discuss_data/ddcomments/migrations/0023_comment_deleted.py new file mode 100644 index 0000000000000000000000000000000000000000..7a5745241ecc4fa8b541a025588b37d3b5cda66d --- /dev/null +++ b/discuss_data/ddcomments/migrations/0023_comment_deleted.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2020-05-20 14:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ddcomments', '0022_auto_20200520_1436'), + ] + + operations = [ + migrations.AddField( + model_name='comment', + name='deleted', + field=models.BooleanField(default=False), + ), + ] diff --git a/discuss_data/ddcomments/models.py b/discuss_data/ddcomments/models.py index 44df8924d589d825be4d3ec60e08dd372801e5ff..5af5d4495da183bf7206450d41f94a536407e065 100644 --- a/discuss_data/ddcomments/models.py +++ b/discuss_data/ddcomments/models.py @@ -5,12 +5,85 @@ from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ +from django.utils import timezone + +from django.contrib.contenttypes.fields import GenericRelation + +from mptt.models import MPTTModel, TreeForeignKey from django_bleach.models import BleachField +from actstream.models import Action + + +class Notification(MPTTModel): + """ + Notification with tree-traversal support for threads + """ + + PUBLIC = "PUB" + PRIVATE = "PRI" + CURATOR = "CUR" + ACCESS_REQUEST = "AR" + + NOTIFICATION_TYPE_CHOICES = [ + (PUBLIC, _("public")), + (PRIVATE, _("private")), + (CURATOR, _("curator")), + (ACCESS_REQUEST, _("access request")), + ] + + uuid = models.UUIDField(default=uuid.uuid4, editable=False) + notification_type = models.CharField( + max_length=4, choices=NOTIFICATION_TYPE_CHOICES, default=PUBLIC, + ) + text = BleachField() + date_added = models.DateTimeField(auto_now_add=True) + date_edited = models.DateTimeField(auto_now=True) + owner = models.ForeignKey( + "ddusers.User", related_name="notification_owner", on_delete=models.PROTECT + ) + recipient = models.ForeignKey( + "ddusers.User", + related_name="notification_recipient", + on_delete=models.PROTECT, + null=True, + blank=True, + ) + permanent = models.BooleanField(default=False) + + parent = TreeForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="notification_children", + ) + + # mandatory fields for generic relation + content_type = models.ForeignKey(ContentType, on_delete=models.PROTECT) + object_id = models.IntegerField() + content_object = GenericForeignKey() + + def __str__(self): + return "[%s] %s %s (%s), %s" % ( + self.get_notification_type_display(), + self.content_type, + self.content_object, + self.owner, + self.date_added, + ) + + def class_name(self): + return self.__class__.__name__ + @reversion.register() -class Comment(models.Model): +class Comment(MPTTModel): + """ + Comment with tree-traversal support for threads + """ + PUBLIC = "PUB" PRIVATE = "PRI" CURATOR = "CUR" @@ -24,16 +97,25 @@ class Comment(models.Model): ] uuid = models.UUIDField(default=uuid.uuid4, editable=False) + doi = models.CharField(max_length=200, blank=True) comment_type = models.CharField( max_length=4, choices=COMMENT_TYPE_CHOICES, default=PUBLIC, ) - doi = models.CharField(max_length=200, blank=True) text = BleachField() date_added = models.DateTimeField(auto_now_add=True) date_edited = models.DateTimeField(auto_now=True) - owner = models.ForeignKey("ddusers.User", on_delete=models.PROTECT) - reply_to = models.ForeignKey( - "Comment", on_delete=models.PROTECT, blank=True, null=True + owner = models.ForeignKey( + "ddusers.User", related_name="comment_owner", on_delete=models.PROTECT + ) + permanent = models.BooleanField(default=False) + deleted = models.BooleanField(default=False) + + parent = TreeForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="comment_children", ) # mandatory fields for generic relation @@ -42,9 +124,20 @@ class Comment(models.Model): content_object = GenericForeignKey() def __str__(self): - return "%s %s, %s (%s)" % ( + return "[%s] %s %s (%s), %s" % ( + self.get_comment_type_display(), self.content_type, self.content_object, self.owner, self.date_added, ) + + def class_name(self): + return self.__class__.__name__ + + def set_delete(self): + delete_date = timezone.now().strftime("%d.%m.%Y, %H:%M:%S") + delete_text = _("This comment has been deleted at") + self.text = "{} {}".format(delete_text, delete_date) + self.deleted = True + self.save() diff --git a/discuss_data/dddatasets/models.py b/discuss_data/dddatasets/models.py index b2d3e494b2c8bfca788f965c84335e478a4989bb..d0b033b79f12bf891f51583771f856f814404274 100644 --- a/discuss_data/dddatasets/models.py +++ b/discuss_data/dddatasets/models.py @@ -1,13 +1,13 @@ import logging import os import uuid +import filetype from actstream import action from datetime import datetime from dcxml import simpledc from PIL import Image - from taggit.managers import TaggableManager from taggit.models import ( TagBase, @@ -61,6 +61,8 @@ logger = logging.getLogger(__name__) # provide a storage independent of 'media' file storage datafile_storage = FileSystemStorage(location=settings.DATA_ROOT) + + class Category(models.Model): name = models.CharField(max_length=400) slug = models.CharField(max_length=400) @@ -94,6 +96,11 @@ class Sponsor(models.Model): class DataFile(models.Model): + FILE_FORMATS = { + "png": "image", + "jpeg": "image", + "jpg": "image", + } DATA_FILE_TYPES = ( ("DAT", _("data")), ("MET", _("metadata")), @@ -106,7 +113,7 @@ class DataFile(models.Model): dataset = models.ForeignKey( "DataSet", related_name="datafile_dataset_dataset", on_delete=models.CASCADE, ) - #better: provide an upload directory with a datestamp to prevent overflow + # better: provide an upload directory with a datestamp to prevent overflow file = models.FileField(upload_to="datasets/", storage=datafile_storage) data_file_type = models.CharField( max_length=3, choices=DATA_FILE_TYPES, default="DAT", @@ -118,10 +125,21 @@ class DataFile(models.Model): repository_file_id = models.CharField(max_length=200, default="not set") repository = models.CharField(max_length=200, default="dariah-repository") + def get_download_file_name(self): + return "DiscussData-{}-{}".format( + self.dataset.title.replace(" ", "_"), self.file + ) + def extension(self): extension = os.path.splitext(self.file.name) return extension + def get_file_format(self): + file_format = filetype.guess(self.file) + if file_format is None: + return "other" + return file_format.mime + def get_user(self): return self.dataset.owner @@ -132,16 +150,15 @@ class DataFile(models.Model): return self.name def clone(self, new_ds): - # orig_id = self.id self.save() new_file = ContentFile(self.file.read()) new_file.name = self.file.name df = self df.pk = None + df.uuid = uuid.uuid4() df.file = new_file df.dataset = new_ds df.save() - # print("cloned datafile " + str(df.id)) return df class Meta: @@ -314,10 +331,13 @@ class DataSetExternalLink(models.Model): class DataSet(models.Model): + OPEN_ACCESS = "OA" + METADATA_ONLY = "MO" + RESTRICTED_ACCESS = "RA" DATA_ACCESS_CHOICES = [ - ("OA", _("Open Access")), - ("MO", _("Metadata only")), - ("RA", _("Restricted access")), + (OPEN_ACCESS, _("Open Access")), + (METADATA_ONLY, _("Metadata only")), + (RESTRICTED_ACCESS, _("Restricted access")), ] uuid = models.UUIDField( @@ -467,6 +487,12 @@ class DataSet(models.Model): # get all users with permissions, but exclude owner return get_users_with_perms(self).exclude(uuid=self.owner.uuid) + def user_has_ra_view_right(self, user): + return user.has_perm("ra_view_dataset", self) + + def user_has_admin_right(self, user): + return user.has_perm("admin_dsmo", self.dataset_management_object) + def class_name(self): return self.__class__.__name__ @@ -514,12 +540,24 @@ class DataSet(models.Model): return dataset_get_users(self) - def get_prep_comments(self): - # return prep comments in reverse date order - return self.comments.order_by("-date_added") + def get_prep_comments_all(self): + # return all prep comments in reverse date order + ct = ContentType.objects.get_for_model(DataSet) + return Comment.objects.filter(content_type=ct, object_id=self.id,).order_by( + "-date_added" + ) + + def get_prep_comments_root(self): + # return prep comments root nodes in reverse date order + ct = ContentType.objects.get_for_model(DataSet) + return ( + Comment.objects.root_nodes() + .filter(content_type=ct, object_id=self.id,) + .order_by("-date_added") + ) - def get_public_comments(self): - # return public comments in reverse date order + def get_public_comments_all(self): + # return all public comments in reverse date order ct = ContentType.objects.get_for_model(DataSet) return Comment.objects.filter( content_type=ct, @@ -527,6 +565,19 @@ class DataSet(models.Model): comment_type__in=(Comment.PUBLIC, Comment.PERMANENT), ).order_by("-date_added") + def get_public_comments_root(self): + # return public comments root nodes in reverse date order + ct = ContentType.objects.get_for_model(DataSet) + return ( + Comment.objects.root_nodes() + .filter( + content_type=ct, + object_id=self.id, + comment_type__in=(Comment.PUBLIC, Comment.PERMANENT), + ) + .order_by("-date_added") + ) + def get_pdf_file_name(self): return "DiscussData-%s-description.pdf" % (self.title.replace(" ", "_")) @@ -670,10 +721,7 @@ class DataSet(models.Model): assign_perm(perm, group, self) def save(self, *args, **kwargs): - # print(self.id) - # print(self.__dict__) if not self.dataset_management_object: - # print('creatin dsmo') dsmo = DataSetManagementObject() dsmo.owner = self.owner dsmo.save() @@ -707,6 +755,9 @@ class DataSet(models.Model): def get_datafiles(self): return DataFile.objects.filter(dataset=self) + def get_datafiles_count(self): + return self.get_datafiles().count() + def dublin_core(self): dc_dict = dict( titles=[self.title_en, self.subtitle_en], diff --git a/discuss_data/dddatasets/urls.py b/discuss_data/dddatasets/urls.py index ef529de01bb1fa0a599c100ca982a63885281df4..40ee8b648e4ebccc91c51cff440bace0d9b17a49 100644 --- a/discuss_data/dddatasets/urls.py +++ b/discuss_data/dddatasets/urls.py @@ -12,6 +12,12 @@ urlpatterns = [ path("public//", views.dataset_detail, name="detail"), path("public//follow/", views.dataset_follow, name="follow"), path("public//metadata/", views.dataset_metadata, name="metadata"), + path("public//access/", views.dataset_access, name="access"), + path( + "public//access/request/", + views.dataset_access_request, + name="access_request", + ), path("public//files/", views.dataset_files, name="files"), path("public//files/pdf/", views.dataset_files_pdf, name="files_pdf"), path("public//files/zip/", views.dataset_files_zip, name="files_zip"), @@ -42,6 +48,11 @@ urlpatterns = [ views.public_dataset_edit_comment, name="edit_comment", ), + path( + "public//edit/comment//reply/", + views.public_dataset_edit_comment_reply, + name="edit_comment_reply", + ), path( "public//edit/comment/", views.public_dataset_edit_comment, @@ -228,4 +239,9 @@ urlpatterns += [ views_prep.prep_dataset_edit_comment, name="prep_edit_comment", ), + path( + "prep//edit/comment//reply/", + views_prep.prep_dataset_edit_comment_reply, + name="prep_edit_comment_reply", + ), ] diff --git a/discuss_data/dddatasets/utils.py b/discuss_data/dddatasets/utils.py index 314ae0824bf25545e577b8ef6ea1782ef08e7665..7d2f298d293b41d16540ea916115b6dab0097620 100644 --- a/discuss_data/dddatasets/utils.py +++ b/discuss_data/dddatasets/utils.py @@ -1,12 +1,22 @@ +from datetime import datetime + from django.http import Http404 -from django.shortcuts import render +from django.shortcuts import ( + render, + redirect, +) from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext as _ from weasyprint import HTML from actstream import action -from discuss_data.dddatasets.models import DataSet, DataSetManagementObject, DataFile +from discuss_data.dddatasets.models import ( + DataSet, + DataSetManagementObject, + DataFile, +) from discuss_data.pages.models import ManualPage @@ -106,28 +116,43 @@ def dataset_comments(request, ds_uuid, view_type): if view_type != "public": check_perms_403("edit_dsmo", request.user, ds.dataset_management_object) - comments = ds.get_prep_comments() + comments = ds.get_prep_comments_root() add_url = "dddatasets:prep_edit_comment" list_url = "dddatasets:prep_comments" + reply_url = "dddatasets:prep_edit_comment_reply" + root_count = comments.count() + all_count = ds.get_prep_comments_all().count() else: - comments = ds.get_public_comments() + comments = ds.get_public_comments_root() add_url = "dddatasets:edit_comment" list_url = "dddatasets:comments" + reply_url = "dddatasets:edit_comment_reply" + root_count = comments.count() + all_count = ds.get_public_comments_all().count() # add http header to response to resume polling (which could have been canceled in prep_dataset_edit_comment) response = render( request, "ddcomments/_comments_list.html", - {"ds": ds, "comments": comments, "add_url": add_url, "list_url": list_url}, + { + "ds": ds, + "comments": comments, + "add_url": add_url, + "list_url": list_url, + "reply_url": reply_url, + "root_count": root_count, + "all_count": all_count, + }, ) response["X-IC-ResumePolling"] = "true" return response -def dataset_edit_comment(request, ds_uuid, view_type, co_uuid): +def dataset_edit_comment(request, ds_uuid, view_type, co_uuid, reply=None): """ view to add, edit or delete public/prep comments to/from a dataset """ + if view_type == "public" or view_type == "prep": pass else: @@ -137,26 +162,31 @@ def dataset_edit_comment(request, ds_uuid, view_type, co_uuid): if view_type == "public": add_url = "dddatasets:edit_comment" list_url = "dddatasets:comments" + reply_url = "dddatasets:edit_comment_reply" else: add_url = "dddatasets:prep_edit_comment" list_url = "dddatasets:prep_comments" + reply_url = "dddatasets:prep_edit_comment_reply" if view_type != "public": check_perms_403("edit_dsmo", request.user, ds.dataset_management_object) + parent = None - # DELETE + # DELETE: delete not the whole Comment object, but mark the text as "deleted" if request.method == "DELETE": co_uuid = request.DELETE["objectid"] comment = Comment.objects.get(uuid=co_uuid) check_perms_403("delete_comment", request.user, comment) - comment.delete() + comment.set_delete() + comment.save() + if view_type == "public": return dataset_comments(request, ds.uuid, "public") else: return dataset_comments(request, ds.uuid, "prep") if request.method == "POST": - if co_uuid: + if co_uuid and not reply: # Edit comment = Comment.objects.get(uuid=co_uuid) check_perms_403("edit_comment", request.user, comment) @@ -170,7 +200,7 @@ def dataset_edit_comment(request, ds_uuid, view_type, co_uuid): else: err = form.errors.as_data() else: - # Add + # Add new and reply comment = Comment() form = CommentForm(request.POST, instance=comment) if form.is_valid(): @@ -182,24 +212,35 @@ def dataset_edit_comment(request, ds_uuid, view_type, co_uuid): comment.comment_type = comment.PUBLIC action.send( request.user, - verb="posted comment", + verb="commented dataset", target=ds, action_object=comment, + public=True, ) else: comment.comment_type = comment.PRIVATE + action.send( + request.user, + verb="commented dataset", + target=ds, + action_object=comment, + public=False, + ) comment = form.save() + if reply: + comment.parent = Comment.objects.get(uuid=co_uuid) + comment.save() return render( request, "ddcomments/_comment_block_load.html", - {"object_uuid": ds.uuid, "add_url": add_url, "list_url": list_url}, + {"ds": ds, "add_url": add_url, "list_url": list_url}, ) else: err = form.errors.as_data() else: # Get - if co_uuid: + if co_uuid and not reply: # Edit form comment = Comment.objects.get(uuid=co_uuid) check_perms_403("edit_comment", request.user, comment) @@ -209,32 +250,45 @@ def dataset_edit_comment(request, ds_uuid, view_type, co_uuid): request, "ddcomments/_comment_add.html", { - "dsuuid": ds.uuid, + "ds": ds, "comment": comment, "comment_form": form, "err": err, "edit": True, "add_url": add_url, "list_url": list_url, + "reply_url": reply_url, + "form": form, + "ictarget": "#comments-list", }, ) response["X-IC-CancelPolling"] = "true" return response + elif reply: + # Reply form + parent = Comment.objects.get(uuid=co_uuid) + form = CommentForm() else: # Add form - comment = None form = CommentForm() # updated = False # form_err = None - return render( + response = render( request, "ddcomments/_comment_add.html", { - "dsuuid": ds.uuid, - "comment": comment, + "ds": ds, + "parent": parent, "comment_form": form, "err": err, "add_url": add_url, "list_url": list_url, + "reply_url": reply_url, + "form": form, + "reply": reply, + "ictarget": "#comment-block", }, ) + if reply: + response["X-IC-CancelPolling"] = "true" + return response diff --git a/discuss_data/dddatasets/views.py b/discuss_data/dddatasets/views.py index fd31f7dc2a355c0772f17c674370b78997410d66..b95f7c0afb8acbfa9d00fbe78e65ca7163b267ce 100644 --- a/discuss_data/dddatasets/views.py +++ b/discuss_data/dddatasets/views.py @@ -5,18 +5,28 @@ from django.http import ( Http404, HttpResponse, FileResponse, + HttpResponseForbidden, ) from django.shortcuts import render from django.utils.translation import gettext as _ from django.contrib.auth.decorators import login_required from django.shortcuts import redirect +from django.contrib import messages +from django.contrib.contenttypes.models import ContentType +from actstream import action from actstream.actions import ( follow, unfollow, ) from actstream.models import followers +from discuss_data.core.views import ( + check_perms, + check_perms_403, + get_help_texts, +) + from discuss_data.dddatasets.models import ( DataSet, DataSetManagementObject, @@ -32,6 +42,12 @@ from discuss_data.dddatasets.utils import ( from discuss_data.core.views import core_search_view +from discuss_data.ddcomments.models import Notification +from discuss_data.ddcomments.forms import ( + NotificationForm, + AccessRequestForm, +) + @login_required def dataset_search(request): @@ -62,6 +78,7 @@ def dataset_search(request): # ) +@login_required def public_dataset_comments(request, ds_uuid): """ calls dataset_comments for "public" comments @@ -70,6 +87,7 @@ def public_dataset_comments(request, ds_uuid): return response +@login_required def public_dataset_edit_comment(request, ds_uuid, co_uuid=None): """ calls dataset_edit_comment for a "public" comment @@ -78,6 +96,15 @@ def public_dataset_edit_comment(request, ds_uuid, co_uuid=None): return response +@login_required +def public_dataset_edit_comment_reply(request, ds_uuid, co_uuid): + """ + calls dataset_edit_comment_reply for a "public" comment + """ + response = dataset_edit_comment(request, ds_uuid, "public", co_uuid, reply=True) + return response + + @login_required def datafile_download(request, df_uuid): """ @@ -121,11 +148,76 @@ def dataset_metadata(request, uuid): ) +@login_required +def dataset_access(request, uuid): + ds = check_published(uuid) + return render( + request, + "dddatasets/access.html", + {"ds": ds, "updated": True, "type": "access"}, + ) + + +@login_required +def dataset_access_request(request, uuid): + ds = check_published(uuid) + # check restricted access view permissions for the dataset + if check_perms("ra_view_dataset", request.user, ds): + # user has already access + return HttpResponse( + ''.format( + _("You have access to this Dataset") + ) + ) + + if request.method == "POST": + form = AccessRequestForm(request.POST) + if form.is_valid(): + comment = form.save(commit=False) + comment.owner = request.user + comment.notification_type = Notification.ACCESS_REQUEST + comment.content_type = ContentType.objects.get_for_model(ds) + comment.object_id = ds.id + comment.save() + + return HttpResponse( + ''.format( + _("Access request sent to the Datasets administrators") + ) + ) + else: + messages.warning(request, form.errors) + else: + initial = {"text": "Please grant me access to this Dataset"} + form = AccessRequestForm(initial=initial) + return render( + request, + "ddcomments/_notification_add.html", + { + "form": form, + "add_url": "dddatasets:access_request", + "object": ds, + "btn_text": "Request Access", + "access_request": True, + }, + ) + + @login_required def dataset_files(request, uuid): ds = check_published(uuid) + datafiles = list() + for datafile in ds.get_datafiles(): + # check restricted access view permissions for the dataset + if check_perms("ra_view_dataset", request.user, ds): + datafiles.append(datafile) + else: + datafiles.append("RA") + return render( - request, "dddatasets/files.html", {"ds": ds, "updated": True, "type": "files"}, + request, + "dddatasets/files.html", + {"ds": ds, "updated": True, "type": "files", "datafiles": datafiles}, ) @@ -146,13 +238,14 @@ def dataset_files_pdf(request, uuid): @login_required def dataset_files_zip(request, uuid): - # TODO: no rights management whatsoever implemented yet! ds = DataSet.objects.get(uuid=uuid) in_memory = BytesIO() zipfile = ZipFile(in_memory, "a") zipfile.writestr(ds.get_pdf_file_name(), create_pdf(request, ds)) for file in ds.get_datafiles(): - zipfile.writestr(file.name, file.file.read()) + # check restricted access view permissions for the dataset + if check_perms("ra_view_dataset", request.user, ds): + zipfile.writestr(file.name, file.file.read()) # fix for Linux zip files read in Windows for file in zipfile.filelist: file.create_system = 0 @@ -170,11 +263,17 @@ def dataset_files_zip(request, uuid): @login_required def dataset_files_download(request, uuid, df_uuid): - # TODO: no rights management whatsoever implemented yet! - datafile = DataFile.objects.get(uuid=df_uuid).file - response = FileResponse(datafile) - response["Content-Disposition"] = "attachment; filename=%s" % (datafile,) - return response + datafile = DataFile.objects.get(uuid=df_uuid, dataset__uuid=uuid) + download_name = datafile.get_download_file_name() + # check restricted access view permissions for the dataset + if check_perms("ra_view_dataset", request.user, datafile.dataset): + response = FileResponse(datafile.file) + response["Content-Disposition"] = "attachment; filename={}".format( + download_name, + ) + return response + else: + return HttpResponseForbidden() @login_required diff --git a/discuss_data/dddatasets/views_prep.py b/discuss_data/dddatasets/views_prep.py index dcf738bb758f4cf1ad88dcc8892309057be7fc62..319d57dabf2fa26d54652b3383bf5046a090b660 100644 --- a/discuss_data/dddatasets/views_prep.py +++ b/discuss_data/dddatasets/views_prep.py @@ -77,7 +77,10 @@ from discuss_data.dddatasets.utils import ( from discuss_data.ddpublications.forms import PublicationForm from discuss_data.ddpublications.models import Publication from discuss_data.ddusers.models import Country, User -from discuss_data.ddusers.views import user_search +from discuss_data.ddusers.views import ( + user_search, + user_notifications, +) from discuss_data.pages.models import LicensePage @@ -138,6 +141,7 @@ def prep_dataset_add(request): ds.owner = request.user ds.save() form.save_m2m() + return redirect("dddatasets:prep_edit", ds_uuid=ds.uuid) else: None @@ -452,7 +456,7 @@ def prep_dataset_edit_metadata(request, ds_uuid): @login_required def prep_dataset_edit_collaboration(request, ds_uuid): """ - Prep area tab to edit access + Prep area tab to edit collaborators """ ds = DataSet.objects.get(uuid=ds_uuid) dsmo = ds.dataset_management_object @@ -503,7 +507,7 @@ def prep_dataset_edit_access_user_search(request, ds_uuid): return render( request, - "ddusers/search_results.html", + "ddusers/_search_results.html", {"object_list": qs, "ds": ds, "search_type": search_type}, ) @@ -518,17 +522,36 @@ def prep_dataset_edit_access_user(request, ds_uuid): userid = request.POST["userid"] user = User.objects.get(uuid=userid) perm_code = request.POST["perm"] + feed = request.POST.get("requestsrc") if perm_code == "clr": - print("clearing perms for", user) + logger.debug("clearing perms for", user) ds.clear_user_permissions(user) + ds.save() + action.send( + request.user, + verb="revoked access", + action_object=ds, + target=user, + public=False, + ) elif perm_code == "acc": - print("assigning perms for", user) + logger.debug("assigning perms for", user) ds.assign_user_permissions(user, "ra_view_dataset") + action.send( + request.user, + verb="granted access", + action_object=ds, + target=user, + public=False, + ) + ds.save() else: logger.error("requested non existing perm_code", perm_code, exc_info=1) - - return prep_dataset_edit_publish(request, ds_uuid) + if feed: + return user_notifications(request) + else: + return prep_dataset_edit_publish_access(request, ds_uuid) @login_required @@ -541,7 +564,7 @@ def prep_dataset_edit_collaboration_user_search(request, ds_uuid): return render( request, - "ddusers/search_results.html", + "ddusers/_search_results.html", {"object_list": qs, "ds": ds, "search_type": search_type}, ) @@ -591,6 +614,15 @@ def prep_dataset_edit_comment(request, ds_uuid, co_uuid=None): return response +@login_required +def prep_dataset_edit_comment_reply(request, ds_uuid, co_uuid): + """ + calls dataset_edit_comment_reply for a "prep" comment + """ + response = dataset_edit_comment(request, ds_uuid, "prep", co_uuid, reply=True) + return response + + @login_required def prep_dataset_edit_datafiles(request, ds_uuid): """ @@ -648,14 +680,12 @@ def prep_dataset_edit_datafile(request, ds_uuid, df_uuid=None): if request.method == "POST": if df_uuid: # Edit - print("Edit DF") form = DataFileUploadForm(request.POST, request.FILES, instance=datafile) if form.is_valid(): form, form_err, updated = edit_datafile(request, datafile, form) return prep_dataset_edit_datafiles(request, ds_uuid) else: # Add - print("Add DF") form = DataFileUploadForm(request.POST, request.FILES) form, form_err, updated = edit_datafile(request, datafile, form) return prep_dataset_edit_datafiles(request, ds_uuid) @@ -663,10 +693,8 @@ def prep_dataset_edit_datafile(request, ds_uuid, df_uuid=None): # Get if df_uuid: # Edit - print("GET: Edit DF") form = DataFileUploadForm(instance=datafile) else: - print("GET: Add DF") form = DataFileUploadForm() updated = False form_err = None @@ -708,7 +736,6 @@ def prep_dataset_edit_publication(request, ds_uuid, ds_pub_uuid=None): if request.method == "POST": if ds_pub_uuid: - print("Edit ds_pub") # Edit form = PublicationForm( request.POST, instance=publication, prefix="publication" @@ -741,13 +768,11 @@ def prep_dataset_edit_publication(request, ds_uuid, ds_pub_uuid=None): form_err = form.errors.as_data() else: # Get - print("GET EDIT") if ds_pub_uuid: # Edit form = PublicationForm(instance=publication, prefix="publication") sub_form = DataSetPublicationForm(instance=ds_pub, prefix="ds_pub") else: - print("GET ADD") form = PublicationForm(prefix="publication") sub_form = DataSetPublicationForm(prefix="ds_pub") updated = False diff --git a/discuss_data/ddusers/models.py b/discuss_data/ddusers/models.py index d4d9d2160207f31b7204e1d728ac9d3871c59bda..659ff9b427a92977a022854c355e2458cbd66b34 100644 --- a/discuss_data/ddusers/models.py +++ b/discuss_data/ddusers/models.py @@ -13,6 +13,9 @@ from actstream.models import ( followers, user_stream, ) + +from guardian.shortcuts import get_objects_for_user + from django_bleach.models import BleachField from taggit.managers import TaggableManager @@ -142,9 +145,9 @@ class User(AbstractUser): def get_affiliations(self): return self.affiliation_user.all() - # def get_datasets(self): - # datasets = get_objects_for_user(self, 'view_dataset') - # return datasets #DataSet.objects.filter(user=self).order_by('title') + def get_published_admin_datasets(self): + model = apps.get_model("dddatasets", "dataset") + return get_objects_for_user(self, "admin_dsmo", model).filter(published=True) def get_published_datasets(self): # use get model to avoid circular import diff --git a/discuss_data/ddusers/urls.py b/discuss_data/ddusers/urls.py index ff5a48867a4b667f8eae041742ccbe05656e2115..110464c2abe2272ec509351d2640db972a2c1257 100644 --- a/discuss_data/ddusers/urls.py +++ b/discuss_data/ddusers/urls.py @@ -6,28 +6,25 @@ from discuss_data.ddusers import views app_name = "ddusers" -# public URLs + urlpatterns = [ - path("public/search/", views.search, name="search"), - path("public/list/", views.user_list, name="list"), - path("public//", views.user_detail, name="detail"), + # public URLs + path("search/", views.search, name="search"), + path("list/", views.user_list, name="list"), + path("/", views.user_detail, name="detail"), path("follow//", views.user_follow, name="follow"), path("follow/", views.user_follow_status, name="follow_status"), - path("follow/userfeed/", views.user_act_feed, name="user_feed"), - path("follow/datasetfeed/", views.dataset_act_feed, name="dataset_feed"), + path("feed/users/", views.user_act_feed, name="user_feed"), + path("feed/datasets/", views.dataset_act_feed, name="dataset_feed"), + path("feed/notifications", views.notification_act_feed, name="notification_feed"), # path("follow/otherfeed/", views.other_feed, name="other_feed"), - path( - "public//affiliations/", - views.user_affiliations, - name="affiliations", - ), - path("public//projects/", views.user_projects, name="projects"), - path( - "public//publications/", - views.user_publications, - name="publications", - ), - path("public//datasets/", views.user_datasets, name="datasets"), + path("/affiliations/", views.user_affiliations, name="affiliations",), + path("/projects/", views.user_projects, name="projects"), + path("/publications/", views.user_publications, name="publications",), + path("/datasets/", views.user_datasets, name="datasets"), + path("notifications/", views.user_notifications, name="notifications"), + path("notifications/add/", views.user_notification, name="notification"), + # edit urls # users search for dataset access control path("edit/search/", views.user_search, name="user_search"), # user profile: edit pages diff --git a/discuss_data/ddusers/views.py b/discuss_data/ddusers/views.py index b59174fc3a8522461bd87c64975a7a4752ac6906..8fead868869893f67fb5e4e787afde1acc732c6c 100644 --- a/discuss_data/ddusers/views.py +++ b/discuss_data/ddusers/views.py @@ -6,7 +6,11 @@ from django.http import Http404 from django.shortcuts import render, redirect from django.utils.translation import gettext as _ from django.contrib.auth.decorators import login_required +from django.apps import apps +from django.contrib import messages +from django.shortcuts import get_object_or_404 +from actstream import action from actstream.actions import ( follow, unfollow, @@ -16,6 +20,8 @@ from actstream.models import ( Follow, followers, user_stream, + Action, + model_stream, ) from discuss_data.core.models import KeywordTagged @@ -40,6 +46,9 @@ from discuss_data.ddusers.models import ( User, ) +from discuss_data.ddcomments.models import Notification # , Notification +from discuss_data.ddcomments.forms import NotificationForm + logger = logging.getLogger(__name__) @@ -89,16 +98,16 @@ def user_list(request, page=1): def format_act_feed(request, feed, object_list=None): action_list = list() - for action in feed: - if action.target.class_name() == "User": - action_list.append(action) - if action.target.class_name() == "DataSet" and action.target.published: - action_list.append(action) + for act in feed: + if act.target.class_name() == "User": + action_list.append(act) + if act.target.class_name() == "DataSet" and act.target.published: + action_list.append(act) if ( - action.target.class_name() == "DataSetManagementObject" - and action.target.published + act.target.class_name() == "DataSetManagementObject" + and act.target.published ): - action_list.append(action) + action_list.append(act) paginator = Paginator(action_list, 5) page = request.GET.get("page") feed_actions = paginator.get_page(page) @@ -126,23 +135,103 @@ def following_act_feed(request): raise Http404("Page not found.") +@login_required +def notification_act_feed(request): + """ + Deprecated, to be replaced by user_notifications() + """ + # list of ids of datasets where user has admin rights + user_admin_datasets = list( + request.user.get_published_admin_datasets().values_list("id", flat=True) + ) + + # queries for actions with notification as action object and one of the users datasets as target + actions_ar = Action.objects.access_requests_received(user_admin_datasets) + + notification_content_type = ContentType.objects.get( + app_label="ddcomments", model="comment" + ) + actions_notifications = Action.objects.any_everything(request.user).filter( + action_object_content_type=notification_content_type.id, + ) + + actions = actions_ar | actions_notifications + feed_actions, paginator_range = format_act_feed(request, actions) + feed_type = "notification-act-feed" + + return render( + request, + "ddusers/dashboard.html", + { + "feed_actions": feed_actions, + "paginator_range": paginator_range, + "feed_type": feed_type, + }, + ) + + +@login_required +def user_notifications(request): + user_notifications_to = Notification.objects.root_nodes().filter( + recipient=request.user + ) + user_notifications_from = Notification.objects.root_nodes().filter( + owner=request.user + ) + + user_admin_datasets = list( + request.user.get_published_admin_datasets().values_list("id", flat=True) + ) + dataset_content_type = ContentType.objects.get( + app_label="dddatasets", model="dataset" + ) + datasets_notifications = Notification.objects.root_nodes().filter( + content_type=dataset_content_type.id, object_id__in=user_admin_datasets + ) + + notifications = ( + user_notifications_to | user_notifications_from | datasets_notifications + ) + + feed_type = "notifications" + + paginator = Paginator(notifications.order_by("-date_added"), 5) + page = request.GET.get("page") + feed_actions = paginator.get_page(page) + paginator_range = range(1, paginator.num_pages + 1) + + form = NotificationForm() + + return render( + request, + "ddusers/dashboard.html", + { + "feed_actions": feed_actions, + "paginator_range": paginator_range, + "feed_type": feed_type, + "form": form, + "add_url": "ddusers:notification", + "btn_text": "Reply", + }, + ) + + @login_required def user_act_feed(request): - if request.user.is_authenticated: - feed_actions, paginator_range = format_act_feed( - request, actor_stream(request.user) - ) - feed_type = "user-act-feed" + actions = Action.objects.any_everything(request.user) - return render( - request, - "ddusers/dashboard.html", - { - "feed_actions": feed_actions, - "paginator_range": paginator_range, - "feed_type": feed_type, - }, - ) + feed_actions, paginator_range = format_act_feed(request, actions) + + feed_type = "user-act-feed" + return render( + request, + "ddusers/dashboard.html", + { + "feed_actions": feed_actions, + "paginator_range": paginator_range, + "feed_type": feed_type, + }, + ) @login_required @@ -191,11 +280,63 @@ def user_follow_status(request): @login_required def user_detail(request, us_uuid): - try: - user = User.objects.get(uuid=us_uuid) - except Exception: - raise Http404("Page not found.") - return render(request, "ddusers/detail.html", {"user": user}) + user = get_object_or_404(User, uuid=us_uuid) + initial = { + "recipient_uuid": user.uuid, + "parent_uuid": None, + } + + return render( + request, + "ddusers/detail.html", + { + "form": NotificationForm(initial=initial), + "add_url": "ddusers:notification", + "object": user, + "ictarget": "#ds-content", + "btn_text": _("Send message"), + "user": user, + }, + ) + + +@login_required +def user_notification(request): + if request.method == "POST": + form = NotificationForm(request.POST) + if form.is_valid(): + comment = form.save(commit=False) + comment.owner = request.user + recipient_uuid = form.cleaned_data.get("recipient_uuid") + user = User.objects.get(uuid=recipient_uuid) + comment.recipient = user + parent_uuid = form.cleaned_data.get("parent_uuid") + if parent_uuid: + comment.parent = Notification.objects.get(uuid=parent_uuid) + comment.notification_type = Notification.PRIVATE + comment.content_type = ContentType.objects.get_for_model(user) + comment.object_id = user.id + comment.save() + + # # send an action + # action.send( + # request.user, + # verb=_("sent message to"), + # target=user, + # action_object=comment, + # public=False, + # ) + messages.success(request, _("Message sent user_notification()")) + return user_notifications(request) + else: + messages.warning(request, form.errors) + else: + form = NotificationForm() + return render( + request, + "ddcomments/_notification_add.html", + {"form": form, "add_url": "ddusers:notification", "ictarget": "#ds-content"}, + ) @login_required diff --git a/discuss_data/static/js/project.js b/discuss_data/static/js/project.js index 4d65684028ce4431fa8105154dfc89200775d3a0..bb61950deab599a34d4f05bec156c3f90eca998f 100644 --- a/discuss_data/static/js/project.js +++ b/discuss_data/static/js/project.js @@ -91,6 +91,14 @@ function docucardActive(element) { } }; +/* + * Remove modal manually in cases where an intercooler request conflicts + * with bootstraps data-dismiss="modal". + * Called like this: ic-on-beforeSend="removeModal()" + */ +function removeModal() { + $(".modal").modal('hide'); +}; /* * disable buttons prior to submit and show spinner @@ -114,4 +122,5 @@ $(document).on('beforeSend.ic', function (event, el, data) { el.addClass('form-submitted'); } } -}); \ No newline at end of file +}); + diff --git a/discuss_data/static/sass/project.scss b/discuss_data/static/sass/project.scss index 726a4d941e4e71fa134a0d979e00b24872e9439f..526680960d5a74564f4b5515196af299d200415b 100644 --- a/discuss_data/static/sass/project.scss +++ b/discuss_data/static/sass/project.scss @@ -289,6 +289,27 @@ body { border: 1px solid theme-color("border-gray"); } +.border-restricted { + border-color: theme-color("danger"); +} + +.fileicon { + font-size: xx-large; + color: theme-color("primary"); +} + +/* + * Notifications + */ + +.notification textarea.form-control { + height: 5rem; +} + +.notification .form-control { + width: 80%; +} + /* * Sidebar */ @@ -345,6 +366,10 @@ body { * Comments */ +.comments-head { + margin-top: 1rem; +} + .comment-name { font-weight: 700; } @@ -364,6 +389,18 @@ body { /* * Comment badges */ +.badge-deleted { + background-color: theme-color("dark"); + color: theme-color("light"); + font-weight: 500; +} + +.badge-access-request { + background-color: theme-color("tertiary"); + color: theme-color("light"); + font-weight: 500; +} + .badge-public { background-color: theme-color("success"); diff --git a/discuss_data/templates/actstream/action.html b/discuss_data/templates/actstream/action.html index e0e18e1b6a7b6649e986600bbe52aa0964651985..1844b045a47ecd33753cc2e9f0ef34b27d27d5c5 100644 --- a/discuss_data/templates/actstream/action.html +++ b/discuss_data/templates/actstream/action.html @@ -3,12 +3,19 @@ {% if action.actor.get_absolute_url %}{{ action.actor }} {% else %}{{ action.actor }}{% endif %} -{% if action.verb %}{{ action.verb }}{% endif %} +{% if action.verb %} + {{ action.verb }} +{% endif %} {% if action.action_object %} + {% if action.action_object.class_name == 'Notification' %} + {% else %} {{ action.action_object }} + {% endif %} +{% endif %} +{% if action.action_object and action.target %} + {% if action.target.class_name == 'DataSet' %}for Dataset{% else %}to{% endif %} {% endif %} -{% if action.action_object and action.target %}to{% endif %} {% if action.target %} {{ action.target }} {% endif %} diff --git a/discuss_data/templates/base.html b/discuss_data/templates/base.html index 144c441f2b114433fc3ca037d27460521a1440b1..c2a9167789a652d1d0f28445143800633f6c35c5 100644 --- a/discuss_data/templates/base.html +++ b/discuss_data/templates/base.html @@ -53,12 +53,6 @@ {# sidebar nav #} {% include 'nav-sidebar.html' with editmode=editmode %} - {% if messages %} - {% for message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} -
{% block content %} {% block ic-content %} diff --git a/discuss_data/templates/core/_confirm_modal.html b/discuss_data/templates/core/_confirm_modal.html index 9c9afcbe452003d7fc0bc51a3ca7c35dc44546bf..2bbcfe7a45aba1fe55f3cf8eb5075b31e0547ac7 100644 --- a/discuss_data/templates/core/_confirm_modal.html +++ b/discuss_data/templates/core/_confirm_modal.html @@ -2,7 +2,7 @@ -