Repository: carpentries/amy

Issue:
Set permissions for Get Involved CRUD views
- [ ] trainee cannot edit/delete their submission after it's been evaluated
- [ ] trainee cannot add a second submission unless they have been asked to repeat
- [ ] trainee cannot access submissions belonging to other trainees
- [ ] trainee cannot access TrainingProgresses for steps other than Get Involved

Essential for 4.2 release.


Discussion:

Resolving patch:
diff --git a/amy/dashboard/forms.py b/amy/dashboard/forms.py
--- a/amy/dashboard/forms.py
+++ b/amy/dashboard/forms.py
@@ -18,6 +18,7 @@
     Person,
     Task,
     TrainingProgress,
+    TrainingRequirement,
 )
 
 
@@ -187,6 +188,25 @@ class Meta:
             "trainee_notes",
         ]
 
+    def user_may_create_submission(self, user: Person) -> bool:
+        """The user may create a submission if either of the conditions below are met:
+        1. no progress exists for the Get Involved step for this user
+        2. all existing progress for the Get Involved step for this user has state "a"
+        """
+        get_involved = TrainingRequirement.objects.get(name="Get Involved")
+        existing_progresses = TrainingProgress.objects.filter(
+            requirement=get_involved, trainee=user
+        )
+        num_existing = existing_progresses.count()
+        if num_existing == 0:
+            return True
+        else:
+            num_asked_to_repeat = existing_progresses.filter(state="a").count()
+            if num_asked_to_repeat == num_existing:
+                return True
+            else:
+                return False
+
     def clean_trainee_notes(self):
         """Raise an error if the trainee has not provided notes where required.
 
@@ -202,6 +222,25 @@ def clean_trainee_notes(self):
 
         return trainee_notes
 
+    def clean(self) -> None:
+        super().clean()
+
+        # check that the user may create/update this TrainingProgress instance
+        if self.instance:
+            if self.instance.pk is None and not self.user_may_create_submission(
+                self.instance.trainee
+            ):
+                raise ValidationError(
+                    "You already have an existing submission. "
+                    "You may not create another submission unless your previous "
+                    'submission has the status "asked to repeat."'
+                )
+            elif self.instance.pk and self.instance.state != "n":
+                raise ValidationError(
+                    "This submission can no longer be edited as it has already been "
+                    "evaluated."
+                )
+
 
 class SearchForm(forms.Form):
     """Represent general searching form."""
diff --git a/amy/dashboard/views.py b/amy/dashboard/views.py
--- a/amy/dashboard/views.py
+++ b/amy/dashboard/views.py
@@ -4,7 +4,16 @@
 
 from django.contrib import messages
 from django.contrib.auth.mixins import LoginRequiredMixin
-from django.db.models import Case, Count, IntegerField, Prefetch, Q, Value, When
+from django.db.models import (
+    Case,
+    Count,
+    IntegerField,
+    Prefetch,
+    Q,
+    QuerySet,
+    Value,
+    When,
+)
 from django.forms.widgets import HiddenInput
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse, reverse_lazy
@@ -244,13 +253,12 @@ def training_progress(request):
 
 
 class GetInvolvedCreateView(LoginRequiredMixin, AMYCreateView):
-    # permission_required = TODO
     model = TrainingProgress
     form_class = GetInvolvedForm
     template_name = "get_involved_form.html"
     success_url = reverse_lazy("training-progress")
     success_message = (
-        "Thank you. Your Get Involved submission will be reviewed within 7-10 days."
+        "Thank you. Your Get Involved submission will be evaluated within 7-10 days."
     )
 
     def get_context_data(self, **kwargs) -> dict:
@@ -274,12 +282,24 @@ def post(self, request, *args, **kwargs):
 
 
 class GetInvolvedUpdateView(LoginRequiredMixin, AMYUpdateView):
-    # permission_required = TODO
-    model = TrainingProgress
     form_class = GetInvolvedForm
     template_name = "get_involved_form.html"
     success_url = reverse_lazy("training-progress")
     success_message = "Your Get Involved submission was updated successfully."
+    pk_url_kwarg = "pk"
+
+    def dispatch(self, request, *args, **kwargs):
+        self.request = request
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_queryset(self) -> QuerySet[TrainingProgress]:
+        # user should only be able to update progress that belongs to them and has not
+        # been evaluated yet
+        return TrainingProgress.objects.filter(
+            trainee=self.request.user,
+            requirement__name="Get Involved",
+            state="n",
+        )
 
     def get_context_data(self, **kwargs) -> dict:
         context = super().get_context_data(**kwargs)
@@ -288,10 +308,23 @@ def get_context_data(self, **kwargs) -> dict:
 
 
 class GetInvolvedDeleteView(LoginRequiredMixin, AMYDeleteView):
-    # permission_required = TODO
     model = TrainingProgress
     success_url = reverse_lazy("training-progress")
     success_message = "Your Get Involved submission was deleted."
+    pk_url_kwarg = "pk"
+
+    def dispatch(self, request, *args, **kwargs):
+        self.request = request
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_queryset(self) -> QuerySet[TrainingProgress]:
+        # user should only be able to delete progress that belongs to them and has not
+        # been evaluated yet
+        return TrainingProgress.objects.filter(
+            trainee=self.request.user,
+            requirement__name="Get Involved",
+            state="n",
+        )
 
 
 # ------------------------------------------------------------


Test patch:
diff --git a/amy/dashboard/tests/test_forms.py b/amy/dashboard/tests/test_forms.py
--- a/amy/dashboard/tests/test_forms.py
+++ b/amy/dashboard/tests/test_forms.py
@@ -118,6 +118,30 @@ def test_clean_custom_validation__conflicting_tasks(self):
 
 
 class TestGetInvolvedForm(TestCase):
+    def setUp(self):
+        super().setUp()
+        self.person = Person.objects.create(
+            personal="Test", family="User", email="test@user.com"
+        )
+        self.get_involved = TrainingRequirement.objects.get(name="Get Involved")
+
+        self.involvement, _ = Involvement.objects.get_or_create(
+            name="Other", defaults={"display_name": "Other", "notes_required": True}
+        )
+        self.base_instance = TrainingProgress(
+            trainee=self.person,
+            state="n",  # not evaluated yet
+            requirement=TrainingRequirement.objects.get(name="Get Involved"),
+        )
+        self.EXISTING_SUBMISSION_ERROR_TEXT = (
+            "You already have an existing submission. "
+            "You may not create another submission unless your previous "
+            'submission has the status "asked to repeat."'
+        )
+        self.ALREADY_EVALUATED_ERROR_TEXT = (
+            "This submission can no longer be edited as it has already been evaluated."
+        )
+
     def test_fields(self):
         # Act
         form = GetInvolvedForm()
@@ -129,21 +153,10 @@ def test_fields(self):
 
     def test_clean_custom_validation__trainee_notes(self):
         # Arrange
-        person = Person.objects.create(
-            personal="Test", family="User", email="test@user.com"
-        )
-        involvement, _ = Involvement.objects.get_or_create(
-            name="Other", defaults={"display_name": "Other", "notes_required": True}
-        )
-        data = {"involvement_type": involvement, "date": date(2023, 7, 27)}
-        base_instance = TrainingProgress(
-            trainee=person,
-            state="n",  # not evaluated yet
-            requirement=TrainingRequirement.objects.get(name="Get Involved"),
-        )
+        data = {"involvement_type": self.involvement, "date": date(2023, 7, 27)}
 
         # Act
-        form = GetInvolvedForm(data, instance=base_instance)
+        form = GetInvolvedForm(data, instance=self.base_instance)
 
         # Assert
         # expect to see an error on "trainee_notes" and not on "notes"
@@ -157,18 +170,10 @@ def test_clean_custom_validation__trainee_notes(self):
     def test_clean_custom_validation__no_involvement_type(self):
         """Check that trainee_notes validation works when no involvement is chosen"""
         # Arrange
-        person = Person.objects.create(
-            personal="Test", family="User", email="test@user.com"
-        )
         data = {}
-        base_instance = TrainingProgress(
-            trainee=person,
-            state="n",  # not evaluated yet
-            requirement=TrainingRequirement.objects.get(name="Get Involved"),
-        )
 
         # Act
-        form = GetInvolvedForm(data, instance=base_instance)
+        form = GetInvolvedForm(data, instance=self.base_instance)
 
         # Assert
         self.assertEqual(form.is_valid(), False)
@@ -177,3 +182,155 @@ def test_clean_custom_validation__no_involvement_type(self):
             ['This field is required for progress type "Get Involved".'],
         )
         self.assertNotIn("trainee_notes", form.errors.keys())
+
+    def test_custom_validation__existing_not_evaluated_yet(self):
+        # Arrange
+        # create pre-existing submission
+        TrainingProgress.objects.create(
+            trainee=self.person,
+            requirement=self.get_involved,
+            involvement_type=self.involvement,
+            date=date(2023, 7, 1),
+            trainee_notes="Notes from trainee",
+            state="n",
+        )
+        data = {
+            "involvement_type": self.involvement,
+            "trainee_notes": "Notes from trainee",
+            "date": date(2023, 7, 27),
+        }
+
+        # Act
+        form = GetInvolvedForm(data, instance=self.base_instance)
+        self.assertEqual(form.is_valid(), False)
+        self.assertEqual(
+            form.errors["__all__"],
+            [self.EXISTING_SUBMISSION_ERROR_TEXT],
+        )
+
+    def test_custom_validation__existing_passed(self):
+        # Arrange
+        # create pre-existing submission
+        TrainingProgress.objects.create(
+            trainee=self.person,
+            requirement=self.get_involved,
+            involvement_type=self.involvement,
+            date=date(2023, 7, 1),
+            trainee_notes="Notes from trainee",
+            state="p",
+        )
+        data = {
+            "involvement_type": self.involvement,
+            "trainee_notes": "Notes from trainee",
+            "date": date(2023, 7, 27),
+        }
+
+        # Act
+        form = GetInvolvedForm(data, instance=self.base_instance)
+
+        # Assert
+        self.assertEqual(form.is_valid(), False)
+        self.assertEqual(
+            form.errors["__all__"],
+            [self.EXISTING_SUBMISSION_ERROR_TEXT],
+        )
+
+    def test_custom_validation__existing_failed(self):
+        # Arrange
+        # create pre-existing submission
+        TrainingProgress.objects.create(
+            trainee=self.person,
+            requirement=self.get_involved,
+            involvement_type=self.involvement,
+            date=date(2023, 7, 1),
+            trainee_notes="Notes from trainee",
+            state="f",
+        )
+        data = {
+            "involvement_type": self.involvement,
+            "trainee_notes": "Notes from trainee",
+            "date": date(2023, 7, 27),
+        }
+
+        # Act
+        form = GetInvolvedForm(data, instance=self.base_instance)
+
+        # Assert
+        self.assertEqual(form.is_valid(), False)
+        self.assertEqual(
+            form.errors["__all__"],
+            [self.EXISTING_SUBMISSION_ERROR_TEXT],
+        )
+
+    def test_custom_validation__existing_asked_to_repeat(self):
+        # Arrange
+        # create pre-existing submission
+        TrainingProgress.objects.create(
+            trainee=self.person,
+            requirement=self.get_involved,
+            involvement_type=self.involvement,
+            date=date(2023, 7, 1),
+            trainee_notes="Notes from trainee",
+            state="a",
+        )
+        data = {
+            "involvement_type": self.involvement,
+            "trainee_notes": "Notes from trainee",
+            "date": date(2023, 7, 27),
+        }
+
+        # Act
+        form = GetInvolvedForm(data, instance=self.base_instance)
+
+        # Assert
+        self.assertEqual(form.is_valid(), True)
+
+    def test_custom_validation__already_evaluated(self):
+        # Arrange
+        # create pre-existing submission
+        progress = TrainingProgress.objects.create(
+            trainee=self.person,
+            requirement=self.get_involved,
+            involvement_type=self.involvement,
+            date=date(2023, 7, 1),
+            trainee_notes="Notes from trainee",
+            state="p",
+        )
+        data = {
+            "involvement_type": self.involvement,
+            "trainee_notes": "Updated notes from trainee",
+            "date": date(2023, 7, 27),
+        }
+
+        # Act
+        form = GetInvolvedForm(data, instance=progress)
+
+        # Assert
+        self.assertEqual(form.is_valid(), False)
+        self.assertEqual(
+            form.errors["__all__"],
+            [self.ALREADY_EVALUATED_ERROR_TEXT],
+        )
+
+    def test_custom_validation__not_already_evaluated(self):
+        # Arrange
+        # create pre-existing submission
+        progress = TrainingProgress.objects.create(
+            trainee=self.person,
+            requirement=self.get_involved,
+            involvement_type=self.involvement,
+            date=date(2023, 7, 1),
+            trainee_notes="Notes from trainee",
+            state="n",
+        )
+        data = {
+            "involvement_type": self.involvement,
+            "trainee_notes": "Updated notes from trainee",
+            "date": date(2023, 7, 27),
+        }
+
+        # Act
+        form = GetInvolvedForm(data, instance=progress)
+
+        # Assert
+        self.assertEqual(form.is_valid(), True)
diff --git a/amy/dashboard/tests/test_get_involved_views.py b/amy/dashboard/tests/test_get_involved_views.py
--- a/amy/dashboard/tests/test_get_involved_views.py
+++ b/amy/dashboard/tests/test_get_involved_views.py
@@ -106,7 +106,66 @@ def test_create_view_works(self):
         self.assertEqual(rv.status_code, 200)
         self.assertEqual(rv.resolver_match.view_name, "training-progress")
         self.assertContains(
-            rv, "Your Get Involved submission will be reviewed within 7-10 days."
+            rv, "Your Get Involved submission will be evaluated within 7-10 days."
+        )
+        got = list(
+            TrainingProgress.objects.values_list(
+                "state", "trainee", "url", "requirement", "involvement_type", "date"
+            )
+        )
+        expected = [
+            (
+                "n",
+                self.user.pk,
+                "https://example.org",
+                self.get_involved.pk,
+                self.involvement.pk,
+                date(2023, 7, 27),
+            )
+        ]
+        self.assertEqual(got, expected)
+
+    def test_cannot_create_if_not_logged_in(self):
+        self.client.logout()
+
+        data = {
+            "involvement_type": self.involvement.pk,
+            "url": "https://example.org",
+            "date": "2023-07-27",
+        }
+
+        # Act
+        rv_get = self.client.get(reverse("getinvolved_add"))
+        rv_post = self.client.post(reverse("getinvolved_add"), data)
+
+        # Assert
+        self.assertEqual(rv_get.status_code, 302)
+        self.assertEqual(rv_post.status_code, 302)
+        # cannot check by assertRedirect because there's additional `?next`
+        # parameter
+        self.assertTrue(rv_get.url.startswith(reverse("login")))
+        self.assertTrue(rv_post.url.startswith(reverse("login")))
+
+    def test_trainee_cannot_set_type_or_state(self):
+        # Arrange
+
+        demo, _ = TrainingRequirement.objects.get_or_create(name="Demo")
+        data = {
+            "requirement": demo,
+            "state": "p",
+            "involvement_type": self.involvement.pk,
+            "url": "https://example.org",
+            "date": "2023-07-27",
+        }
+
+        # Act
+        rv = self.client.post(reverse("getinvolved_add"), data, follow=True)
+
+        # Assert
+        self.assertEqual(rv.status_code, 200)
+        self.assertEqual(rv.resolver_match.view_name, "training-progress")
+        self.assertContains(
+            rv, "Your Get Involved submission will be evaluated within 7-10 days."
         )
         got = list(
             TrainingProgress.objects.values_list(
@@ -139,13 +198,177 @@ def setUp(self):
             date=date(2023, 7, 27),
         )
 
-    def test_edit_view_loads(self):
+    def test_update_view_loads(self):
         # Act
         rv = self.client.get(reverse("getinvolved_update", args=[self.progress.pk]))
 
         # Assert
         self.assertEqual(rv.status_code, 200)
 
+    def test_update_view_works(self):
+        # Arrange
+        data = {
+            "involvement_type": self.involvement.pk,
+            "url": "https://second-example.org",
+            "date": "2023-08-03",
+        }
+
+        # Act
+        rv = self.client.post(
+            reverse("getinvolved_update", args=[self.progress.pk]), data, follow=True
+        )
+
+        # Assert
+        self.assertEqual(rv.status_code, 200)
+        self.assertEqual(rv.resolver_match.view_name, "training-progress")
+        self.assertContains(
+            rv, "Your Get Involved submission was updated successfully."
+        )
+        got = list(
+            TrainingProgress.objects.values_list(
+                "state", "trainee", "url", "requirement", "involvement_type", "date"
+            )
+        )
+        expected = [
+            (
+                "n",
+                self.user.pk,
+                "https://second-example.org",
+                self.get_involved.pk,
+                self.involvement.pk,
+                date(2023, 8, 3),
+            )
+        ]
+        self.assertEqual(got, expected)
+
+    def test_cannot_update_if_not_logged_in(self):
+        self.client.logout()
+
+        # Arrange
+        data = {
+            "involvement_type": self.involvement.pk,
+            "url": "https://second-example.org",
+            "date": "2023-07-27",
+        }
+
+        # Act
+        rv_get = self.client.get(reverse("getinvolved_update", args=[self.progress.pk]))
+        rv_post = self.client.post(
+            reverse("getinvolved_update", args=[self.progress.pk]), data
+        )
+
+        # Assert
+        self.assertEqual(rv_get.status_code, 302)
+        self.assertEqual(rv_post.status_code, 302)
+        # cannot check by assertRedirect because there's additional `?next`
+        # parameter
+        self.assertTrue(rv_get.url.startswith(reverse("login")))
+        self.assertTrue(rv_post.url.startswith(reverse("login")))
+
+    def test_cannot_update_evaluated_progress(self):
+        # Arrange
+        self.progress.state = "p"
+        self.progress.save()
+
+        data = {
+            "involvement_type": self.involvement.pk,
+            "url": "https://second-example.org",
+            "date": "2023-07-27",
+        }
+
+        # Act
+        rv_get = self.client.get(reverse("getinvolved_update", args=[self.progress.pk]))
+        rv_post = self.client.post(
+            reverse("getinvolved_update", args=[self.progress.pk]), data, follow=True
+        )
+
+        # Assert
+        self.assertEqual(rv_get.status_code, 404)
+        self.assertEqual(rv_post.status_code, 404)
+
+    def test_cannot_update_progress_of_other_type(self):
+        # Arrange
+        demo, _ = TrainingRequirement.objects.get_or_create(name="Demo")
+        progress = TrainingProgress.objects.create(
+            trainee=self.user,
+            requirement=demo,
+            date=date(2023, 7, 31),
+            state="n",
+        )
+        data = {
+            "involvement_type": self.involvement.pk,
+            "url": "https://example.org",
+            "date": "2023-07-27",
+        }
+
+        # Act
+        rv_get = self.client.get(reverse("getinvolved_update", args=[progress.pk]))
+        rv_post = self.client.post(
+            reverse("getinvolved_update", args=[progress.pk]), data, follow=True
+        )
+
+        # Assert
+        self.assertEqual(rv_get.status_code, 404)
+        self.assertEqual(rv_post.status_code, 404)
+
+    def test_cannot_update_non_existent_progress(self):
+        # Arrange
+        id = 1000
+        data = {
+            "involvement_type": self.involvement.pk,
+            "url": "https://example.org",
+            "date": "2023-07-27",
+        }
+
+        # Act
+        rv_get = self.client.get(reverse("getinvolved_update", args=[id]))
+        rv_post = self.client.post(
+            reverse("getinvolved_update", args=[id]), data, follow=True
+        )
+
+        # Assert
+        self.assertEqual(rv_get.status_code, 404)
+        self.assertEqual(rv_post.status_code, 404)
+
+    def test_trainee_cannot_set_type_or_state(self):
+        # Arrange
+        demo, _ = TrainingRequirement.objects.get_or_create(name="Demo")
+        data = {
+            "requirement": demo,
+            "state": "p",
+            "involvement_type": self.involvement.pk,
+            "url": "https://second-example.org",
+            "date": "2023-07-27",
+        }
+
+        # Act
+        rv = self.client.post(
+            reverse("getinvolved_update", args=[self.progress.pk]), data, follow=True
+        )
+
+        # Assert
+        self.assertEqual(rv.status_code, 200)
+        self.assertEqual(rv.resolver_match.view_name, "training-progress")
+        self.assertContains(
+            rv, "Your Get Involved submission was updated successfully."
+        )
+        got = list(
+            TrainingProgress.objects.values_list(
+                "state", "trainee", "url", "requirement", "involvement_type", "date"
+            )
+        )
+        expected = [
+            (  # date changed but not state or requirement
+                "n",
+                self.user.pk,
+                "https://second-example.org",
+                self.get_involved.pk,
+                self.involvement.pk,
+                date(2023, 7, 27),
+            )
+        ]
+        self.assertEqual(got, expected)
+
 
 class TestGetInvolvedDeleteView(TestGetInvolvedViewBase):
     def setUp(self):
@@ -177,3 +400,76 @@ def test_delete_view_works(self):
         self.assertEqual(rv.status_code, 200)
         self.assertEqual(rv.resolver_match.view_name, "training-progress")
         self.assertEqual(set(TrainingProgress.objects.all()), set())
+
+    def test_cannot_delete_if_not_logged_in(self):
+        # Arrange
+        self.client.logout()
+
+        # Act
+        rv_post = self.client.post(
+            reverse("getinvolved_delete", args=[self.progress.pk])
+        )
+
+        # Assert
+        self.assertEqual(rv_post.status_code, 302)
+        # cannot check by assertRedirect because there's additional `?next`
+        # parameter
+        self.assertTrue(rv_post.url.startswith(reverse("login")))
+
+    def test_cannot_delete_other_trainees_submission(self):
+        # Arrange
+        other_trainee = Person.objects.create(
+            personal="Bob", family="Trainee", email="bob_trainee@example.com"
+        )
+
+        progress = TrainingProgress.objects.create(
+            trainee=other_trainee,
+            requirement=self.get_involved,
+            involvement_type=self.involvement,
+            date=date(2023, 7, 31),
+            url="https://example.org",
+            state="n",
+        )
+
+        # Act
+        rv = self.client.post(reverse("getinvolved_delete", args=[progress.pk]))
+
+        # Assert
+        self.assertEqual(rv.status_code, 404)
+
+    def test_cannot_delete_progress_of_other_type(self):
+        # Arrange
+        demo, _ = TrainingRequirement.objects.get_or_create(name="Demo")
+        progress = TrainingProgress.objects.create(
+            trainee=self.user,
+            requirement=demo,
+            date=date(2023, 7, 31),
+            state="n",
+        )
+
+        # Act
+        rv = self.client.post(reverse("getinvolved_delete", args=[progress.pk]))
+
+        # Assert
+        self.assertEqual(rv.status_code, 404)
+
+    def test_cannot_delete_evaluated_progress(self):
+        # Arrange
+        self.progress.state = "a"
+        self.progress.save()
+
+        # Act
+        rv = self.client.post(reverse("getinvolved_delete", args=[self.progress.pk]))
+
+        # Assert
+        self.assertEqual(rv.status_code, 404)
+
+    def test_cannot_delete_non_existent_progress(self):
+        # Arrange
+        id = 1000
+
+        # Act
+        rv = self.client.post(reverse("getinvolved_delete", args=[id]))
+
+        # Assert
+        self.assertEqual(rv.status_code, 404)