diff --git a/amy/extforms/forms.py b/amy/extforms/forms.py index 6b192dadc..21e3dce6c 100644 --- a/amy/extforms/forms.py +++ b/amy/extforms/forms.py @@ -1,5 +1,6 @@ from datetime import date from typing import Iterable, cast +from urllib.parse import urlparse from captcha.fields import ReCaptchaField from crispy_forms.layout import HTML, Div, Field, Layout @@ -39,6 +40,7 @@ class Meta: "review_process", "member_code", "member_code_override", + "eventbrite_url", "personal", "family", "email", @@ -150,6 +152,7 @@ def set_accordion(self, layout: Layout) -> None: "preapproved": [ self["member_code"], self["member_code_override"], + self["eventbrite_url"], ], "open": [], # this option doesn't require any additional fields } @@ -163,6 +166,7 @@ def set_accordion(self, layout: Layout) -> None: layout.fields.remove("review_process") layout.fields.remove("member_code") layout.fields.remove("member_code_override") + layout.fields.remove("eventbrite_url") # insert div+field at previously saved position layout.insert( @@ -248,6 +252,12 @@ def validate_member_code( return errors + def clean_eventbrite_url(self): + """Check that entered URL includes 'eventbrite' in the domain.""" + eventbrite_url = self.cleaned_data.get("eventbrite_url", "") + if eventbrite_url and "eventbrite" not in urlparse(eventbrite_url).hostname: + raise ValidationError("Must be an Eventbrite URL.") + def clean(self): super().clean() errors = dict() diff --git a/amy/extforms/tests/test_training_request_form.py b/amy/extforms/tests/test_training_request_form.py index a87ca36dc..1d76d39b8 100644 --- a/amy/extforms/tests/test_training_request_form.py +++ b/amy/extforms/tests/test_training_request_form.py @@ -18,6 +18,7 @@ class TestTrainingRequestForm(TestBase): MEMBER_CODE_OVERRIDE_EMAIL_WARNING = ( "A member of our team will check the code and follow up with you" ) + INVALID_EVENTBRITE_URL_ERROR = "Must be an Eventbrite URL." def setUp(self): self._setUpUsersAndLogin() @@ -470,3 +471,42 @@ def test_member_code_validation__code_invalid_override_full_request(self): self.assertNotIn( settings.TEMPLATES[0]["OPTIONS"]["string_if_invalid"], msg.body ) + + def test_eventbrite_url_validation__none(self): + """Should not error if no URL is entered.""" + # Arrange + self.setUpMembership() + data = {"eventbrite_url": ""} + + # Act + rv = self.client.post(reverse("training_request"), data=data) + + # Assert + self.assertEqual(rv.status_code, 200) + self.assertNotContains(rv, self.INVALID_EVENTBRITE_URL_ERROR) + + def test_eventbrite_url_validation__invalid(self): + """Should error if a non-Eventbrite URL is entered.""" + # Arrange + self.setUpMembership() + data = {"eventbrite_url": "https://google.com"} + + # Act + rv = self.client.post(reverse("training_request"), data=data) + + # Assert + self.assertEqual(rv.status_code, 200) + self.assertContains(rv, self.INVALID_EVENTBRITE_URL_ERROR) + + def test_eventbrite_url_validation__valid(self): + """Should not error if an Eventbrite URL is entered.""" + # Arrange + self.setUpMembership() + data = {"eventbrite_url": "https://www.eventbrite.com/e/711576483417"} + + # Act + rv = self.client.post(reverse("training_request"), data=data) + + # Assert + self.assertEqual(rv.status_code, 200) + self.assertNotContains(rv, self.INVALID_EVENTBRITE_URL_ERROR) diff --git a/amy/extrequests/filters.py b/amy/extrequests/filters.py index febe56965..b49c8f086 100644 --- a/amy/extrequests/filters.py +++ b/amy/extrequests/filters.py @@ -7,6 +7,7 @@ import django_filters from extrequests.models import SelfOrganisedSubmission, WorkshopInquiryRequest +from extrequests.utils import get_eventbrite_id_from_url_or_return_input from workshops.fields import Select2Widget from workshops.filters import ( AllCountriesFilter, @@ -43,6 +44,11 @@ def __init__(self, data=None, *args, **kwargs): field_name="member_code", lookup_expr="icontains", label="Member code" ) + eventbrite_id = django_filters.CharFilter( + label="Eventbrite ID or URL", + method="filter_eventbrite_id", + ) + state = django_filters.ChoiceFilter( label="State", choices=(("pa", "Pending or accepted"),) + TrainingRequest.STATE_CHOICES, @@ -167,6 +173,25 @@ def filter_member_code_override( return queryset.filter(member_code_override=True) return queryset + def filter_eventbrite_id( + self, queryset: QuerySet, name: str, value: str + ) -> QuerySet: + """ + Returns the queryset filtered by an Eventbrite ID or URL. + Events have multiple possible URLs which all contain the ID, so + if a URL is used, the filter will try to extract and filter by the ID. + If no ID can be found, the filter will use the original input. + """ + + try: + # if input is an integer, assume it to be a partial or full Eventbrite ID + int(value) + except ValueError: + # otherwise, try to extract an ID from the input + value = get_eventbrite_id_from_url_or_return_input(value) + + return queryset.filter(eventbrite_url__icontains=value) + # ------------------------------------------------------------ # WorkshopRequest related filter and filter methods diff --git a/amy/extrequests/templatetags/eventbrite.py b/amy/extrequests/templatetags/eventbrite.py new file mode 100644 index 000000000..c124c7cad --- /dev/null +++ b/amy/extrequests/templatetags/eventbrite.py @@ -0,0 +1,13 @@ +import re + +from django import template + +from extrequests.utils import EVENTBRITE_URL_PATTERN + +register = template.Library() + + +@register.simple_tag +def url_matches_eventbrite_format(url: str) -> bool: + match = re.search(EVENTBRITE_URL_PATTERN, url) + return match is not None diff --git a/amy/extrequests/tests/test_filters.py b/amy/extrequests/tests/test_filters.py index 3930540ac..cc1c6e52c 100644 --- a/amy/extrequests/tests/test_filters.py +++ b/amy/extrequests/tests/test_filters.py @@ -63,6 +63,7 @@ def setUp(self) -> None: person=self.ironman, review_process="preapproved", member_code=self.membership.registration_code, + eventbrite_url="https://www.eventbrite.com/e/711575811407", personal="Tony", family="Stark", email="me@stark.com", @@ -112,6 +113,7 @@ def test_fields(self): "invalid_member_code", "affiliation", "location", + "eventbrite_id", "order_by", }, ) @@ -318,6 +320,28 @@ def test_filter_location(self): # Assert self.assertQuerysetEqual(result, [self.request_ironman]) + def test_filter_eventbrite_id__digits(self): + # Arrange + name = "eventbrite_id" + value = "1407" + + # Act + result = self.filterset.filters[name].filter(self.qs, value) + + # Assert + self.assertQuerysetEqual(result, [self.request_ironman]) + + def test_filter_eventbrite_id__url(self): + # Arrange + name = "eventbrite_id" + value = "https://www.eventbrite.com/myevent?eid=711575811407" + + # Act + result = self.filterset.filters[name].filter(self.qs, value) + + # Assert + self.assertQuerysetEqual(result, [self.request_ironman]) + def test_filter_order_by(self): # Arrange filter_name = "order_by" diff --git a/amy/extrequests/tests/test_template_tags.py b/amy/extrequests/tests/test_template_tags.py index e752ca540..411b746ef 100644 --- a/amy/extrequests/tests/test_template_tags.py +++ b/amy/extrequests/tests/test_template_tags.py @@ -2,7 +2,8 @@ from django.test import TestCase -from amy.extrequests.templatetags.request_membership import ( +from extrequests.templatetags.eventbrite import url_matches_eventbrite_format +from extrequests.templatetags.request_membership import ( membership_active, membership_alert_type, ) @@ -98,3 +99,78 @@ def test_inactive(self): # Assert self.assertEqual(expected, result) + + +class TestUrlMatchesEventbriteFormat(TestCase): + def test_long_url(self): + # Arrange + url = "https://www.eventbrite.com/e/online-instructor-training-7-8-november-2023-tickets-711575811407?aff=oddtdtcreator" # noqa: line too long + + # Act + result = url_matches_eventbrite_format(url) + + # Assert + self.assertTrue(result) + + def test_short_url(self): + # Arrange + url = "www.eventbrite.com/e/711575811407" + + # Act + result = url_matches_eventbrite_format(url) + + # Assert + self.assertTrue(result) + + def test_localised_url__couk(self): + # Arrange + url = "https://www.eventbrite.co.uk/e/711575811407" + + # Act + result = url_matches_eventbrite_format(url) + + # Assert + self.assertTrue(result) + + def test_localised_url__fr(self): + # Arrange + url = "https://www.eventbrite.fr/e/711575811407" + + # Act + result = url_matches_eventbrite_format(url) + + # Assert + self.assertTrue(result) + + def test_admin_url(self): + """Admin url should fail - we don't expect trainees to provide this format""" + # Arrange + url = "https://www.eventbrite.com/myevent?eid=711575811407" + + # Act + result = url_matches_eventbrite_format(url) + + # Assert + self.assertFalse(result) + + def test_non_eventbrite_url(self): + """URLs outside the Eventbrite domain should fail.""" + # Arrange + url = "https://carpentries.org/instructor-training/123123123123/" + + # Act + result = url_matches_eventbrite_format(url) + + # Assert + self.assertFalse(result) + + def test_empty_string(self): + """Empty string should fail.""" + # Arrange + url = "" + + # Act + result = url_matches_eventbrite_format(url) + + # Assert + self.assertFalse(result) diff --git a/amy/extrequests/tests/test_utils.py b/amy/extrequests/tests/test_utils.py index 1cc291eea..4718af414 100644 --- a/amy/extrequests/tests/test_utils.py +++ b/amy/extrequests/tests/test_utils.py @@ -1,9 +1,12 @@ from datetime import date, timedelta +from django.test import TestCase + from extrequests.tests.test_training_request import create_training_request from extrequests.utils import ( MemberCodeValidationError, accept_training_request_and_match_to_event, + get_eventbrite_id_from_url_or_return_input, get_membership_from_training_request_or_raise_error, get_membership_or_none_from_code, get_membership_warnings_after_match, @@ -613,3 +616,35 @@ def test_multiple_warnings(self): # Assert self.assertListEqual(expected, result) + + +class TestGetEventbriteIdFromUrl(TestCase): + def test_long_url(self): + # Arrange + url = "https://www.eventbrite.com/e/online-instructor-training-7-8-november-2023-tickets-711575811407?aff=oddtdtcreator" # noqa: line too long + + # Act + result = get_eventbrite_id_from_url_or_return_input(url) + + # Assert + self.assertEqual(result, "711575811407") + + def test_short_url(self): + # Arrange + url = "https://www.eventbrite.com/e/711575811407" + + # Act + result = get_eventbrite_id_from_url_or_return_input(url) + + # Assert + self.assertEqual(result, "711575811407") + + def test_admin_url(self): + # Arrange + url = "https://www.eventbrite.com/myevent?eid=711575811407" + + # Act + result = get_eventbrite_id_from_url_or_return_input(url) + + # Assert + self.assertEqual(result, "711575811407") diff --git a/amy/extrequests/utils.py b/amy/extrequests/utils.py index 48ba86bec..6d08d0cab 100644 --- a/amy/extrequests/utils.py +++ b/amy/extrequests/utils.py @@ -1,4 +1,5 @@ from datetime import date +import re from django.core.exceptions import ValidationError @@ -170,3 +171,33 @@ def get_membership_warnings_after_match( ) return warnings + + +# ---------------------------------------- +# Utilities for Eventbrite URLs +# ---------------------------------------- + +# Eventbrite IDs are long strings of digits (~12 characters) +EVENTBRITE_ID_PATTERN = re.compile(r"\d{10,}") + +# regex to cover known forms of Eventbrite URL that trainees could provide +# https://www.eventbrite.com/e/event-name-123456789012 +# https://www.eventbrite.com/e/123456789012 +# plus a possible query at the end e.g. ?aff=oddtdtcreator +# and considering localised domains such as .co.uk and .fr +EVENTBRITE_URL_PATTERN = re.compile( + r"^(https?:\/\/)?" # optional https:// + r"www\.eventbrite\." + r"(com|co\.uk|[a-z]{2})" # possible domains - .com, .co.uk, 2-letter country domain + r"\/e\/" # /e/ should always be present at start of path + r"[a-z0-9\-]+" # optional event-name + r"\d{10,}" # event ID + r"($|\?)", # end of string or beginning of query (?) +) + + +def get_eventbrite_id_from_url_or_return_input(url: str) -> str: + """Given the URL for an Eventbrite event, returns that event's ID. + If the ID can't be found, returns the input URL.""" + match = re.search(EVENTBRITE_ID_PATTERN, url) + return match.group() if match else url diff --git a/amy/templates/includes/trainingrequest_details.html b/amy/templates/includes/trainingrequest_details.html index 0a626f779..c42406860 100644 --- a/amy/templates/includes/trainingrequest_details.html +++ b/amy/templates/includes/trainingrequest_details.html @@ -1,5 +1,6 @@ {% load state %} {% load utils %} +{% load eventbrite %} @@ -30,6 +31,15 @@ {% endif %} + + diff --git a/amy/templates/mailing/training_request.txt b/amy/templates/mailing/training_request.txt index 67cafc4f4..5ebe52d1b 100644 --- a/amy/templates/mailing/training_request.txt +++ b/amy/templates/mailing/training_request.txt @@ -37,6 +37,7 @@ Registration Code: {{ object.member_code|default:"—" }} {% if object.member_code_override %} Continue with registration code marked as invalid: {{object.member_code_override|yesno}} {% endif %} +Eventbrite URL: {{ object.eventbrite_url }} Person: {{object.personal}} {{object.middle}} {{object.family}} <{{object.email}}> Github: {{ object.github|default:"---" }} Occupation: {{ object.get_occupation_display }} {{ object.occupation_other }} diff --git a/amy/workshops/management/commands/fake_database.py b/amy/workshops/management/commands/fake_database.py index c850710e5..b7f3c1f9c 100644 --- a/amy/workshops/management/commands/fake_database.py +++ b/amy/workshops/management/commands/fake_database.py @@ -322,12 +322,19 @@ def fake_training_request(self, person_or_None): underrepresented_choices = TrainingRequest._meta.get_field( "underrepresented" ).choices + eventbrite_url = "" + if registration_code and randbool(0.5): + eventbrite_url = ( + "https://eventbrite.com/fake-" + f"{self.faker.random_number(digits=12, fix_len=True)}" + ) req = TrainingRequest.objects.create( state=state, person=person_or_None, review_process="preapproved" if registration_code else "open", member_code=registration_code, member_code_override=override_invalid_code, + eventbrite_url=eventbrite_url, personal=person.personal, middle="", family=person.family, diff --git a/amy/workshops/migrations/0268_trainingrequest_eventbrite_url.py b/amy/workshops/migrations/0268_trainingrequest_eventbrite_url.py new file mode 100644 index 000000000..42bb67acf --- /dev/null +++ b/amy/workshops/migrations/0268_trainingrequest_eventbrite_url.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.20 on 2023-11-08 16:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("workshops", "0267_alter_trainingrequest_default_for_text_fields"), + ] + + operations = [ + migrations.AddField( + model_name="trainingrequest", + name="eventbrite_url", + field=models.URLField( + blank=True, + default="", + verbose_name="Eventbrite URL", + help_text="If you are registering or have registered for a training event through Eventbrite, enter the URL of that event. You can find this on the registration page or in the confirmation email. If you have not yet registered for an event, leave this field blank.", + ), + ), + ] diff --git a/amy/workshops/models.py b/amy/workshops/models.py index 9cb8baf22..7e193cf78 100644 --- a/amy/workshops/models.py +++ b/amy/workshops/models.py @@ -2134,6 +2134,16 @@ class TrainingRequest( help_text="A member of our team will check the code and follow up with you if " "there are any problems that require your attention.", ) + eventbrite_url = models.URLField( + null=False, + blank=True, + default="", + verbose_name="Eventbrite URL", + help_text="If you are registering or have registered for a training event " + "through Eventbrite, enter the URL of that event. You can find this on the " + "registration page or in the confirmation email. " + "If you have not yet registered for an event, leave this field blank.", + ) personal = models.CharField( max_length=STR_LONG,
Submission date:
Continue with registration code marked as invalid: {{ object.member_code_override|yesno }}
Eventbrite URL:{{ object.eventbrite_url|default:"—" }} + {% url_matches_eventbrite_format object.eventbrite_url as url_format_valid %} + {% if object.eventbrite_url and not url_format_valid %} +
+ This URL doesn't match known Eventbrite URLs. Please use caution if copying this link. +
+ {% endif %} +
Personal name: {{ object.personal }}
Middle name: