From c62513292d82c100b240debcca87e36fb39a2373 Mon Sep 17 00:00:00 2001 From: sumandari Date: Mon, 20 Nov 2023 09:18:52 +0100 Subject: [PATCH] Add Feature [276] Plugins feedback (multiple tasks) (#284) * added PluginVersionFeedback model * added version_feedback_notify * added plugin feedback received and feedback pending view and url * updated plugin base and detail html template * added create feedback view * notified user when create feedback * added version_feedback_update * updated template and ajax requests * never cache feedback * use submit button for feedback update * updated layout --- REQUIREMENTS-dev.txt | 1 + qgis-app/plugins/forms.py | 37 +- .../migrations/0002_plugins_feedback.py | 31 ++ qgis-app/plugins/models.py | 81 ++++ .../templates/plugins/plugin_base.html | 4 + .../templates/plugins/plugin_detail.html | 3 + .../templates/plugins/plugin_feedback.html | 170 ++++++++ qgis-app/plugins/tests/test_models.py | 129 ++++++ .../tests/test_plugin_version_feedback.py | 389 ++++++++++++++++++ qgis-app/plugins/urls.py | 32 ++ qgis-app/plugins/views.py | 162 +++++++- 11 files changed, 1037 insertions(+), 2 deletions(-) create mode 100644 qgis-app/plugins/migrations/0002_plugins_feedback.py create mode 100644 qgis-app/plugins/templates/plugins/plugin_feedback.html create mode 100644 qgis-app/plugins/tests/test_models.py create mode 100644 qgis-app/plugins/tests/test_plugin_version_feedback.py diff --git a/REQUIREMENTS-dev.txt b/REQUIREMENTS-dev.txt index de63c055..8e4d5297 100644 --- a/REQUIREMENTS-dev.txt +++ b/REQUIREMENTS-dev.txt @@ -1,2 +1,3 @@ flake8 pre-commit +freezegun diff --git a/qgis-app/plugins/forms.py b/qgis-app/plugins/forms.py index 5c10e204..99dc804a 100644 --- a/qgis-app/plugins/forms.py +++ b/qgis-app/plugins/forms.py @@ -6,7 +6,7 @@ from django.forms import CharField, ModelForm, ValidationError from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ -from plugins.models import Plugin, PluginVersion +from plugins.models import Plugin, PluginVersion, PluginVersionFeedback from plugins.validator import validator from taggit.forms import * @@ -208,3 +208,38 @@ def clean_package(self): # Clean tags self.cleaned_data["tags"] = _clean_tags(self.cleaned_data.get("tags", None)) return package + + +class VersionFeedbackForm(forms.Form): + """Feedback for a plugin version""" + + feedback = forms.CharField( + widget=forms.Textarea( + attrs={ + "placeholder": _( + "Please provide clear feedback as a task. \n" + "You can create multiple tasks with '- [ ]'.\n" + "e.g:\n" + "- [ ] first task\n" + "- [ ] second task" + ), + "rows": "5", + "class": "span12" + } + ) + ) + + def clean(self): + super().clean() + feedback = self.cleaned_data.get('feedback') + + if feedback: + lines: list = feedback.split('\n') + bullet_points: list = [ + line[6:].strip() for line in lines if line.strip().startswith('- [ ]') + ] + has_bullet_point = len(bullet_points) >= 1 + tasks: list = bullet_points if has_bullet_point else [feedback] + self.cleaned_data['tasks'] = tasks + + return self.cleaned_data diff --git a/qgis-app/plugins/migrations/0002_plugins_feedback.py b/qgis-app/plugins/migrations/0002_plugins_feedback.py new file mode 100644 index 00000000..76f760a3 --- /dev/null +++ b/qgis-app/plugins/migrations/0002_plugins_feedback.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.25 on 2023-06-17 03:19 + +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), + ('plugins', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PluginVersionFeedback', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('task', models.TextField(help_text='A feedback task. Please write your review as a task for this plugin.', max_length=1000, verbose_name='Task')), + ('created_on', models.DateTimeField(auto_now_add=True, verbose_name='Created on')), + ('completed_on', models.DateTimeField(blank=True, null=True, verbose_name='Completed on')), + ('is_completed', models.BooleanField(db_index=True, default=False, verbose_name='Completed')), + ('reviewer', models.ForeignKey(help_text='The user who reviewed this plugin.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Reviewed by')), + ('version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='plugins.PluginVersion')), + ], + options={ + 'ordering': ['created_on'], + }, + ), + ] diff --git a/qgis-app/plugins/models.py b/qgis-app/plugins/models.py index 27c963bb..7deee802 100644 --- a/qgis-app/plugins/models.py +++ b/qgis-app/plugins/models.py @@ -273,6 +273,37 @@ def get_queryset(self): return super(ServerPlugins, self).get_queryset().filter(server=True).distinct() +class FeedbackReceivedPlugins(models.Manager): + """ + Show only unapproved plugins with a feedback + """ + def get_queryset(self): + return ( + super(FeedbackReceivedPlugins, self) + .get_queryset() + .filter( + pluginversion__approved=False, + pluginversion__feedback__isnull=False + ).distinct() + ) + + +class FeedbackPendingPlugins(models.Manager): + """ + Show only unapproved plugins with a feedback + """ + def get_queryset(self): + return ( + super(FeedbackPendingPlugins, self) + .get_queryset() + .filter( + pluginversion__approved=False, + pluginversion__feedback__isnull=True + ).distinct() + ) + + + class Plugin(models.Model): """ Plugins model @@ -352,6 +383,8 @@ class Plugin(models.Model): most_voted_objects = MostVotedPlugins() most_rated_objects = MostRatedPlugins() server_objects = ServerPlugins() + feedback_received_objects = FeedbackReceivedPlugins() + feedback_pending_objects = FeedbackPendingPlugins() rating = AnonymousRatingField( range=5, use_cookies=True, can_change_vote=True, allow_delete=True @@ -769,6 +802,54 @@ def __str__(self): return self.__unicode__() +class PluginVersionFeedback(models.Model): + """Feedback for a plugin version.""" + + version = models.ForeignKey( + PluginVersion, + on_delete=models.CASCADE, + related_name="feedback" + ) + reviewer = models.ForeignKey( + User, + verbose_name=_("Reviewed by"), + help_text=_("The user who reviewed this plugin."), + on_delete=models.CASCADE, + ) + task = models.TextField( + verbose_name=_("Task"), + help_text=_("A feedback task. Please write your review as a task for this plugin."), + max_length=1000, + blank=False, + null=False + ) + created_on = models.DateTimeField( + verbose_name=_("Created on"), + auto_now_add=True, + editable=False + ) + completed_on = models.DateTimeField( + verbose_name=_("Completed on"), + blank=True, + null=True + ) + is_completed = models.BooleanField( + verbose_name=_("Completed"), + default=False, + db_index=True + ) + + class Meta: + ordering = ["created_on"] + + def save(self, *args, **kwargs): + if self.is_completed is True: + self.completed_on = datetime.datetime.now() + else: + self.completed_on = None + super(PluginVersionFeedback, self).save(*args, **kwargs) + + def delete_version_package(sender, instance, **kw): """ Removes the zip package diff --git a/qgis-app/plugins/templates/plugins/plugin_base.html b/qgis-app/plugins/templates/plugins/plugin_base.html index 5faa9698..ffc44a31 100644 --- a/qgis-app/plugins/templates/plugins/plugin_base.html +++ b/qgis-app/plugins/templates/plugins/plugin_base.html @@ -22,6 +22,10 @@

{% trans "Plugins" %}

{% endif %} {% if user.is_staff %}
  • {% trans "Unapproved"%}
  • +
  • {% trans "Deprecated"%}
  • {% endif %}
  • {% trans "Featured "%}
  • diff --git a/qgis-app/plugins/templates/plugins/plugin_detail.html b/qgis-app/plugins/templates/plugins/plugin_detail.html index 192026f9..70fccfa5 100644 --- a/qgis-app/plugins/templates/plugins/plugin_detail.html +++ b/qgis-app/plugins/templates/plugins/plugin_detail.html @@ -201,6 +201,9 @@

    {{ object.name }} {% if user.is_staff or user in version.plugin.approvers %} {% if not version.approved %}{% else %}{% endif %} {% endif %} + + {% if user.is_staff or user in version.plugin.editors %}  {% endif %} diff --git a/qgis-app/plugins/templates/plugins/plugin_feedback.html b/qgis-app/plugins/templates/plugins/plugin_feedback.html new file mode 100644 index 00000000..79b64f94 --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_feedback.html @@ -0,0 +1,170 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% block content %} + {% if form.errors %} +
    + +

    {% trans "The form contains errors and cannot be submitted, please check the fields highlighted in red." %}

    +
    + {% endif %} + {% if form.non_field_errors %} +
    + + {% for error in form.non_field_errors %} +

    {{ error }}

    + {% endfor %} +
    + {% endif %} +

    {% trans "Feedback Plugin" %} {{ version.plugin.name }} {{ version.version }}

    +
    +

    Please tick the checkbox when the task is completed and click the "Update" button to update status.

    + {% for feedback in feedbacks %} +
    + + + {% if feedback.reviewer == request.user %} + + {% endif %} +
    + {% endfor %} + {% if feedbacks %} +
    + +
    + {% endif %} + + {% if is_user_has_approval_rights %} +
    +
    {% csrf_token %} + New Feedback + {{ form.feedback }} +
    + +
    +
    +
    + {% endif %} +
    + +{% endblock %} + +{% block extrajs %} + + + +{% endblock %} \ No newline at end of file diff --git a/qgis-app/plugins/tests/test_models.py b/qgis-app/plugins/tests/test_models.py new file mode 100644 index 00000000..4c0a26c6 --- /dev/null +++ b/qgis-app/plugins/tests/test_models.py @@ -0,0 +1,129 @@ +from datetime import datetime + +from freezegun import freeze_time + +from django.contrib.auth.models import User +from django.test import TestCase +from plugins.models import Plugin, PluginVersion, PluginVersionFeedback + + +class PluginVersionFeedbackTest(TestCase): + fixtures = ["fixtures/auth.json", ] + + def setUp(self): + self.creator = User.objects.get(id=2) + self.admin = User.objects.get(id=1) + self.staff = User.objects.get(id=3) + self.plugin = Plugin.objects.create( + created_by=self.creator, + repository="http://example.com", + tracker="http://example.com", + package_name="test-feedback", + name="test feedback", + about="this is a test for plugin feedbacks" + ) + self.version = PluginVersion.objects.create( + plugin=self.plugin, + created_by=self.creator, + min_qg_version="0.0.0", + max_qg_version="99.99.99", + version="0.2", + approved=False, + external_deps="test" + ) + + def test_create_feedback_success(self): + feedback = PluginVersionFeedback.objects.create( + version=self.version, + reviewer=self.staff, + task="test comment in a feedback." + ) + self.assertIsNotNone(feedback.created_on) + self.assertFalse(feedback.is_completed) + self.assertIsNone(feedback.completed_on) + + @freeze_time("2023-06-30 10:00:00") + def test_update_feedback_is_completed(self): + feedback = PluginVersionFeedback.objects.create( + version=self.version, + reviewer=self.staff, + task="test comment in a feedback.", + is_completed=True + ) + self.assertEqual(feedback.completed_on, datetime(2023, 6, 30, 10, 0, 0)) + feedback.is_completed = False + feedback.save() + self.assertIsNone(feedback.completed_on) + + +class PluginVersionFeedbackManagerTest(TestCase): + fixtures = ["fixtures/auth.json", ] + + def setUp(self): + self.creator = User.objects.get(id=2) + self.staff = User.objects.get(id=3) + self.plugin_1 = Plugin.objects.create( + created_by=self.creator, + repository="http://example.com", + tracker="http://example.com", + package_name="plugin-test-1", + name="plugin test 1", + about="this is a test for plugin feedbacks" + ) + self.version_1 = PluginVersion.objects.create( + plugin=self.plugin_1, + created_by=self.creator, + min_qg_version="0.0.0", + max_qg_version="99.99.99", + version="1.0", + approved=False, + external_deps="test" + ) + self.feedback_1 = PluginVersionFeedback.objects.create( + version=self.version_1, + reviewer=self.staff, + task="test comment in a feedback." + ) + self.plugin_2 = Plugin.objects.create( + created_by=self.creator, + repository="http://example.com", + tracker="http://example.com", + package_name="plugin-test-2", + name="plugin test 2", + about="this is a test for plugin feedbacks" + ) + self.version_2 = PluginVersion.objects.create( + plugin=self.plugin_2, + created_by=self.creator, + min_qg_version="0.0.0", + max_qg_version="99.99.99", + version="2.0", + approved=False, + external_deps="test" + ) + + def test_query_plugins_objects_all(self): + plugins = Plugin.objects.all() + self.assertEqual(len(plugins), 2) + + def test_query_plugins_feedback_received_objects(self): + plugins = Plugin.feedback_received_objects.all() + self.assertEqual(len(plugins), 1) + self.assertEqual(plugins[0], self.plugin_1) + + PluginVersionFeedback.objects.create( + version=self.version_2, + reviewer=self.staff, + task="test comment in a feedback for plugin 2." + ) + plugins = Plugin.feedback_received_objects.all() + self.assertEqual(len(plugins), 2) + self.assertListEqual(list(plugins), [self.plugin_1, self.plugin_2]) + + + def test_query_plugins_feedback_pending_objects(self): + plugins = Plugin.feedback_pending_objects.all() + self.assertEqual(len(plugins), 1) + self.assertEqual(plugins[0], self.plugin_2) + + diff --git a/qgis-app/plugins/tests/test_plugin_version_feedback.py b/qgis-app/plugins/tests/test_plugin_version_feedback.py new file mode 100644 index 00000000..c2034c98 --- /dev/null +++ b/qgis-app/plugins/tests/test_plugin_version_feedback.py @@ -0,0 +1,389 @@ +import datetime + +from django.contrib.auth.models import User +from django.core import mail +from django.test import TestCase +from django.urls import reverse + +from freezegun import freeze_time + +from plugins.models import Plugin, PluginVersion, PluginVersionFeedback +from plugins.views import version_feedback_notify + + +class SetupMixin: + fixtures = ["fixtures/auth.json", "fixtures/simplemenu.json"] + + def setUp(self) -> None: + self.creator = User.objects.get(id=2) + self.staff = User.objects.get(id=3) + self.plugin_1 = Plugin.objects.create( + created_by=self.creator, + repository="http://example.com", + tracker="http://example.com", + package_name="test-feedback", + name="test plugin 1", + about="this is a test for plugin feedbacks", + author="author plugin" + ) + self.version_1 = PluginVersion.objects.create( + plugin=self.plugin_1, + created_by=self.creator, + min_qg_version="0.0.0", + max_qg_version="99.99.99", + version="0.1", + approved=False, + external_deps="test" + ) + self.feedback_1 = PluginVersionFeedback.objects.create( + version=self.version_1, + reviewer=self.staff, + task="test comment in a feedback." + ) + self.plugin_2 = Plugin.objects.create( + created_by=self.creator, + repository="http://example.com", + tracker="http://example.com", + package_name="plugin-test-2", + name="test plugin 2", + about="this is a test for plugin feedbacks", + author="author plugin 2" + ) + self.version_2 = PluginVersion.objects.create( + plugin=self.plugin_2, + created_by=self.creator, + min_qg_version="0.0.0", + max_qg_version="99.99.99", + version="2.0", + approved=False, + external_deps="test" + ) + + +class TestFeedbackNotify(SetupMixin, TestCase): + def test_version_feedback_notify_no_email(self): + self.assertFalse(self.creator.email) + with self.assertLogs(level='WARNING'): + version_feedback_notify(self.version_1, self.creator) + + def test_version_feedback_notify_sent(self): + self.creator.email = 'email@example.com' + self.creator.save() + with self.assertLogs(level='DEBUG'): + version_feedback_notify(self.version_1, self.staff) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].subject, + f"Plugin {self.plugin_1} feedback notification." + ) + self.assertIn( + ( + "\nPlugin test plugin 1 reviewed by staff and received a " + "feedback.\nLink: http://example.com/plugins/test-feedback/" + "version/0.1/feedback/\n" + ), + mail.outbox[0].body + ) + self.assertEqual( + mail.outbox[0].recipients(), + ['email@example.com'] + ) + + def test_add_recipient_in_email_notification(self): + self.creator.email = 'email@example.com' + self.creator.save() + new_recipient = User.objects.create( + username="new-recipient", + email="new@example.com" + ) + self.plugin_1.owners.add(new_recipient) + self.assertListEqual( + list(self.plugin_1.editors), + [new_recipient, self.creator] + ) + with self.assertLogs(level='DEBUG'): + version_feedback_notify(self.version_1, self.staff) + self.assertEqual( + mail.outbox[0].recipients(), + ['new@example.com', 'email@example.com'] + ) + + +class TestPluginFeedbackReceivedList(SetupMixin, TestCase): + fixtures = ["fixtures/simplemenu.json", "fixtures/auth.json"] + + def setUp(self): + super().setUp() + self.url = reverse("feedback_received_plugins") + + def test_non_staff_should_not_see_plugin_feedback_received_list(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + + self.client.force_login(user=self.creator) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + + def test_staff_should_see_plugin_feedback_received(self): + self.client.force_login(user=self.staff) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed( + response, 'plugins/plugin_list.html' + ) + self.assertEqual( + list(response.context['object_list']), + [self.plugin_1] + ) + self.assertContains(response, "test plugin 1") + self.assertNotContains(response, "test plugin 2") + + # add feedback for plugin 2 + PluginVersionFeedback.objects.create( + version=self.version_2, + reviewer=self.staff, + task="test comment in a feedback for plugin 2." + ) + response = self.client.get(self.url) + self.assertEqual( + list(response.context['object_list']), + [self.plugin_1, self.plugin_2] + ) + self.assertContains(response, "test plugin 2") + + def test_approved_plugin_should_not_show_in_feedback_received_list(self): + self.client.force_login(user=self.staff) + response = self.client.get(self.url) + self.assertEqual( + list(response.context['object_list']), + [self.plugin_1] + ) + self.version_1.approved = True + self.version_1.save() + response = self.client.get(self.url) + self.assertEqual( + list(response.context['object_list']), + [] + ) + + +class TestPluginFeedbackPendingList(SetupMixin, TestCase): + fixtures = ["fixtures/simplemenu.json", "fixtures/auth.json"] + + def setUp(self): + super().setUp() + self.url = reverse("feedback_pending_plugins") + + def test_non_staff_should_not_see_plugin_feedback_pending_list(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + + self.client.force_login(user=self.creator) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + + def test_staff_should_see_plugin_feedback_pending_list(self): + self.client.force_login(user=self.staff) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed( + response, 'plugins/plugin_list.html' + ) + self.assertEqual( + list(response.context['object_list']), + [self.plugin_2] + ) + self.assertContains(response, "test plugin 2") + self.assertNotContains(response, "test plugin 1") + + # add feedback for plugin 2 + PluginVersionFeedback.objects.create( + version=self.version_2, + reviewer=self.staff, + task="test comment in a feedback for plugin 2." + ) + response = self.client.get(self.url) + self.assertEqual( + list(response.context['object_list']), + [] + ) + + +class TestCreateVersionFeedback(SetupMixin, TestCase): + def setUp(self) -> None: + super().setUp() + self.url = reverse( + "version_feedback", + kwargs={ + "package_name": self.plugin_2.package_name, + "version": self.version_2.version + } + ) + self.new_user = User.objects.create( + username="new-user", + is_staff=False + ) + + def test_version_feedback_required_login(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + self.assertRedirects( + response, + f"/accounts/login/?next={self.url}" + ) + + def test_only_plugin_editor_and_staff_can_see_version_feedback_page(self): + self.client.force_login(self.new_user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + self.client.force_login(user=self.staff) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_only_staff_can_see_new_feedback_form(self): + self.client.force_login(user=self.creator) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, '
    ') + self.client.force_login(user=self.staff) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '
    ') + + def test_post_create_single_task_feedback(self): + self.client.force_login(self.staff) + response = self.client.post( + self.url, + data={ + "feedback": "single line feedback" + } + ) + self.assertEqual(response.status_code, 200) + feedbacks = PluginVersionFeedback.objects.filter( + version=self.version_2).all() + self.assertEqual(len(feedbacks), 1) + self.assertEqual(feedbacks[0].task, "single line feedback") + + def test_post_create_multiple_task_feedback(self): + self.client.force_login(self.staff) + response = self.client.post( + self.url, + data={ + "feedback": "- [ ] task one\n - [ ] task two" + } + ) + self.assertEqual(response.status_code, 200) + feedbacks = PluginVersionFeedback.objects.filter( + version=self.version_2).all() + self.assertEqual(len(feedbacks), 2) + self.assertEqual(feedbacks[0].task, "task one") + self.assertEqual(feedbacks[1].task, "task two") + + def test_post_create_invalid_bullet_point_1(self): + self.client.force_login(self.staff) + self.client.post( + self.url, + data={ + "feedback": "-[ ] invalid bullet point \n -[ ] invalid two" + } + ) + feedbacks = PluginVersionFeedback.objects.filter( + version=self.version_2).all() + self.assertEqual(len(feedbacks), 1) + self.assertEqual( + feedbacks[0].task, + "-[ ] invalid bullet point \n -[ ] invalid two" + ) + + def test_post_create_invalid_bullet_point_2(self): + self.client.force_login(self.staff) + self.client.post( + self.url, + data={ + "feedback": ("-[ ] invalid bullet point\n" + " - [ ] only save valid bullet point") + } + ) + feedbacks = PluginVersionFeedback.objects.filter( + version=self.version_2).all() + self.assertEqual(len(feedbacks), 1) + self.assertEqual(feedbacks[0].task, "only save valid bullet point") + + +class TestDeleteVersionFeedback(SetupMixin, TestCase): + def setUp(self) -> None: + super().setUp() + self.url = reverse( + "version_feedback_delete", + kwargs={ + "package_name": self.plugin_1.package_name, + "version": self.version_1.version, + "feedback": self.feedback_1.id + } + ) + + def test_only_the_reviewer_can_delete_a_feedback(self): + self.client.force_login(user=self.creator) + response = self.client.post( + self.url, + data={ + "status_feedback": "deleted" + } + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(self.version_1.feedback.exists()) + self.client.force_login(user=self.staff) + response = self.client.post( + self.url, + data={ + "status_feedback": "deleted" + } + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(self.version_1.feedback.exists()) + + +class TestUpdateVersionFeedback(SetupMixin, TestCase): + def setUp(self) -> None: + super().setUp() + self.url = reverse( + "version_feedback_update", + kwargs={ + "package_name": self.plugin_1.package_name, + "version": self.version_1.version + } + ) + + @freeze_time("2023-06-30 10:00:00") + def test_staff_and_editor_can_update_feedback(self): + feedbacks = self.version_1.feedback.all() + self.assertEqual(len(feedbacks), 1) + self.assertFalse(feedbacks[0].is_completed) + self.client.force_login(user=self.creator) + response = self.client.post( + self.url, + data={ + "completed_tasks": [feedbacks[0].id] + } + ) + self.assertEqual(response.status_code, 201) + feedbacks = self.version_1.feedback.all() + self.assertEqual(len(feedbacks), 1) + self.assertTrue(feedbacks[0].is_completed) + self.assertEqual( + feedbacks[0].completed_on, datetime.datetime(2023, 6, 30, 10, 0, 0)) + + def test_non_staff_and_non_editor_cannot_update_feedback(self): + feedback = self.version_1.feedback.first() + new_user = User.objects.create(username="new-user") + self.client.force_login(user=new_user) + self.client.post( + self.url, + data={ + "status_feedback": [feedback.id] + } + ) + feedback = self.version_1.feedback.first() + self.assertFalse(feedback.is_completed) + self.assertIsNone(feedback.completed_on) diff --git a/qgis-app/plugins/urls.py b/qgis-app/plugins/urls.py index 50c6c05e..52abab9a 100644 --- a/qgis-app/plugins/urls.py +++ b/qgis-app/plugins/urls.py @@ -177,6 +177,20 @@ ), name="most_rated_plugins", ), + url( + r"^feedback_pending/$", + FeedbackPendingPluginsList.as_view( + additional_context={"title": _("Feedback pending plugins")} + ), + name="feedback_pending_plugins", + ), + url( + r"^feedback_received/$", + FeedbackReceivedPluginsList.as_view( + additional_context={"title": _("Feedback received plugins")} + ), + name="feedback_received_plugins", + ), url( r"^author/(?P[^/]+)/$", AuthorPluginsList.as_view(), @@ -246,6 +260,24 @@ {}, name="version_unapprove", ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/feedback/$", + version_feedback, + {}, + name="version_feedback", + ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/feedback/update/$", + version_feedback_update, + {}, + name="version_feedback_update", + ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/feedback/(?P[0-9]+)/$", + version_feedback_delete, + {}, + name="version_feedback_delete", + ), ] # RPC diff --git a/qgis-app/plugins/views.py b/qgis-app/plugins/views.py index dc48a7a1..a8939822 100644 --- a/qgis-app/plugins/views.py +++ b/qgis-app/plugins/views.py @@ -15,13 +15,14 @@ from django.db.models import Max, Q from django.db.models.expressions import RawSQL from django.db.models.functions import Lower -from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils.timezone import now from django.utils.decorators import method_decorator from django.utils.encoding import DjangoUnicodeDecodeError from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie from django.views.decorators.http import require_POST from django.views.generic.detail import DetailView @@ -169,6 +170,41 @@ def plugin_approve_notify(plugin, msg, user): ) +def version_feedback_notify(version, user): + """ + Sends a message when a version is receiving feedback. + """ + if settings.DEBUG: + return + plugin = version.plugin + recipients = [u.email for u in plugin.editors if u.email] + if recipients: + domain = Site.objects.get_current().domain + mail_from = settings.DEFAULT_FROM_EMAIL + logging.debug( + "Sending email feedback notification for %s plugin version %s, recipients: %s" + % (plugin, version.version, recipients) + ) + send_mail_wrapper( + _("Plugin %s feedback notification.") % (plugin, ), + _("\r\nPlugin %s reviewed by %s and received a feedback.\r\nLink: http://%s%sfeedback/\r\n") + % ( + plugin.name, + user, + domain, + version.get_absolute_url(), + ), + mail_from, + recipients, + fail_silently=True, + ) + else: + logging.warning( + "No recipients found for %s plugin feedback notification" + % (plugin, ) + ) + + def user_trust_notify(user): """ Sends a message when an author is trusted or untrusted. @@ -694,6 +730,35 @@ def get_context_data(self, **kwargs): return context +class FeedbackReceivedPluginsList(PluginsList): + """List of Plugins that has feedback received in its versions. + + The plugins editor can only see their plugin feedbacks. + The staff can see all plugin feedbacks. + """ + queryset = Plugin.feedback_received_objects.all() + + def get_filtered_queryset(self, qs): + user = get_object_or_404(User, username=self.request.user) + if not user.is_staff: + raise Http404 + return qs + + +class FeedbackPendingPluginsList(PluginsList): + """List of Plugins that has feedback pending in its versions. + + Only staff can see plugin feedback list. + """ + queryset = Plugin.feedback_pending_objects.all() + + def get_filtered_queryset(self, qs): + user = get_object_or_404(User, username=self.request.user) + if not user.is_staff: + raise Http404 + return qs + + @login_required @require_POST def plugin_manage(request, package_name): @@ -1019,6 +1084,101 @@ def version_manage(request, package_name, version): return HttpResponseRedirect(reverse("plugin_detail", args=[package_name])) +@login_required +@never_cache +def version_feedback(request, package_name, version): + """ + The form will add a comment/ feedback for the package version. + """ + plugin = get_object_or_404(Plugin, package_name=package_name) + version = get_object_or_404(PluginVersion, plugin=plugin, version=version) + is_user_plugin_owner: bool = request.user in plugin.editors + is_user_has_approval_rights: bool = check_plugin_version_approval_rights( + request.user, plugin) + if not is_user_plugin_owner and not is_user_has_approval_rights: + return render( + request, + template_name="plugins/version_permission_deny.html", + context={}, + status=403 + ) + if request.method == "POST": + form = VersionFeedbackForm(request.POST) + if form.is_valid(): + tasks = form.cleaned_data['tasks'] + for task in tasks: + PluginVersionFeedback.objects.create( + version=version, + reviewer=request.user, + task=task + ) + version_feedback_notify(version, request.user) + form = VersionFeedbackForm() + feedbacks = PluginVersionFeedback.objects.filter(version=version) + return render( + request, + "plugins/plugin_feedback.html", + { + "feedbacks": feedbacks, + "form": form, + "version": version, + "is_user_has_approval_rights": is_user_has_approval_rights, + "is_user_plugin_owner": is_user_plugin_owner + } + ) + + +@login_required +@require_POST +def version_feedback_update(request, package_name, version): + plugin = get_object_or_404(Plugin, package_name=package_name) + version = get_object_or_404(PluginVersion, plugin=plugin, version=version) + has_update_permission: bool = ( + request.user in plugin.editors + or check_plugin_version_approval_rights(request.user, plugin) + ) + if not has_update_permission: + return JsonResponse({"success": False}, status=401) + completed_tasks = request.POST.getlist('completed_tasks') + for task_id in completed_tasks: + try: + task_id = int(task_id) + except ValueError: + continue + feedback = PluginVersionFeedback.objects.filter( + version=version, pk=task_id).first() + feedback.is_completed = True + feedback.save() + return JsonResponse({"success": True}, status=201) + + +@login_required +@require_POST +def version_feedback_delete(request, package_name, version, feedback): + feedback = get_object_or_404( + PluginVersionFeedback, + version__plugin__package_name=package_name, + version__version=version, + pk=feedback + ) + plugin = feedback.version.plugin + status = request.POST.get('status_feedback') + is_update_succeed: bool = False + is_user_can_update_feedback: bool = ( + request.user in plugin.editors + or check_plugin_version_approval_rights(request.user, plugin) + ) + if status == "deleted" and feedback.reviewer == request.user: + feedback.delete() + is_update_succeed: bool = True + elif (status == "completed" or status == "uncompleted") and ( + is_user_can_update_feedback): + feedback.is_completed = (status == "completed") + feedback.save() + is_update_succeed: bool = True + return JsonResponse({"success": is_update_succeed}) + + def version_download(request, package_name, version): """ Update download counter(s)