Repository: carpentries/amy

Issue:
Get involved GH contribution allows repos in non-Carpentries organizations
The instructions refer the trainee to [this list](https://docs.carpentries.org/topic_folders/communications/tools/github_organisations.html). AMY should also use this list to validate the submission.  If the link is not to an issue or PR in one of those organization's repos, the user should get an error that says "Please be sure your contribution is to a repo in one of [these organizations](https://docs.carpentries.org/topic_folders/communications/tools/github_organisations.html)."


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
@@ -166,8 +166,12 @@ class GetInvolvedForm(forms.ModelForm):
     )
     url = forms.URLField(
         label="URL",
-        help_text="A link to the activity, if there is one. For example, a "
-        "workshop website or GitHub contribution.",
+        help_text="A link to the activity, if there is one. If you served "
+        "at a workshop, enter the workshop website. "
+        "If you made a GitHub contribution, enter that link and ensure it is "
+        "associated with a repository in one of the "
+        '<a href="https://docs.carpentries.org/topic_folders/communications/tools/github_organisations.html">'  # noqa
+        "GitHub organisations owned by The Carpentries</a>.",
         required=False,
     )
     trainee_notes = forms.CharField(
diff --git a/amy/scripts/seed_involvements.py b/amy/scripts/seed_involvements.py
--- a/amy/scripts/seed_involvements.py
+++ b/amy/scripts/seed_involvements.py
@@ -30,14 +30,14 @@
         "notes_required": False,
     },
     {
-        "display_name": "Attended an Instructor meeting, regional meetup, or other community meeting",  # noqa
+        "display_name": "Attended a regional meetup, skill-up, or other community meeting",  # noqa
         "name": "Community Meeting",
         "url_required": False,
         "date_required": True,
         "notes_required": True,
     },
     {
-        "display_name": "Submitted a contribution to a Carpentries repository",
+        "display_name": "Submitted a contribution to a Carpentries repository on GitHub",  # noqa
         "name": "GitHub Contribution",
         "url_required": True,
         "date_required": True,
diff --git a/amy/workshops/models.py b/amy/workshops/models.py
--- a/amy/workshops/models.py
+++ b/amy/workshops/models.py
@@ -2612,6 +2612,22 @@ class TrainingProgress(CreatedUpdatedMixin, models.Model):
     )
     notes = models.TextField(blank=True)
 
+    CARPENTRIES_GITHUB_ORGS = [
+        "carpentries",
+        "datacarpentry",
+        "librarycarpentry",
+        "swcarpentry",
+        "carpentries-es",
+        "Reproducible-Science-Curriculum",
+        "CarpentryCon",
+        "CarpentryConnect",
+        "carpentries-workshops",
+        "data-lessons",
+        "carpentries-lab",
+        "carpentries-incubator",
+        "carpentrieslab",
+    ]
+
     def get_absolute_url(self):
         return reverse("trainingprogress_edit", args=[str(self.id)])
 
@@ -2651,13 +2667,22 @@ def clean_url(
                 and involvement_type.url_required
             ):
                 return self.get_required_error(involvement_type)
-
-        elif (
-            self.url
-            and not requirement.url_required
-            and not requirement.involvement_required
-        ):
-            return self.get_not_required_error(involvement_type)
+        else:
+            if not requirement.url_required and not requirement.involvement_required:
+                return self.get_not_required_error(involvement_type)
+            elif involvement_type and involvement_type.name == "GitHub Contribution":
+                return self.clean_github_url(self.url)
+
+    def clean_github_url(self, url):
+        """Check if URL is associated with a Carpentries GitHub organisation"""
+        if not any(f"github.com/{org}" in url for org in self.CARPENTRIES_GITHUB_ORGS):
+            msg = (
+                "This URL is not associated with a repository in any of the "
+                "GitHub organisations owned by The Carpentries. "
+                "If you need help resolving this error, please contact us using the "
+                "details at the top of this form."
+            )
+            return ValidationError(msg)
 
     def clean_event(
         self,


Test patch:
diff --git a/amy/api/tests/test_export.py b/amy/api/tests/test_export.py
--- a/amy/api/tests/test_export.py
+++ b/amy/api/tests/test_export.py
@@ -208,7 +208,7 @@ def prepare_data(self, user):
             involvement_type=github_contribution,
             state="a",  # asked to repeat
             event=None,
-            url="http://example.org/lesson",
+            url="https://github.com/carpentries",
             date=datetime.date(2023, 5, 31),
             trainee_notes="Notes submitted by trainee",
         )
@@ -564,7 +564,7 @@ def test_relational_fields_structure(self):
                 },
                 "state": "Asked to repeat",
                 "event": None,
-                "url": "http://example.org/lesson",
+                "url": "https://github.com/carpentries",
                 "date": "2023-05-31",
                 "trainee_notes": "Notes submitted by trainee",
             },
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
@@ -21,9 +21,9 @@ def setUp(self):
             },
         )
         self.involvement, _ = Involvement.objects.get_or_create(
-            name="GitHub Contribution",
+            name="Workshop Instructor/Helper",
             defaults={
-                "display_name": "GitHub Contribution",
+                "display_name": "Workshop Instructor/Helper",
                 "url_required": True,
                 "date_required": True,
             },
@@ -69,6 +69,7 @@ def test_create_view_loads(self):
 
     def test_create_view_does_not_show_archived_involvements(self):
         # Arrange
+        self.github_contribution = Involvement.objects.get(name="GitHub Contribution")
         self.involvement_to_be_archived.archive()
 
         # Act
@@ -80,7 +81,14 @@ def test_create_view_does_not_show_archived_involvements(self):
             c[0].instance.pk
             for c in rv.context["form"].fields["involvement_type"].choices
         ]
-        self.assertEqual(choices, [self.involvement.pk, self.involvement_other.pk])
+        self.assertEqual(
+            choices,
+            [
+                self.github_contribution.pk,
+                self.involvement.pk,
+                self.involvement_other.pk,
+            ],
+        )
 
     def test_create_view_does_not_show_admin_fields(self):
         # Act
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
@@ -83,9 +83,12 @@ def test_eligible_but_not_awarded(self):
             "Welcome Session",
             "Demo",
         ]
+        workshop_instructor, _ = Involvement.objects.get_or_create(
+            name="Workshop Instructor/Helper"
+        )
         for requirement in requirements:
             if requirement == "Get Involved":
-                involvement = Involvement.objects.get(name="GitHub Contribution")
+                involvement = workshop_instructor
                 date = datetime.today()
                 url = "https://example.com"
             else:
@@ -182,8 +185,13 @@ def setUp(self):
         self.get_involved, _ = TrainingRequirement.objects.get_or_create(
             name="Get Involved", defaults={"involvement_required": True}
         )
-        self.github_contribution, _ = Involvement.objects.get_or_create(
-            name="GitHub Contribution", defaults={"url_required": True}
+        self.workshop_instructor, _ = Involvement.objects.get_or_create(
+            name="Workshop Instructor/Helper",
+            defaults={
+                "display_name": "Served as an Instructor or a helper at a Carpentries "
+                "workshop",
+                "url_required": True,
+            },
         )
         self.other_involvement, _ = Involvement.objects.get_or_create(
             name="Other", defaults={"display_name": "Other", "notes_required": True}
@@ -194,15 +202,15 @@ def setUp(self):
         self.progress = TrainingProgress.objects.create(
             trainee=self.admin,
             requirement=self.get_involved,
-            involvement_type=self.github_contribution,
+            involvement_type=self.workshop_instructor,
             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/>"
+            "<strong>Activity:</strong> Served as an Instructor or a helper at a "
+            "Carpentries workshop<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"
@@ -305,11 +313,12 @@ def test_get_involved_asked_to_repeat(self):
 
     def test_get_involved_details_not_provided(self):
         """Check that optional fields are summarised correctly when empty"""
+        # Arrange
         self.progress.delete()
         TrainingProgress.objects.create(
             trainee=self.admin,
             requirement=self.get_involved,
-            involvement_type=self.github_contribution,
+            involvement_type=self.workshop_instructor,
             state="n",
         )
 
@@ -319,8 +328,8 @@ def test_get_involved_details_not_provided(self):
         # Assert
         PROGRESS_SUMMARY_BLOCK = (
             "<p>"
-            "<strong>Activity:</strong> Submitted a contribution to a Carpentries "
-            "repository<br/>"
+            "<strong>Activity:</strong> Served as an Instructor or a helper at a "
+            "Carpentries workshop<br/>"
             "<strong>Date:</strong> No date provided<br/>"
             "<strong>URL:</strong> No URL provided<br/>"
             "<strong>Notes:</strong> No notes provided"
diff --git a/amy/trainings/tests/test_filters.py b/amy/trainings/tests/test_filters.py
--- a/amy/trainings/tests/test_filters.py
+++ b/amy/trainings/tests/test_filters.py
@@ -42,9 +42,9 @@ def setUp(self) -> None:
             involvement_required=True,
         )
         self.url_and_date_required, _ = Involvement.objects.get_or_create(
-            name="GitHub Contribution",
+            name="Workshop Instructor/Helper",
             defaults={
-                "display_name": "GitHub Contribution",
+                "display_name": "Workshop Instructor/Helper",
                 "url_required": True,
                 "date_required": True,
             },
diff --git a/amy/trainings/tests/test_trainees.py b/amy/trainings/tests/test_trainees.py
--- a/amy/trainings/tests/test_trainees.py
+++ b/amy/trainings/tests/test_trainees.py
@@ -34,7 +34,7 @@ def setUp(self):
         self.welcome = TrainingRequirement.objects.get(name="Welcome Session")
         self.demo = TrainingRequirement.objects.get(name="Demo")
         self.involvement, _ = Involvement.objects.get_or_create(
-            name="GitHub Contribution", defaults={"url_required": True}
+            name="Workshop Instructor/Helper", defaults={"url_required": True}
         )
 
         self.ttt_event = Event.objects.create(
diff --git a/amy/trainings/tests/test_training_progress.py b/amy/trainings/tests/test_training_progress.py
--- a/amy/trainings/tests/test_training_progress.py
+++ b/amy/trainings/tests/test_training_progress.py
@@ -40,9 +40,9 @@ def setUp(self):
             involvement_required=True,
         )
         self.url_and_date_required, _ = Involvement.objects.get_or_create(
-            name="GitHub Contribution",
+            name="Workshop Instructor/Helper",
             defaults={
-                "display_name": "GitHub Contribution",
+                "display_name": "Workshop Instructor/Helper",
                 "url_required": True,
                 "date_required": True,
             },
@@ -113,6 +113,33 @@ def test_url_must_be_blank(self):
         p3.full_clean()
         p4.full_clean()  # involvement URLs can be optional
 
+    def test_url_associated_with_github_organisation(self):
+        github_url_required, _ = Involvement.objects.get_or_create(
+            name="GitHub Contribution",
+            defaults={
+                "display_name": "GitHub Contribution",
+                "url_required": True,
+                "date_required": True,
+            },
+        )
+        p1 = TrainingProgress.objects.create(
+            requirement=self.get_involved,
+            involvement_type=github_url_required,
+            trainee=self.admin,
+            url="https://github.com/carpentries/amy/issues/2470",
+            date=datetime(2023, 5, 31),
+        )
+        p2 = TrainingProgress.objects.create(
+            requirement=self.get_involved,
+            involvement_type=github_url_required,
+            trainee=self.admin,
+            url="http://example.com",
+            date=datetime(2023, 5, 31),
+        )
+        p1.full_clean()
+        with self.assertValidationErrors(["url"]):
+            p2.full_clean()
+
     def test_event_is_required(self):
         p1 = TrainingProgress.objects.create(
             requirement=self.requirement, trainee=self.admin
@@ -414,7 +441,7 @@ def setUp(self):
                 "date_required": True,
             },
         )
-        self.involvement2, _ = Involvement.objects.get_or_create(
+        self.involvement_to_be_archived, _ = Involvement.objects.get_or_create(
             name="To be archived",
             defaults={
                 "display_name": "To be archived",
@@ -453,7 +480,7 @@ def test_create_view_works_with_initial_trainee(self):
         self.assertEqual(int(rv.context["form"].initial["trainee"]), self.ironman.pk)
 
     def test_create_view_does_not_show_archived_involvements(self):
-        self.involvement2.archive()
+        self.involvement_to_be_archived.archive()
         rv = self.client.get(
             reverse("trainingprogress_add"), {"type": self.get_involved.pk}
         )