Repository: carpentries/amy

Issue:
Actions when trainee submits "Get involved" 
After a trainee successfully submits a "Get involved" step, the trainee can not currently view, edit, or delete their submission, and the trainee is able to create another submission.

The trainee should see the following: 

* The trainee should see a summary of their submission with a message that says "Thank you. Your submission will be reviewed in 7-10 days."   
* The trainee should be able to edit or delete their submission.  
* The trainee should not be able to submit another entry.
Actions when admin reviews trainee's "Get involved" submission 
When an admin reviews a trainee's "Get involved" submission:

* If the submission is accepted, a summary of the submission should remain in the admin and in the trainee views.  The trainee should not be able to create any more submissions.
* If the submission is not accepted (asked to repeat), a summary of submission should remain in the admin and in the trainee views.  Both views should also include the notes that show why the submission was not accepted.  The trainee should then be able to create a new submission.  This should continue until a submission is accepted.  


Discussion:

Resolving patch:
diff --git a/amy/dashboard/urls.py b/amy/dashboard/urls.py
--- a/amy/dashboard/urls.py
+++ b/amy/dashboard/urls.py
@@ -46,6 +46,28 @@
                     views.ResignFromRecruitment.as_view(),
                     name="resign-from-recruitment",
                 ),
+                path(
+                    "get_involved/",
+                    include(
+                        [
+                            path(
+                                "create/",
+                                views.GetInvolvedCreateView.as_view(),
+                                name="getinvolved_add",
+                            ),
+                            path(
+                                "<int:pk>/edit/",
+                                views.GetInvolvedUpdateView.as_view(),
+                                name="getinvolved_update",
+                            ),
+                            path(
+                                "<int:pk>/delete/",
+                                views.GetInvolvedDeleteView.as_view(),
+                                name="getinvolved_delete",
+                            ),
+                        ]
+                    ),
+                ),
             ]
         ),
     ),
diff --git a/amy/dashboard/views.py b/amy/dashboard/views.py
--- a/amy/dashboard/views.py
+++ b/amy/dashboard/views.py
@@ -31,7 +31,13 @@
 from fiscal.models import MembershipTask
 from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup
 from recruitment.views import RecruitmentEnabledMixin
-from workshops.base_views import AMYListView, ConditionallyEnabledMixin
+from workshops.base_views import (
+    AMYCreateView,
+    AMYDeleteView,
+    AMYListView,
+    AMYUpdateView,
+    ConditionallyEnabledMixin,
+)
 from workshops.models import (
     Airport,
     Badge,
@@ -209,8 +215,6 @@ def autoupdate_profile(request):
 
 @login_required
 def training_progress(request):
-    get_involved_form = GetInvolvedForm()
-
     # Add information about instructor training progress to request.user.
     request.user = (
         Person.objects.annotate_with_instructor_eligibility()
@@ -229,28 +233,8 @@ def training_progress(request):
     progress_welcome = get_passed_or_last_progress(request.user, "Welcome Session")
     progress_demo = get_passed_or_last_progress(request.user, "Demo")
 
-    if request.method == "POST":
-        base_training_progress = TrainingProgress(
-            trainee=request.user,
-            state="n",  # not evaluated yet
-            requirement=TrainingRequirement.objects.get(name="Get Involved"),
-        )
-        get_involved_form = GetInvolvedForm(
-            data=request.POST, instance=base_training_progress
-        )
-        if get_involved_form.is_valid():
-            get_involved_form.save()
-
-            messages.success(
-                request,
-                "Thank you. Your Get Involved submission will be reviewed within 7-10 "
-                "days.",
-            )
-            return redirect(reverse("training-progress"))
-
     context = {
         "title": "Your training progress",
-        "get_involved_form": get_involved_form,
         "progress_training": progress_training,
         "progress_get_involved": progress_get_involved,
         "progress_welcome": progress_welcome,
@@ -259,6 +243,57 @@ def training_progress(request):
     return render(request, "dashboard/training_progress.html", context)
 
 
+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."
+    )
+
+    def get_context_data(self, **kwargs) -> dict:
+        context = super().get_context_data(**kwargs)
+        context["title"] = "Submit your Get Involved activity"
+        return context
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        base_training_progress = TrainingProgress(
+            trainee=self.request.user,
+            state="n",  # not evaluated yet
+            requirement=TrainingRequirement.objects.get(name="Get Involved"),
+        )
+        kwargs["instance"] = base_training_progress
+        return kwargs
+
+    def post(self, request, *args, **kwargs):
+        self.request = request
+        return super().post(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."
+
+    def get_context_data(self, **kwargs) -> dict:
+        context = super().get_context_data(**kwargs)
+        context["title"] = "Update your Get Involved submission"
+        return context
+
+
+class GetInvolvedDeleteView(LoginRequiredMixin, AMYDeleteView):
+    # permission_required = TODO
+    model = TrainingProgress
+    success_url = reverse_lazy("training-progress")
+    success_message = "Your Get Involved submission was deleted."
+
+
 # ------------------------------------------------------------
 # Views for instructors - upcoming teaching opportunities
 


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
@@ -2,9 +2,18 @@
 
 from django.test import TestCase
 
-from dashboard.forms import SignupForRecruitmentForm
+from dashboard.forms import GetInvolvedForm, SignupForRecruitmentForm
 from recruitment.models import InstructorRecruitment
-from workshops.models import Event, Organization, Person, Role, Task
+from trainings.models import Involvement
+from workshops.models import (
+    Event,
+    Organization,
+    Person,
+    Role,
+    Task,
+    TrainingProgress,
+    TrainingRequirement,
+)
 
 
 class TestSignupForRecruitmentForm(TestCase):
@@ -106,3 +115,41 @@ def test_clean_custom_validation__conflicting_tasks(self):
                 f"{conflicting_task.event.slug}"
             ],
         )
+
+
+class TestGetInvolvedForm(TestCase):
+    def test_fields(self):
+        # Act
+        form = GetInvolvedForm()
+
+        # Assert
+        self.assertEqual(
+            {"involvement_type", "date", "url", "trainee_notes"}, form.fields.keys()
+        )
+
+    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"),
+        )
+
+        # Act
+        form = GetInvolvedForm(data, instance=base_instance)
+
+        # Assert
+        # expect to see an error on "trainee_notes" and not on "notes"
+        self.assertEqual(form.is_valid(), False)
+        self.assertEqual(
+            form.errors["trainee_notes"],
+            ['This field is required for activity "Other".'],
+        )
+        self.assertNotIn("notes", form.errors.keys())
diff --git a/amy/dashboard/tests/test_get_involved_views.py b/amy/dashboard/tests/test_get_involved_views.py
new file mode 100644
--- /dev/null
+++ b/amy/dashboard/tests/test_get_involved_views.py
@@ -0,0 +1,179 @@
+from datetime import date
+
+from django.urls import reverse
+
+from trainings.models import Involvement
+from workshops.models import Person, TrainingProgress, TrainingRequirement
+from workshops.tests.base import TestBase, consent_to_all_required_consents
+
+
+class TestGetInvolvedViewBase(TestBase):
+    def setUp(self):
+        self._setUpRoles()
+        self._setUpTags()
+
+        self.get_involved, _ = TrainingRequirement.objects.get_or_create(
+            name="Get Involved",
+            defaults={
+                "url_required": False,
+                "event_required": False,
+                "involvement_required": True,
+            },
+        )
+        self.involvement, _ = Involvement.objects.get_or_create(
+            name="GitHub Contribution",
+            defaults={
+                "display_name": "GitHub Contribution",
+                "url_required": True,
+                "date_required": True,
+            },
+        )
+
+        # set up and log in as a trainee
+        self.user = Person.objects.create_user(
+            username="trainee_alice",
+            personal="Alice",
+            family="Trainee",
+            email="alice_trainee@example.com",
+            password="password",
+        )
+        consent_to_all_required_consents(self.user)
+        self.client.login(username="trainee_alice", password="password")
+
+
+class TestGetInvolvedCreateView(TestGetInvolvedViewBase):
+    def setUp(self):
+        super().setUp()
+        self.involvement_to_be_archived, _ = Involvement.objects.get_or_create(
+            name="To be archived",
+            defaults={
+                "display_name": "To be archived",
+                "url_required": True,
+                "date_required": True,
+            },
+        )
+        self.involvement_other, _ = Involvement.objects.get_or_create(
+            name="Other",
+            defaults={
+                "display_name": "Other",
+                "notes_required": True,
+            },
+        )
+
+    def test_create_view_loads(self):
+        # Act
+        rv = self.client.get(reverse("getinvolved_add"))
+
+        # Assert
+        self.assertEqual(rv.status_code, 200)
+
+    def test_create_view_does_not_show_archived_involvements(self):
+        # Arrange
+        self.involvement_to_be_archived.archive()
+
+        # Act
+        rv = self.client.get(reverse("getinvolved_add"))
+
+        # Assert
+        self.assertEqual(rv.status_code, 200)
+        choices = [
+            c[0].instance.pk
+            for c in rv.context["form"].fields["involvement_type"].choices
+        ]
+        self.assertEqual(choices, [self.involvement.pk, self.involvement_other.pk])
+
+    def test_create_view_does_not_show_admin_fields(self):
+        # Act
+        rv = self.client.get(reverse("getinvolved_add"))
+
+        # Assert
+        self.assertEqual(rv.status_code, 200)
+        expected_fields = {"involvement_type", "date", "url", "trainee_notes"}
+        self.assertSetEqual(set(rv.context["form"].fields.keys()), expected_fields)
+
+    def test_create_view_works(self):
+        # Arrange
+        data = {
+            "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 reviewed 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)
+
+
+class TestGetInvolvedUpdateView(TestGetInvolvedViewBase):
+    def setUp(self):
+        super().setUp()
+
+        self.progress = TrainingProgress.objects.create(
+            trainee=self.user,
+            state="n",
+            requirement=self.get_involved,
+            involvement_type=self.involvement,
+            url="https://example.org",
+            date=date(2023, 7, 27),
+        )
+
+    def test_edit_view_loads(self):
+        # Act
+        rv = self.client.get(reverse("getinvolved_update", args=[self.progress.pk]))
+
+        # Assert
+        self.assertEqual(rv.status_code, 200)
+
+
+class TestGetInvolvedDeleteView(TestGetInvolvedViewBase):
+    def setUp(self):
+        super().setUp()
+
+        self.progress = TrainingProgress.objects.create(
+            trainee=self.user,
+            state="n",
+            requirement=self.get_involved,
+            involvement_type=self.involvement,
+            url="https://example.org",
+            date=date(2023, 7, 27),
+        )
+
+    def test_delete_view_get_request_not_allowed(self):
+        # Act
+        rv = self.client.get(reverse("getinvolved_delete", args=[self.progress.pk]))
+
+        # Assert
+        self.assertEqual(rv.status_code, 405)
+
+    def test_delete_view_works(self):
+        # Act
+        rv = self.client.post(
+            reverse("getinvolved_delete", args=[self.progress.pk]), follow=True
+        )
+
+        # Assert
+        self.assertEqual(rv.status_code, 200)
+        self.assertEqual(rv.resolver_match.view_name, "training-progress")
+        self.assertEqual(set(TrainingProgress.objects.all()), set())
diff --git a/amy/dashboard/tests/test_instructor_dashboard.py b/amy/dashboard/tests/test_instructor_dashboard.py
--- a/amy/dashboard/tests/test_instructor_dashboard.py
+++ b/amy/dashboard/tests/test_instructor_dashboard.py
@@ -189,97 +189,144 @@ def setUp(self):
             name="Other", defaults={"display_name": "Other", "notes_required": True}
         )
         self.progress_url = reverse("training-progress")
+        self.SESSION_LINK_TEXT = "Submit a Get Involved activity"
 
-    def test_get_involved_not_submitted(self):
-        rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Get Involved step not submitted")
-
-    def test_get_involved_waiting_to_be_evaluated(self):
-        TrainingProgress.objects.create(
+        self.progress = TrainingProgress.objects.create(
             trainee=self.admin,
             requirement=self.get_involved,
-            state="n",
             involvement_type=self.github_contribution,
             date=datetime.today(),
             url="https://example.org",
+            trainee_notes="Notes from trainee",
+        )
+        self.PROGRESS_SUMMARY_BLOCK = (
+            "<p>"
+            "<strong>Activity:</strong> Submitted a contribution to a Carpentries "
+            "repository<br/>"
+            f'<strong>Date:</strong> {datetime.today().strftime("%B %-d, %Y")}<br/>'
+            "<strong>URL:</strong> https://example.org<br/>"
+            "<strong>Notes:</strong> Notes from trainee"
+            "</p>"
         )
+        self.EDIT_CLASS = "edit-object"  # class on the Edit button
+        self.DELETE_CLASS = "delete-object"  # class on the Delete button
+
+    def test_get_involved_not_submitted(self):
+        # Arrange
+        self.progress.delete()
+
+        # Act
         rv = self.client.get(self.progress_url)
+
+        # Assert
+        self.assertContains(rv, "Get Involved step not submitted")
+        self.assertContains(rv, self.SESSION_LINK_TEXT)
+
+    def test_get_involved_waiting_to_be_evaluated(self):
+        # Arrange
+        self.progress.state = "n"
+        self.progress.save()
+
+        # Act
+        rv = self.client.get(self.progress_url)
+
+        # Assert
         self.assertContains(
             rv,
             '<p>Get Involved <span class="badge-warning">not evaluated yet</span> '
             f'as of {datetime.today().strftime("%B %-d, %Y")}.</p>',
             html=True,
         )
+        self.assertContains(rv, self.PROGRESS_SUMMARY_BLOCK, html=True)
+        self.assertContains(rv, self.EDIT_CLASS)
+        self.assertContains(rv, self.DELETE_CLASS)
+        self.assertNotContains(rv, self.SESSION_LINK_TEXT, html=True)
 
     def test_get_involved_passed(self):
-        TrainingProgress.objects.create(
-            trainee=self.admin,
-            requirement=self.get_involved,
-            involvement_type=self.github_contribution,
-            date=datetime.today(),
-            url="https://example.org",
-        )
+        # Arrange
+        self.progress.state = "p"
+        self.progress.save()
+
+        # Act
         rv = self.client.get(self.progress_url)
+
+        # Assert
         self.assertContains(
             rv,
             '<p>Get Involved <span class="badge-success">passed</span> '
             f'as of {datetime.today().strftime("%B %-d, %Y")}.</p>',
             html=True,
         )
+        self.assertContains(rv, self.PROGRESS_SUMMARY_BLOCK, html=True)
+        self.assertNotContains(rv, self.EDIT_CLASS)
+        self.assertNotContains(rv, self.DELETE_CLASS)
+        self.assertNotContains(rv, self.SESSION_LINK_TEXT)
 
-    def test_submission_form(self):
-        data = {
-            "url": "http://example.com",
-            "requirement": self.get_involved.pk,
-            "involvement_type": self.github_contribution.pk,
-            "date": "2023-06-21",
-        }
-        rv = self.client.post(self.progress_url, data, follow=True)
-        self.assertEqual(rv.status_code, 200)
-        self.assertEqual(rv.resolver_match.view_name, "training-progress")
+    def test_get_involved_failed(self):
+        # Arrange
+        self.progress.state = "f"
+        self.progress.save()
+
+        # Act
+        rv = self.client.get(self.progress_url)
+
+        # Assert
         self.assertContains(
-            rv, "Your Get Involved submission will be reviewed within 7-10 days."
-        )
-        got = list(
-            TrainingProgress.objects.values_list(
-                "state", "trainee", "url", "requirement", "involvement_type", "date"
-            )
+            rv,
+            '<p>Get Involved <span class="badge-danger">failed</span> '
+            f'as of {datetime.today().strftime("%B %-d, %Y")}.</p>',
+            html=True,
         )
-        expected = [
-            (
-                "n",
-                self.admin.pk,
-                "http://example.com",
-                self.get_involved.pk,
-                self.github_contribution.pk,
-                date(2023, 6, 21),
-            )
-        ]
-        self.assertEqual(got, expected)
-
-    def test_submission_form_invalid_notes(self):
-        """Test that errors relating to notes/trainee_notes fields are
-        handled correctly."""
-        data = {
-            "requirement": self.get_involved.pk,
-            "involvement_type": self.other_involvement.pk,
-            "date": "2023-06-21",
-        }
-        rv = self.client.post(self.progress_url, data, follow=True)
-        # if "notes" field error is not excluded, a server error will occur
-        # as there is no "notes" field on the form
-        # so a status code 200 means it has been excluded correctly
-        self.assertEqual(rv.status_code, 200)
-        self.assertEqual(rv.resolver_match.view_name, "training-progress")
-        # check that "trainee_notes" field error is displayed
-        # special treatment needed due to quotation marks
+        self.assertContains(rv, self.PROGRESS_SUMMARY_BLOCK, html=True)
+        self.assertNotContains(rv, self.EDIT_CLASS)
+        self.assertNotContains(rv, self.DELETE_CLASS)
+        self.assertNotContains(rv, self.SESSION_LINK_TEXT)
+
+    def test_get_involved_asked_to_repeat(self):
+        # Arrange
+        self.progress.state = "a"
+        self.progress.save()
+
+        # Act
+        rv = self.client.get(self.progress_url)
+
+        # Assert
         self.assertContains(
             rv,
-            'This field is required for activity "Other".',
+            '<p>Get Involved <span class="badge-info">asked to repeat</span> '
+            f'as of {datetime.today().strftime("%B %-d, %Y")}.</p>',
             html=True,
         )
-        # no TrainingProgress should have been created
-        self.assertEqual(len(TrainingProgress.objects.all()), 0)
+        self.assertContains(rv, self.PROGRESS_SUMMARY_BLOCK, html=True)
+        self.assertNotContains(rv, self.EDIT_CLASS)
+        self.assertNotContains(rv, self.DELETE_CLASS)
+        self.assertNotContains(rv, self.SESSION_LINK_TEXT)
+        self.assertContains(rv, "Submit another Get Involved activity")
+
+    def test_get_involved_details_not_provided(self):
+        """Check that optional fields are summarised correctly when empty"""
+        self.progress.delete()
+        TrainingProgress.objects.create(
+            trainee=self.admin,
+            requirement=self.get_involved,
+            involvement_type=self.github_contribution,
+            state="n",
+        )
+
+        # Act
+        rv = self.client.get(self.progress_url)
+
+        # Assert
+        PROGRESS_SUMMARY_BLOCK = (
+            "<p>"
+            "<strong>Activity:</strong> Submitted a contribution to a Carpentries "
+            "repository<br/>"
+            "<strong>Date:</strong> No date provided<br/>"
+            "<strong>URL:</strong> No URL provided<br/>"
+            "<strong>Notes:</strong> No notes provided"
+            "</p>"
+        )
+        self.assertContains(rv, PROGRESS_SUMMARY_BLOCK, html=True)
 
 
 class TestWelcomeSessionStatus(TestBase):