Repository: pretix/pretix

Issue:
Create a blacklist for slug validation
**Hey!** If you ever wanted to contribute to an Open Source project, but didn't know how, you just found a perfect place to start. We try to write this issue as instructive as possible, but as we're humans we almost certainly forgot to write something important in here. If you want to try to tackle this issue and get stuck at _any_ point, please do not hesitate and ask us, either here on GitHub or via email at support@pretix.eu. We'll be very happy to help! 

We think this issue is very suitable to people who are just starting with Python or Django development or who never contributed to Open Source before. We'd like to ask people with wide experience in these areas to leave this issue to beginners. Thanks! (If you want something more advanced, just drop us a line, we'll find something :wink:)

## How to get started

First of all, you need pretix running locally on your machine. We have a guide on the developer installation [over here](https://docs.pretix.eu/en/latest/development/setup.html). Play around with it and get a feeling for what pretix is and how it works :) Try to find the places in the interface mentioned in this issue where something needs to change. Maybe try to find the same places in the code? Just searching for characteristic words can be a powerful tool to find your way around.

## What this is about

Every event organized with pretix has a "slug", a short version of its name that we use for URLs. The same goes for organizers, so we often build URLs like ``https://pretix.eu/<organizer-slug>/<event-slug>``. This makes for very nice URLs, but it has the danger of conflicts with internal URLs of pretix, for example the pretix backend is at ``/control/``. What about an organizer with the slug ``control``? This will lead to confusion and should be forbidden.

## How to do this

To avoid URL conflicts, we must validate that slugs (the short names for events and organizers that we use in URLs) do not have the following values:

Event slugs:
* ``download``
* ``healthcheck``
* ``locale``
* ``control``
* ``redirect``
* ``jsi18n``
* ``metrics`` (#271)
* ``_global``
* ``__debug__``´

Organizer slugs:
* ``download``
* ``healthcheck``
* ``locale``
* ``control``
* ``pretixdroid``
* ``redirect``
* ``jsi18n``
* ``metrics`` (#271)
* ``_global``
* ``__debug__``
* ``about``

We can do this by writing a custom [validator](https://docs.djangoproject.com/en/1.10/ref/validators/) and then add this to the slug fields in our ``Event`` and ``Organizer`` models.

## I want to give it a try!

You're still reading, nice! If you want to try working on this, please comment below, so everyone else knows this is being looked at and can search for a different place to contribute. 

First, [fork](https://github.com/pretix/pretix/fork) the repository to your own GitHub account. Then clone it locally (or [set it as your remote](https://help.github.com/articles/configuring-a-remote-for-a-fork/) if you already cloned it earlier) and prepare your pull requests. There are a number of tutorials on the Pull Request workflow online, for example this [short version](https://guides.github.com/introduction/flow/) by GitHub itself and this [more in-depth tutorial](https://www.digitalocean.com/community/tutorials/how-to-create-a-pull-request-on-github). We're excited to see your pull request! 

And don't forget: If you get stuck, don't hestitate to ask. We bound ourselves to a [Code of Conduct](https://docs.pretix.eu/en/latest/development/contribution/codeofconduct.html) and we're committed to providing a welcoming experience and giving constructive, friendly and useful feedback on your contributions.



Discussion:
Would like to have a go at this. Ideally, where should we keep the list of blacklisted slug values, in a config file, in memory or db?

I think it would be perfectly fine to keep them as a list in the code. They are somehow part of the code and don't change often.

I could imagine using validator classes somewhere like this inside `pretix.control.forms` (maybe an own file)?

```
class BlacklistValidator:
    blacklist = []

    def __call__(self, value):
        ... # Validation logic

class OrganizerSlugBlacklistValidator(BlacklistValidator):
    blacklist = ['control', ...]

class EventSlugBlacklistValidator(BlacklistValidator):
    blacklist = ['control', ...]
```

That would be a good approach, considering that they don't change much often so it would be safe to keep them in a list. I would also go for creating a validator class as suggested. Any thoughts on how best to test this (unit tests)?

Testing this is probably rather easy, it could be done on two levels.
- Unit test: Just call the validator with one valid and one invalid value and check that only one of the calls raises an exception
- Integration test: Use Django's test client (used all over the place in our tests) to acutally submit the Organizer/Event creation form and check that the response contains an error message if the slug was invalid

If you need any pointers on similar tests that you can look at, I can help you with that.

P.S.: Testing at _one_ of the two levels would be good enough, on both would be optional but great. (Unit test runs faster and is more helpful to track down the problem when it fails but integration test prevents us from forgetting to _use_ the validator).

Thank you so much, your above pointers are priceless! Yes please, would appreciate the similar tests pointers very much, thanks

Hmm, there doesn't seem to be any integration test for the event creation form yet. I'd suggest you first stick with the unit test (that one should be very short and easy, just call your new method) and I'll add the integration test later.

Cool, will start working on this asap.


Resolving patch:
diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py
--- a/src/pretix/base/models/event.py
+++ b/src/pretix/base/models/event.py
@@ -15,6 +15,7 @@
 from pretix.base.i18n import I18nCharField
 from pretix.base.models.base import LoggedModel
 from pretix.base.settings import SettingsProxy
+from pretix.base.validators import EventSlugBlacklistValidator
 
 from .auth import User
 from .organizer import Organizer
@@ -65,7 +66,8 @@ class Event(LoggedModel):
             RegexValidator(
                 regex="^[a-zA-Z0-9.-]+$",
                 message=_("The slug may only contain letters, numbers, dots and dashes."),
-            )
+            ),
+            EventSlugBlacklistValidator()
         ],
         verbose_name=_("Slug"),
     )
diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py
--- a/src/pretix/base/models/organizer.py
+++ b/src/pretix/base/models/organizer.py
@@ -5,6 +5,7 @@
 
 from pretix.base.models.base import LoggedModel
 from pretix.base.settings import SettingsProxy
+from pretix.base.validators import OrganizerSlugBlacklistValidator
 
 from .auth import User
 from .settings import OrganizerSetting
@@ -34,7 +35,8 @@ class Organizer(LoggedModel):
             RegexValidator(
                 regex="^[a-zA-Z0-9.-]+$",
                 message=_("The slug may only contain letters, numbers, dots and dashes.")
-            )
+            ),
+            OrganizerSlugBlacklistValidator()
         ],
         verbose_name=_("Slug"),
     )
diff --git a/src/pretix/base/validators.py b/src/pretix/base/validators.py
new file mode 100644
--- /dev/null
+++ b/src/pretix/base/validators.py
@@ -0,0 +1,48 @@
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext_lazy as _
+
+
+class BlacklistValidator:
+
+    blacklist = []
+
+    def __call__(self, value):
+        # Validation logic
+        if value in self.blacklist:
+            raise ValidationError(
+                _('This slug has an invalid value: %(value)s.'),
+                code='invalid',
+                params={'value': value},
+            )
+
+
+class EventSlugBlacklistValidator(BlacklistValidator):
+
+    blacklist = [
+        'download',
+        'healthcheck',
+        'locale',
+        'control',
+        'redirect',
+        'jsi18n',
+        'metrics',
+        '_global',
+        '__debug__'
+    ]
+
+
+class OrganizerSlugBlacklistValidator(BlacklistValidator):
+
+    blacklist = [
+        'download',
+        'healthcheck',
+        'locale',
+        'control',
+        'pretixdroid',
+        'redirect',
+        'jsi18n',
+        'metrics',
+        '_global',
+        '__debug__',
+        'about'
+    ]


Test patch:
diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py
--- a/src/tests/base/test_models.py
+++ b/src/tests/base/test_models.py
@@ -1,3 +1,4 @@
+import datetime
 import sys
 from datetime import timedelta
 
@@ -601,6 +602,16 @@ def test_presale_end_before_start(self):
 
         self.assertIn('presale_end', str(context.exception))
 
+    def test_slug_validation(self):
+        event = Event(
+            organizer=self.organizer, name='Download', slug='download',
+            date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc)
+        )
+        with self.assertRaises(ValidationError) as context:
+            event.full_clean()
+
+        self.assertIn('slug', str(context.exception))
+
 
 class CachedFileTestCase(TestCase):
     def test_file_handling(self):
diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py
--- a/src/tests/presale/test_event.py
+++ b/src/tests/presale/test_event.py
@@ -2,6 +2,7 @@
 from decimal import Decimal
 
 from django.core import mail
+from django.core.exceptions import ValidationError
 from django.test import TestCase
 from django.utils.timezone import now
 from tests.base import SoupTest
@@ -469,3 +470,19 @@ def test_multiple_orders(self):
         self.assertEqual(len(mail.outbox), 1)
         self.assertIn('DUMMY1', mail.outbox[0].body)
         self.assertIn('DUMMY2', mail.outbox[0].body)
+
+
+class EventSlugBlacklistValidatorTest(EventTestMixin, SoupTest):
+    def test_slug_validation(self):
+        event = Event(
+            organizer=self.orga,
+            name='download',
+            slug='download',
+            date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc),
+            live=True
+        )
+        with self.assertRaises(ValidationError):
+            if event.full_clean():
+                event.save()
+
+        self.assertEqual(Event.objects.filter(name='download').count(), 0)