diff --git a/ynr/apps/people/README.md b/ynr/apps/people/README.md index 438de8dae..d83094335 100644 --- a/ynr/apps/people/README.md +++ b/ynr/apps/people/README.md @@ -10,3 +10,25 @@ standing in. Thing like their political party should be stored on the _candidacy_. This is currently represented as a `Membership` in the `popolo` app. +# Person Identifiers + +`PersonIdentifiers` are used to store contact information and online presence for a candidate. Users can add/edit these identifiers in the person update form. The following fields are available in the `PersonIdentifier` model with the corresponding form labels and accepted formats: +``` +email = "Email": Accepts a valid email address; raises an exception if the email is submitted with an invalid format. The email address must be unique. +facebook_page_url = "Facebook Page": Accepts a valid URL but does not currently validate the domain. +facebook_personal_url = "Facebook Personal": Same as above. +homepage_url = "Homepage": Accepts a valid URL +blog_url = "Blog": Accepts a valid URL +linkedin_url = "Linkedin": Accepts a valid URL; does not currently validate the domain or the format of the username. +party_ppc_page_url = "Party Candidate Page": Accepts a valid URL +twitter_username = "Twitter": Accepts a Twitter username and returns the full Twitter URL.No longer validates the username actually exists. +mastodon_username = "Mastodon": Accepts and returns a valid Mastodon URL and validates the domain and username. +wikipedia_url = "Wikipedia": Accepts a valid URL; does not currently validate the domain. +wikidata_id = "Wikidata": Accepts a valid Wikidata ID +youtube_profile = "YouTube Profile": Accepts a valid URL; does not currently validate the domain. +instagram_url = "Instagram Profile": Accepts and returns a valid Instagram URL and validates the domain and format of the username. +blue_sky_url = "Bluesky URL": Accepts and returns a valid URL +threads_url = "Threads URL": Accepts and returns a valid URL +tiktok_url = "TikTok URL": Accepts and returns a valid URL +other_url = "Other URL": Accepts and returns a valid URL +``` \ No newline at end of file diff --git a/ynr/apps/people/forms/forms.py b/ynr/apps/people/forms/forms.py index 16dcca803..fe9013632 100644 --- a/ynr/apps/people/forms/forms.py +++ b/ynr/apps/people/forms/forms.py @@ -19,6 +19,7 @@ StrippedCharField, ) from people.helpers import ( + clean_instagram_url, clean_mastodon_username, clean_twitter_username, clean_wikidata_id, @@ -116,6 +117,17 @@ def clean(self): self.add_error(None, e) return self.cleaned_data + def clean_instagram_url(self, username): + if self.instance.value != username: + self.instance.internal_identifier = None + if self.instance.internal_identifier: + return username + try: + return clean_instagram_url(username) + except ValueError as e: + raise ValidationError(e) + return username + def clean_twitter_username(self, username): if self.instance.value != username: self.instance.internal_identifier = None diff --git a/ynr/apps/people/helpers.py b/ynr/apps/people/helpers.py index a8df99634..151ab255d 100644 --- a/ynr/apps/people/helpers.py +++ b/ynr/apps/people/helpers.py @@ -120,6 +120,30 @@ def clean_twitter_username(username): return username +def clean_instagram_url(url): + parsed_username = urlparse(url) + if parsed_username.netloc not in [ + "instagram.com", + "www.instagram.com", + "instagr.am", + "www.instagr.am", + ]: + raise ValueError( + "The Instagram URL must be from a valid Instagram domain." + ) + username = parsed_username.path.strip("/") + if not re.search( + r"^(?!.*[_.]{2})(?!.*[_.]$)[a-zA-Z0-9._]{1,30}$", + username, + ): + raise ValueError( + "This is not a valid Instagram username. Please try again." + ) + return "https://{domain}/{username}".format( + domain=parsed_username.netloc, username=username + ) + + def clean_wikidata_id(identifier): identifier = identifier.strip().lower() m = re.search(r"^.*wikidata.org/(wiki|entity)/(\w+)", identifier) diff --git a/ynr/apps/people/management/commands/reformat_person_identifiers.py b/ynr/apps/people/management/commands/reformat_person_identifiers.py new file mode 100644 index 000000000..2d0373c49 --- /dev/null +++ b/ynr/apps/people/management/commands/reformat_person_identifiers.py @@ -0,0 +1,31 @@ +from django.core.management import call_command +from django.core.management.base import BaseCommand +from people.models import PersonIdentifier + + +class Command(BaseCommand): + def handle(self, *args, **options): + """ + Iterate over all PersonIdentifier objects and reformat urls as needed. + """ + + person_identifiers = PersonIdentifier.objects.all().filter( + value_type="instagram_url" + ) + for identifier in person_identifiers: + if identifier.value.startswith("https"): + self.stdout.write( + f"URL for {identifier.person.name} is already in the correct format." + ) + pass + + if identifier.value.startswith("@"): + identifier.value = ( + f"https://www.instagram.com/{identifier.value[1:]}" + ) + identifier.save() + self.stdout.write( + f"Reformatted LinkedxIn URL for {identifier.person.name} to {identifier.value}" + ) + + call_command("identify_inactive_person_links") diff --git a/ynr/apps/people/models.py b/ynr/apps/people/models.py index 9eb3eadb3..e8eae5873 100644 --- a/ynr/apps/people/models.py +++ b/ynr/apps/people/models.py @@ -169,6 +169,8 @@ def get_value_html(self): if self.value_type == "twitter_username": url = format_html("https://twitter.com/{}", self.value) + if self.value_type == "instagram_url": + url = self.value if self.value.startswith("http"): url = format_html("{}", self.value) diff --git a/ynr/apps/people/tests/test_person_form_identifier_crud.py b/ynr/apps/people/tests/test_person_form_identifier_crud.py index d081f0249..218266c82 100644 --- a/ynr/apps/people/tests/test_person_form_identifier_crud.py +++ b/ynr/apps/people/tests/test_person_form_identifier_crud.py @@ -150,7 +150,6 @@ def _submit_values(self, value, value_type="twitter_username"): form["source"] = "They changed their username" form["tmp_person_identifiers-0-value_type"] = value_type form["tmp_person_identifiers-0-value"] = value - form.submit() return form.submit() def _submit_mastodon_values(self, value, value_type="mastodon_username"): @@ -163,7 +162,30 @@ def _submit_mastodon_values(self, value, value_type="mastodon_username"): form["source"] = "They changed their username" form["tmp_person_identifiers-0-value_type"] = value_type form["tmp_person_identifiers-0-value"] = value - form.submit() + return form.submit() + + def _submit_mastodon_values(self, value, value_type="mastodon_username"): + resp = self.app.get( + reverse("person-update", kwargs={"person_id": self.person.pk}), + user=self.user, + ) + + form = resp.forms[1] + form["source"] = "They changed their username" + form["tmp_person_identifiers-0-value_type"] = value_type + form["tmp_person_identifiers-0-value"] = value + return form.submit() + + def _submit_instagram_values(self, value, value_type="instagram_url"): + resp = self.app.get( + reverse("person-update", kwargs={"person_id": self.person.pk}), + user=self.user, + ) + + form = resp.forms[1] + form["source"] = "They changed their username" + form["tmp_person_identifiers-0-value_type"] = value_type + form["tmp_person_identifiers-0-value"] = value return form.submit() def test_twitter_bad_url(self): @@ -185,6 +207,41 @@ def test_twitter_full_url(self): PersonIdentifier.objects.get().value, "madeuptwitteraccount" ) + def test_clean_instagram_url(self): + resp = self._submit_instagram_values( + "https://www.instagr.am/disco_dude" + ) + self.assertEqual(resp.status_code, 302) + instagram_url_qs = PersonIdentifier.objects.filter( + value_type="instagram_url" + ) + self.assertEqual(instagram_url_qs.count(), 1) + self.assertEqual( + instagram_url_qs[0].value, + "https://www.instagr.am/disco_dude", + ) + + def test_bad_instagram_domain(self): + resp = self._submit_instagram_values("www.instagl.am/blah") + form = resp.context["identifiers_formset"] + self.assertFalse(form.is_valid()) + self.assertEqual( + form[0].non_field_errors(), + ["The Instagram URL must be from a valid Instagram domain."], + ) + + def test_bad_instagram_username(self): + resp = self._submit_instagram_values( + "https://www.instagr.am/________blah" + ) + self.assertEqual(resp.status_code, 200) + form = resp.context["identifiers_formset"] + self.assertFalse(form.is_valid()) + self.assertEqual( + form[0].non_field_errors(), + ["This is not a valid Instagram username. Please try again."], + ) + def test_mastodon_bad_url(self): # submit a username missing the `@` symbol resp = self._submit_mastodon_values("https://mastodon.social/joe") diff --git a/ynr/apps/people/tests/test_person_identifiers.py b/ynr/apps/people/tests/test_person_identifiers.py index e6abd2295..9916bc24b 100644 --- a/ynr/apps/people/tests/test_person_identifiers.py +++ b/ynr/apps/people/tests/test_person_identifiers.py @@ -33,6 +33,23 @@ def test_get_value_html_twitter(self): # Test the value type HTML self.assertEqual(pi.get_value_type_html, "Twitter") + def test_get_value_html_instagram(self): + pi = PersonIdentifier.objects.create( + person=self.person, + value="https://www.instagram.com/democlub", + value_type="instagram_url", + internal_identifier="2324", + ) + + # Test the value HTML + self.assertEqual( + pi.get_value_html, + """https://www.instagram.com/democlub""", + ) + + # Test the value type HTML + self.assertEqual(pi.get_value_type_html, "Instagram") + def test_get_value_html_mastodon(self): pi = PersonIdentifier.objects.create( person=self.person, diff --git a/ynr/apps/ynr_refactoring/settings.py b/ynr/apps/ynr_refactoring/settings.py index 42f89e619..9322d11fb 100644 --- a/ynr/apps/ynr_refactoring/settings.py +++ b/ynr/apps/ynr_refactoring/settings.py @@ -19,7 +19,7 @@ class PersonIdentifierFields(Enum): wikipedia_url = "Wikipedia" wikidata_id = "Wikidata" youtube_profile = "YouTube Profile" - instagram_url = "Instagram Profile" + instagram_url = "Instagram URL" blue_sky_url = "Bluesky URL" threads_url = "Threads URL" tiktok_url = "TikTok URL"