Repository: carpentries/amy

Issue:
Update information at top of training page
At the top of the [training progress page](https://test-amy2.carpentries.org/dashboard/instructor/training_progress/) the trainee should see the following information.

* Remove the warning "Due to the structure of our checkout tracking progress, all steps towards checkout may not be included here. If your instructor badge is correctly displayed, you are in good standing." 
* If the Instructor badge has been awarded, keep that part as is
* If the Instructor badge has not been awarded but all three steps are complete, text should say "Congratulations. You have successfully completed all steps towards Instructor certification. You will receive you certificate and more information by email in 7-10 days."
* If training is complete but all three steps have not been completed, text should say "Please review your progress towards Instructor certification below.  As a reminder, because you completed training on [DATE], you have until [DATE + 90 days] to complete these requirements.  Visit our [checkout page](https://carpentries.github.io/instructor-training/checkout)  for more information.  If you would like an extension on this deadline, please contact us at [instructor.training@carpentries.org](mailto:instructor.training@carpentries.org) If you have recently completed a training or step towards checkout, please allow 7-10 days for this information to show up in your profile."

(edit to add link to checkout page) 


Discussion:
Can I suggest a change to the last item:

> Please review your progress towards Instructor certification below. As a reminder, because you completed training on [DATE], you have until [DATE + 90 days] to complete these requirements. Visit our [checkout page](https://carpentries.github.io/instructor-training/checkout) for more information. If you would like an extension on this deadline, please contact us at [instructor.training@carpentries.org](mailto:instructor.training@carpentries.org) If you have recently completed a training or step towards checkout, please allow 7-10 days for this information to show up in your profile.

instead: 
Please review your progress towards Instructor certification below. Based on your training date of [DATE], the 90-day deadline to complete these requirements is [DATE + 90 days]. If you would like an extension, please contact us at [instructor.training@carpentries.org](mailto:instructor.training@carpentries.org) and refer to your confirmation email for your updated deadline. If you recently completed a training or checkout step, please allow 7-10 days for this information to show up in your profile. Visit our [checkout page](https://carpentries.github.io/instructor-training/checkout) for more information.


This will more appropriately signal to trainees that the indicated deadline will not be updated in the message after they have been granted an extension. (Other language edits suggested but not essential to that primary goal.)
Also note typo "you certificate" -> "your certificate" in bullet 3

Resolving patch:
diff --git a/amy/dashboard/utils.py b/amy/dashboard/utils.py
new file mode 100644
--- /dev/null
+++ b/amy/dashboard/utils.py
@@ -0,0 +1,20 @@
+from workshops.models import Person, TrainingProgress
+
+
+def get_passed_or_last_progress(
+    trainee: Person, requirement: str
+) -> TrainingProgress | None:
+    """Returns a recent progress, prioritising progress with a passed state.
+
+    If there are one or more passed progresses, returns the most recent of those.
+    Otherwise, returns the most recent progress of any state.
+    If the trainee has no progresses for this requirement, returns None.
+    """
+    progresses = trainee.trainingprogress_set.filter(
+        requirement__name=requirement
+    ).order_by("-created_at")
+
+    try:
+        return progresses.filter(state="p")[0]
+    except IndexError:
+        return progresses.first()
diff --git a/amy/dashboard/views.py b/amy/dashboard/views.py
--- a/amy/dashboard/views.py
+++ b/amy/dashboard/views.py
@@ -26,6 +26,7 @@
     SearchForm,
     SignupForRecruitmentForm,
 )
+from dashboard.utils import get_passed_or_last_progress
 from extrequests.base_views import AMYCreateAndFetchObjectView
 from fiscal.models import MembershipTask
 from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup
@@ -223,12 +224,10 @@ def training_progress(request):
         .get(pk=request.user.pk)
     )
 
-    progresses = request.user.trainingprogress_set
-    last_get_involved = (
-        progresses.filter(requirement__name="Get Involved")
-        .order_by("-created_at")
-        .first()
-    )
+    progress_training = get_passed_or_last_progress(request.user, "Training")
+    progress_get_involved = get_passed_or_last_progress(request.user, "Get Involved")
+    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(
@@ -243,16 +242,19 @@ def training_progress(request):
             get_involved_form.save()
 
             messages.success(
-                request, "Your Get Involved submission will be evaluated soon."
+                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,
-        "get_involved_in_evaluation": (
-            last_get_involved is not None and last_get_involved.state == "n"
-        ),
+        "progress_training": progress_training,
+        "progress_get_involved": progress_get_involved,
+        "progress_welcome": progress_welcome,
+        "progress_demo": progress_demo,
     }
     return render(request, "dashboard/training_progress.html", context)
 
diff --git a/amy/workshops/templatetags/training_progress.py b/amy/workshops/templatetags/training_progress.py
--- a/amy/workshops/templatetags/training_progress.py
+++ b/amy/workshops/templatetags/training_progress.py
@@ -1,3 +1,5 @@
+from datetime import datetime, timedelta
+
 from django import template
 from django.template.defaultfilters import escape
 from django.utils.safestring import mark_safe
@@ -8,25 +10,24 @@
 
 
 @register.simple_tag
-def progress_label(progress):
-    assert isinstance(progress, TrainingProgress)
-
+def progress_state_class(state):
     switch = {
         "n": "warning",
         "f": "danger",
         "a": "info",
         "p": "success",
     }
-    additional_label = switch[progress.state]
+    return switch[state]
 
-    fmt = "badge badge-{}".format(additional_label)
+
+@register.simple_tag
+def progress_label(progress: TrainingProgress):
+    fmt = f"badge badge-{progress_state_class(progress.state)}"
     return mark_safe(fmt)
 
 
 @register.simple_tag
-def progress_description(progress):
-    assert isinstance(progress, TrainingProgress)
-
+def progress_description(progress: TrainingProgress):
     # build involvement details as needed
     if progress.requirement.name == "Get Involved" and progress.involvement_type:
         involvement = "<br />"
@@ -53,3 +54,38 @@ def progress_description(progress):
     )
     text = text[0].upper() + text[1:]
     return mark_safe(text)
+
+
+@register.simple_tag
+def checkout_deadline(start_date):
+    # we allow 90 days for checkout
+    return start_date + timedelta(days=90)
+
+
+@register.simple_tag
+def welcome_instructions(date: datetime | None = None):
+    """Show different Etherpad links dependent on the time of year.
+
+    From January to October, show just this year's Etherpad link.
+    From November to December, show this year's and next year's Etherpad links.
+    """
+    if date is None:
+        date = datetime.now()
+    text = "<p>Register for a Welcome Session on "
+    if date.month >= 11:
+        text += (
+            f"one of these Etherpads: "
+            f'<a href="https://pad.carpentries.org/welcome-sessions-{ date.year }">'
+            f"Welcome Sessions { date.year }</a>; "
+            f'<a href="https://pad.carpentries.org/welcome-sessions-{ date.year+1 }">'
+            f"Welcome Sessions { date.year+1 }</a>."
+        )
+    else:
+        text += (
+            f"this Etherpad: "
+            f'<a href="https://pad.carpentries.org/welcome-sessions-{ date.year }">'
+            f"Welcome Sessions { date.year }</a>."
+        )
+    text += "</p>"
+
+    return mark_safe(text)


Test patch:
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
@@ -3,7 +3,7 @@
 from django.urls import reverse
 
 from trainings.models import Involvement
-from workshops.models import Award, Person, TrainingProgress, TrainingRequirement
+from workshops.models import Award, Event, Person, TrainingProgress, TrainingRequirement
 from workshops.tests.base import TestBase
 
 
@@ -39,7 +39,7 @@ def setUp(self):
         self.progress_url = reverse("training-progress")
 
     def test_instructor_badge(self):
-        """When the trainee is awarded both Carpentry Instructor badge,
+        """When the trainee is awarded a Carpentries Instructor badge,
         we want to display that info in the dashboard."""
 
         Award.objects.create(
@@ -48,14 +48,30 @@ def test_instructor_badge(self):
             awarded=datetime(2016, 6, 1, 15, 0),
         )
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Congratulations, you're a certified")
+        self.assertContains(rv, "Congratulations, you're a certified Instructor!")
         self.assertIn(self.instructor_badge, rv.context["user"].instructor_badges)
 
-    def test_neither_swc_nor_dc_instructor(self):
+    def test_not_an_instructor(self):
         """Check that we don't display that the trainee is an instructor if
         they don't have appropriate badge."""
         rv = self.client.get(self.progress_url)
-        self.assertNotContains(rv, "Congratulations, you're certified")
+        self.assertNotContains(rv, "Congratulations, you're a certified Instructor!")
+        self.assertContains(
+            rv, "If you have recently completed a training or step towards checkout"
+        )
+
+    def test_progress_but_not_eligible(self):
+        """Check the correct alert is displayed when some progress is completed."""
+        TrainingProgress.objects.create(
+            trainee=self.admin,
+            requirement=TrainingRequirement.objects.get(name="Welcome Session"),
+            state="p",
+        )
+        rv = self.client.get(self.progress_url)
+        self.assertNotContains(rv, "Congratulations, you're a certified Instructor!")
+        self.assertContains(
+            rv, "Please review your progress towards Instructor certification below."
+        )
 
     def test_eligible_but_not_awarded(self):
         """Test what is displayed when a trainee is eligible to be certified
@@ -91,7 +107,11 @@ def test_eligible_but_not_awarded(self):
 
         rv = self.client.get(self.progress_url)
 
-        self.assertNotContains(rv, "Congratulations, you're certified")
+        self.assertContains(
+            rv,
+            "You have successfully completed all steps towards Instructor "
+            "certification",
+        )
 
 
 class TestInstructorTrainingStatus(TestBase):
@@ -100,31 +120,57 @@ class TestInstructorTrainingStatus(TestBase):
 
     def setUp(self):
         self._setUpUsersAndLogin()
+        self._setUpTags()
+        self._setUpOrganizations()
+
+        self.event = Event.objects.create(
+            slug="event-ttt",
+            start=date(2023, 6, 4),
+            end=date(2023, 6, 5),
+            host=self.org_alpha,
+        )
         self.training = TrainingRequirement.objects.get(name="Training")
         self.progress_url = reverse("training-progress")
 
     def test_training_passed(self):
-        TrainingProgress.objects.create(trainee=self.admin, requirement=self.training)
+        TrainingProgress.objects.create(
+            trainee=self.admin, requirement=self.training, event=self.event
+        )
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Training passed")
+        self.assertContains(
+            rv,
+            '<p>Training <span class="badge-success">passed</span> '
+            "as of June 5, 2023.</p>",
+            html=True,
+        )
 
     def test_training_failed(self):
         TrainingProgress.objects.create(
-            trainee=self.admin, requirement=self.training, state="f"
+            trainee=self.admin, requirement=self.training, event=self.event, state="f"
         )
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Training not passed yet")
+        self.assertContains(
+            rv,
+            '<p>Training <span class="badge-danger">failed</span> '
+            "as of June 5, 2023.</p>",
+            html=True,
+        )
 
     def test_training_asked_to_repeat(self):
         TrainingProgress.objects.create(
-            trainee=self.admin, requirement=self.training, state="a"
+            trainee=self.admin, requirement=self.training, event=self.event, state="a"
         )
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Training not passed yet")
+        self.assertContains(
+            rv,
+            '<p>Training <span class="badge-info">asked to repeat</span> '
+            "as of June 5, 2023.</p>",
+            html=True,
+        )
 
     def test_training_not_finished(self):
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Training not passed yet")
+        self.assertContains(rv, "Training not completed yet")
 
 
 class TestGetInvolvedStatus(TestBase):
@@ -158,7 +204,12 @@ def test_get_involved_waiting_to_be_evaluated(self):
             url="https://example.org",
         )
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Get Involved step evaluation pending")
+        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,
+        )
 
     def test_get_involved_passed(self):
         TrainingProgress.objects.create(
@@ -169,7 +220,12 @@ def test_get_involved_passed(self):
             url="https://example.org",
         )
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Get Involved submission accepted")
+        self.assertContains(
+            rv,
+            '<p>Get Involved <span class="badge-success">passed</span> '
+            f'as of {datetime.today().strftime("%B %-d, %Y")}.</p>',
+            html=True,
+        )
 
     def test_submission_form(self):
         data = {
@@ -181,7 +237,9 @@ def test_submission_form(self):
         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")
-        self.assertContains(rv, "Your Get Involved submission will be evaluated soon.")
+        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"
@@ -233,22 +291,36 @@ def setUp(self):
         self._setUpUsersAndLogin()
         self.welcome = TrainingRequirement.objects.get(name="Welcome Session")
         self.progress_url = reverse("training-progress")
+        self.SESSION_LINK_TEXT = "Register for a Welcome Session on"
 
     def test_session_passed(self):
         TrainingProgress.objects.create(trainee=self.admin, requirement=self.welcome)
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Welcome Session passed")
+        self.assertContains(
+            rv,
+            '<p>Welcome Session <span class="badge-success">completed</span> '
+            f'as of {datetime.today().strftime("%B %-d, %Y")}.</p>',
+            html=True,
+        )
+        self.assertNotContains(rv, self.SESSION_LINK_TEXT)
 
     def test_session_failed(self):
         TrainingProgress.objects.create(
             trainee=self.admin, requirement=self.welcome, state="f"
         )
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Welcome Session not passed yet")
+        self.assertContains(
+            rv,
+            '<p>Welcome Session <span class="badge-danger">failed</span> '
+            f'as of {datetime.today().strftime("%B %-d, %Y")}.</p>',
+            html=True,
+        )
+        self.assertNotContains(rv, self.SESSION_LINK_TEXT)
 
     def test_no_participation_in_a_session_yet(self):
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Welcome Session not passed yet")
+        self.assertContains(rv, "Welcome Session not completed yet")
+        self.assertContains(rv, self.SESSION_LINK_TEXT)
 
 
 class TestDemoSessionStatus(TestBase):
@@ -261,29 +333,46 @@ def setUp(self):
             name="Demo", defaults={}
         )
         self.progress_url = reverse("training-progress")
-        self.SESSION_LINK_TEXT = "You can register for a Demo Session on"
+        self.SESSION_LINK_TEXT = "Register for a Demo Session on"
 
     def test_session_passed(self):
         TrainingProgress.objects.create(trainee=self.admin, requirement=self.demo)
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Demo Session passed")
+        self.assertContains(
+            rv,
+            '<p>Demo <span class="badge-success">passed</span> '
+            f'as of {datetime.today().strftime("%B %-d, %Y")}.</p>',
+            html=True,
+        )
         self.assertNotContains(rv, self.SESSION_LINK_TEXT)
 
+    def test_session_asked_to_repeat(self):
+        TrainingProgress.objects.create(
+            trainee=self.admin, requirement=self.demo, state="a"
+        )
+        rv = self.client.get(self.progress_url)
+        self.assertContains(
+            rv,
+            '<p>Demo <span class="badge-info">asked to repeat</span> '
+            f'as of {datetime.today().strftime("%B %-d, %Y")}.</p>',
+            html=True,
+        )
+        self.assertContains(rv, self.SESSION_LINK_TEXT)
+
     def test_session_failed(self):
         TrainingProgress.objects.create(
             trainee=self.admin, requirement=self.demo, state="f"
         )
         rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Demo Session not completed")
-        self.assertContains(rv, self.SESSION_LINK_TEXT)
+        self.assertContains(
+            rv,
+            '<p>Demo <span class="badge-danger">failed</span> '
+            f'as of {datetime.today().strftime("%B %-d, %Y")}.</p>',
+            html=True,
+        )
+        self.assertNotContains(rv, self.SESSION_LINK_TEXT)
 
     def test_no_participation_in_a_session_yet(self):
         rv = self.client.get(self.progress_url)
         self.assertContains(rv, "Demo Session not completed")
         self.assertContains(rv, self.SESSION_LINK_TEXT)
-
-    def test_no_registration_instruction_when_trainee_passed_session(self):
-        TrainingProgress.objects.create(trainee=self.admin, requirement=self.demo)
-        rv = self.client.get(self.progress_url)
-        self.assertContains(rv, "Demo Session passed")
-        self.assertNotContains(rv, self.SESSION_LINK_TEXT)
diff --git a/amy/dashboard/tests/test_utils.py b/amy/dashboard/tests/test_utils.py
new file mode 100644
--- /dev/null
+++ b/amy/dashboard/tests/test_utils.py
@@ -0,0 +1,98 @@
+from dashboard.utils import get_passed_or_last_progress
+from workshops.models import TrainingProgress, TrainingRequirement
+from workshops.tests.base import TestBase
+
+
+class TestGetLastOrPassedProgress(TestBase):
+    def setUp(self):
+        super().setUp()
+        self._setUpTags()
+
+        # set up some progresses
+        self.requirement, _ = TrainingRequirement.objects.get_or_create(name="Demo")
+
+    def test_no_progress(self):
+        """Should return None if no progress present."""
+        # Act
+        got_progress = get_passed_or_last_progress(
+            self.spiderman, self.requirement.name
+        )
+        # Assert
+        self.assertIsNone(got_progress)
+
+    def test_single_passed_progress(self):
+        """Only one progress, and it's passed"""
+        # Arrange
+        expected = TrainingProgress.objects.create(
+            trainee=self.spiderman, requirement=self.requirement, state="p"
+        )
+        # Act
+        got_progress = get_passed_or_last_progress(
+            self.spiderman, self.requirement.name
+        )
+        # Assert
+        self.assertEqual(expected, got_progress)
+
+    def test_single_nonpassed_progress(self):
+        """Only one progress, and it's not passed"""
+        # Arrange
+        expected = TrainingProgress.objects.create(
+            trainee=self.spiderman, requirement=self.requirement, state="f"
+        )
+        # Act
+        got_progress = get_passed_or_last_progress(
+            self.spiderman, self.requirement.name
+        )
+        # Assert
+        self.assertEqual(expected, got_progress)
+
+    def test_passed_progress_prioritised(self):
+        """Passed progress should be returned even if later progresses exist"""
+        # Arrange
+        TrainingProgress.objects.create(
+            trainee=self.spiderman, requirement=self.requirement, state="a"
+        )
+        expected = TrainingProgress.objects.create(
+            trainee=self.spiderman, requirement=self.requirement, state="p"
+        )
+        TrainingProgress.objects.create(
+            trainee=self.spiderman, requirement=self.requirement, state="f"
+        )
+        # Act
+        got_progress = get_passed_or_last_progress(
+            self.spiderman, self.requirement.name
+        )
+        # Assert
+        self.assertEqual(expected, got_progress)
+
+    def test_recent_progress_no_pass(self):
+        """The most recent progress should be returned if none are passed"""
+        # Arrange
+        TrainingProgress.objects.create(
+            trainee=self.spiderman, requirement=self.requirement, state="a"
+        )
+        expected = TrainingProgress.objects.create(
+            trainee=self.spiderman, requirement=self.requirement, state="a"
+        )
+        # Act
+        got_progress = get_passed_or_last_progress(
+            self.spiderman, self.requirement.name
+        )
+        # Assert
+        self.assertEqual(expected, got_progress)
+
+    def test_recent_passed_progress(self):
+        """The most recent passed progress should be returned if multiple are passed"""
+        # Arrange
+        TrainingProgress.objects.create(
+            trainee=self.spiderman, requirement=self.requirement, state="p"
+        )
+        expected = TrainingProgress.objects.create(
+            trainee=self.spiderman, requirement=self.requirement, state="p"
+        )
+        # Act
+        got_progress = get_passed_or_last_progress(
+            self.spiderman, self.requirement.name
+        )
+        # Assert
+        self.assertEqual(expected, got_progress)
diff --git a/amy/trainings/tests/test_template_tags.py b/amy/trainings/tests/test_template_tags.py
--- a/amy/trainings/tests/test_template_tags.py
+++ b/amy/trainings/tests/test_template_tags.py
@@ -1,12 +1,14 @@
-from datetime import date
+from datetime import date, datetime
 
 from django.test import TestCase
 
 from trainings.models import Involvement
 from workshops.models import Person, TrainingProgress, TrainingRequirement
 from workshops.templatetags.training_progress import (
+    checkout_deadline,
     progress_description,
     progress_label,
+    welcome_instructions,
 )
 
 
@@ -125,3 +127,70 @@ def test_progress_labels(self):
             )
             got = progress_label(progress)
             self.assertEqual(expected[state], got)
+
+
+class TestCheckoutDeadlineTemplateTag(TestCase):
+    def test_checkout_deadline(self):
+        # Arrange
+        start = date(2023, 7, 25)
+        # Act
+        got = checkout_deadline(start)
+        # Assert
+        expected = date(2023, 10, 23)
+        self.assertEqual(expected, got)
+
+
+class TestWelcomeInstructionsTemplateTag(TestCase):
+    def test_welcome_instructions__early_year(self):
+        # Arrange
+        date = datetime(2020, 1, 1)
+
+        # Act
+        got = welcome_instructions(date)
+
+        # Assert
+        expected = (
+            "<p>Register for a Welcome Session on this Etherpad: "
+            '<a href="https://pad.carpentries.org/welcome-sessions-2020">'
+            "Welcome Sessions 2020</a>."
+        )
+        self.assertHTMLEqual(expected, got)
+
+    def test_welcome_instructions__late_year(self):
+        # Arrange
+        date = datetime(2020, 11, 1)
+
+        # Act
+        got = welcome_instructions(date)
+
+        # Assert
+        expected = (
+            "<p>Register for a Welcome Session on one of these Etherpads: "
+            '<a href="https://pad.carpentries.org/welcome-sessions-2020">'
+            "Welcome Sessions 2020</a>; "
+            '<a href="https://pad.carpentries.org/welcome-sessions-2021">'
+            "Welcome Sessions 2021</a>."
+        )
+        self.assertHTMLEqual(expected, got)
+
+    def test_welcome_instructions__now(self):
+        # Arrange
+        date = datetime.now()
+
+        # Act
+        got = welcome_instructions()
+
+        # Assert
+        if date.month >= 11:
+            expected_list = [
+                "one of these Etherpads:",
+                f"Welcome Sessions {date.year}",
+                f"Welcome Sessions {date.year+1}",
+            ]
+        else:
+            expected_list = [
+                "this Etherpad:",
+                f"Welcome Sessions {date.year}",
+            ]
+        for expected in expected_list:
+            self.assertIn(expected, got)