diff --git a/championship/factories.py b/championship/factories.py index 3d21907083c6d3711aba7a132a6e57f865ba16c1..37a541512e8eff16dbdcc95b596379f3fd11b386 100644 --- a/championship/factories.py +++ b/championship/factories.py @@ -26,14 +26,49 @@ class UserFactory(DjangoModelFactory): password = factory.LazyFunction(lambda: make_password("foobar")) +class AddressFactory(DjangoModelFactory): + class Meta: + model = Address + + location_name = factory.Faker("company", locale="fr_CH") + street_address = factory.Faker("street_address", locale="fr_CH") + city = factory.Faker("city", locale="fr_CH") + postal_code = factory.Faker("postcode", locale="fr_CH") + region = factory.Faker( + "random_element", + elements=Address.Region.values, + ) + country = factory.Faker( + "random_element", + elements=Address.Country.values, + ) + + class EventOrganizerFactory(DjangoModelFactory): class Meta: model = EventOrganizer name = factory.Faker("company", locale="fr_CH") contact = factory.Faker("email") + description = factory.Faker("text") user = factory.SubFactory(UserFactory) + @factory.post_generation + def addresses(self, create, extracted, **kwargs): + if not create: + # Simple build, do nothing. + return + + if extracted == None: + # Create 3 new random addresses. + addresses = AddressFactory.create_batch(3, organizer=self) + for address in addresses: + self.addresses.add(address) + + # Set one as the default + self.default_address = addresses[0] + self.save() + class PlayerFactory(DjangoModelFactory): class Meta: diff --git a/championship/forms.py b/championship/forms.py index 7c7ea093d6a074591195dfe837404eaf6597cb45..96f8c1ce40eb32bb23a841dad455429a3edfb8de 100644 --- a/championship/forms.py +++ b/championship/forms.py @@ -1,7 +1,7 @@ from django.db.models import TextChoices, Count from django.core.validators import RegexValidator from django import forms -from .models import Event, EventPlayerResult +from .models import Address, Event, EventPlayerResult, EventOrganizer from crispy_forms.helper import FormHelper from crispy_forms.layout import Submit, Layout, Div, Field from tinymce.widgets import TinyMCE @@ -23,6 +23,7 @@ class EventCreateForm(forms.ModelForm): "date", "format", "category", + "address", "url", "decklists_url", "description", @@ -44,6 +45,57 @@ You can copy/paste the description from a website like swissmtg.ch, and the form "format": "If your desired format is not listed, please contact us and we'll add it.", } + def __init__(self, *args, **kwargs): + organizer = kwargs.pop("organizer", None) + super(EventCreateForm, self).__init__(*args, **kwargs) + if organizer is not None: + self.fields["address"].queryset = organizer.get_addresses() + + +class AddressForm(forms.ModelForm): + set_as_organizer_address = forms.BooleanField(required=False, initial=False) + + class Meta: + model = Address + fields = [ + "location_name", + "street_address", + "city", + "postal_code", + "region", + "country", + "set_as_organizer_address", + ] + + +class OrganizerProfileEditForm(forms.ModelForm): + class Meta: + model = EventOrganizer + fields = [ + "name", + "contact", + "default_address", + "description", + ] + widgets = { + "description": TinyMCE( + mce_attrs={ + "toolbar": "undo redo | bold italic | link unlink | bullist numlist", + "link_assume_external_targets": "http", + }, + ), + } + help_texts = { + "description": """Supports the following HTML tags: {}.""".format( + ", ".join(bleach.ALLOWED_TAGS) + ), + } + + def __init__(self, *args, **kwargs): + organizer = kwargs.get("instance", None) + super().__init__(*args, **kwargs) + self.fields["default_address"].queryset = organizer.get_addresses() + class LinkImporterForm(forms.Form, SubmitButtonMixin): url = forms.URLField( diff --git a/championship/migrations/0016_eventorganizer_description_and_more.py b/championship/migrations/0016_eventorganizer_description_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..6f512efd1f486636e58c730bd0cd225c45f4d6a5 --- /dev/null +++ b/championship/migrations/0016_eventorganizer_description_and_more.py @@ -0,0 +1,127 @@ +# Generated by Django 4.1.7 on 2023-06-30 21:52 + +from django.db import migrations, models +import django.db.models.deletion +import django_bleach.models + + +class Migration(migrations.Migration): + dependencies = [ + ("championship", "0015_player_email_alter_event_description"), + ] + + operations = [ + migrations.AddField( + model_name="eventorganizer", + name="description", + field=django_bleach.models.BleachField( + blank=True, + help_text="Supports the following HTML tags: a, b, blockquote, em, i, li, ol, p, strong, ul", + ), + ), + migrations.AlterField( + model_name="eventorganizer", + name="contact", + field=models.EmailField( + help_text="Prefered contact email (not visible to players)", + max_length=254, + ), + ), + migrations.CreateModel( + name="Address", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("location_name", models.CharField(max_length=255)), + ("street_address", models.CharField(max_length=255)), + ("city", models.CharField(max_length=255)), + ("postal_code", models.CharField(max_length=10)), + ( + "region", + models.CharField( + choices=[ + ("AG", "Aargau"), + ("AR", "Appenzell Ausserrhoden"), + ("AI", "Appenzell Innerrhoden"), + ("BL", "Basel-Landschaft"), + ("BS", "Basel-Stadt"), + ("BE", "Bern"), + ("FR", "Fribourg"), + ("GE", "Genève"), + ("GL", "Glarus"), + ("GR", "Graubünden"), + ("JU", "Jura"), + ("LU", "Luzern"), + ("NE", "Neuchâtel"), + ("NW", "Nidwalden"), + ("OW", "Obwalden"), + ("SH", "Schaffhausen"), + ("SZ", "Schwyz"), + ("SO", "Solothurn"), + ("SG", "Sankt Gallen"), + ("TG", "Thurgau"), + ("TI", "Ticino"), + ("UR", "Uri"), + ("VS", "Valais"), + ("VD", "Vaud"), + ("ZG", "Zug"), + ("ZH", "Zürich"), + ("FR_DE", "Freiburg im Breisgau (DE)"), + ], + default="ZH", + max_length=5, + ), + ), + ( + "country", + models.CharField( + choices=[ + ("CH", "Switzerland"), + ("AT", "Austria"), + ("DE", "Germany"), + ("IT", "Italy"), + ("LI", "Liechtenstein"), + ("FR", "France"), + ], + default="CH", + max_length=2, + ), + ), + ( + "organizer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="addresses", + to="championship.eventorganizer", + ), + ), + ], + ), + migrations.AddField( + model_name="event", + name="address", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="championship.address", + ), + ), + migrations.AddField( + model_name="eventorganizer", + name="default_address", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="championship.address", + ), + ), + ] diff --git a/championship/models.py b/championship/models.py index 70fef93b216f06e72be94337da9b32453ef598e7..3c2a596b883de3db7bad805f3120441f8de7e1f0 100644 --- a/championship/models.py +++ b/championship/models.py @@ -11,6 +11,91 @@ import collections import datetime from prometheus_client import Gauge, Summary from django.contrib.humanize.templatetags.humanize import ordinal +import urllib.parse +from django.contrib.auth.models import User + + +class Address(models.Model): + class Region(models.TextChoices): + AARGAU = "AG", "Aargau" # German + APPENZELL_AUSSERRHODEN = "AR", "Appenzell Ausserrhoden" # German + APPENZELL_INNERRHODEN = "AI", "Appenzell Innerrhoden" # German + BASEL_LANDSCHAFT = "BL", "Basel-Landschaft" # German + BASEL_STADT = "BS", "Basel-Stadt" # German + BERN = "BE", "Bern" # German + FRIBOURG = "FR", "Fribourg" # French + GENEVA = "GE", "Genève" # French + GLARUS = "GL", "Glarus" # German + GRAUBUNDEN = "GR", "Graubünden" # German + JURA = "JU", "Jura" # French + LUCERNE = "LU", "Luzern" # German + NEUCHATEL = "NE", "Neuchâtel" # French + NIDWALDEN = "NW", "Nidwalden" # German + OBWALDEN = "OW", "Obwalden" # German + SCHAFFHAUSEN = "SH", "Schaffhausen" # German + SCHWYZ = "SZ", "Schwyz" # German + SOLOTHURN = "SO", "Solothurn" # German + ST_GALLEN = "SG", "Sankt Gallen" # German + THURGAU = "TG", "Thurgau" # German + TICINO = "TI", "Ticino" # Italian + URI = "UR", "Uri" # German + VALAIS = "VS", "Valais" # French + VAUD = "VD", "Vaud" # French + ZUG = "ZG", "Zug" # German + ZURICH = "ZH", "Zürich" # German + FREIBURG_DE = "FR_DE", "Freiburg im Breisgau (DE)" # German + + class Country(models.TextChoices): + SWITZERLAND = "CH", "Switzerland" + AUSTRIA = "AT", "Austria" + GERMANY = "DE", "Germany" + ITALY = "IT", "Italy" + LIECHTENSTEIN = "LI", "Liechtenstein" + FRANCE = "FR", "France" + + location_name = models.CharField(max_length=255) + street_address = models.CharField(max_length=255) + city = models.CharField(max_length=255) + postal_code = models.CharField(max_length=10) + region = models.CharField( + max_length=5, + choices=Region.choices, + default=Region.ZURICH, + ) + country = models.CharField( + max_length=2, choices=Country.choices, default=Country.SWITZERLAND + ) + + organizer = models.ForeignKey( + "EventOrganizer", on_delete=models.CASCADE, related_name="addresses" + ) + + # Used for naming this object in the deletion popup + display_name = "Address" + + def get_delete_url(self): + return reverse("address_delete", args=[self.pk]) + + def get_absolute_url(self): + return reverse("address_edit", args=[self.pk]) + + def __str__(self): + address_parts = [ + self.location_name, + self.street_address, + self.postal_code, + self.city, + ] + # If the city is the same as the region, we don't need it twice + if self.get_region_display() != self.city: + address_parts.append(self.get_region_display()) + address_parts.append(self.get_country_display()) + return ", ".join(address_parts) + + def get_google_maps_url(self): + """Return a URL for this address on Google Maps.""" + query = urllib.parse.quote(self.__str__()) + return f"https://www.google.com/maps/search/?api=1&query={query}" class EventOrganizer(models.Model): @@ -19,8 +104,26 @@ class EventOrganizer(models.Model): """ name = models.CharField(max_length=200) - contact = models.EmailField(help_text="Prefered contact email") + contact = models.EmailField( + help_text="Prefered contact email (not visible to players)" + ) + description = BleachField( + help_text="Supports the following HTML tags: {}".format( + ", ".join(settings.BLEACH_ALLOWED_TAGS) + ), + blank=True, + strip_tags=True, + ) user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + default_address = models.ForeignKey( + Address, on_delete=models.SET_NULL, null=True, blank=True + ) + + def get_absolute_url(self): + return reverse("organizer_details", args=[self.pk]) + + def get_addresses(self): + return self.addresses.all() def __str__(self): return self.name @@ -68,6 +171,9 @@ class Event(models.Model): blank=True, strip_tags=True, ) + address = models.ForeignKey( + Address, on_delete=models.SET_NULL, null=True, blank=True + ) class Format(models.TextChoices): LEGACY = "LEGACY", "Legacy" diff --git a/championship/templates/championship/address_form.html b/championship/templates/championship/address_form.html new file mode 100644 index 0000000000000000000000000000000000000000..e7b12b7ff67f17eb73a411c6367c9fe21e40379e --- /dev/null +++ b/championship/templates/championship/address_form.html @@ -0,0 +1,17 @@ +{% extends "championship/base.html" %} + +{% load crispy_forms_tags %} + +{% block content %} + <h2>{% if address %}Edit{% else %}Create{% endif %} Address</h2> + <p> + <a class="btn btn-secondary-light" href="{% url 'address_list' %}">Back to Addresses</a> + {% include 'championship/delete_confirmation.html' with object=address %} + </p> + <form method="post"> + {% csrf_token %} + {{ form|crispy }} + <button class="btn btn-secondary" type="submit">Save</button> + </form> + +{% endblock %} \ No newline at end of file diff --git a/championship/templates/championship/address_list.html b/championship/templates/championship/address_list.html new file mode 100644 index 0000000000000000000000000000000000000000..54c669453dcedf7478aa26ca714725995b6734ea --- /dev/null +++ b/championship/templates/championship/address_list.html @@ -0,0 +1,42 @@ +{% extends "championship/base.html" %} + +{% block content %} + <div class="container"> + <h2>Your Addresses</h2> + <p> + <a class="btn btn-secondary-light" href="{{ organizer_url }}">Back to My Events</a> + </p> + <table class="table table-striped"> + <thead> + <tr> + <th>Location Name</th> + <th>Street Address</th> + <th>City</th> + <th>Postal Code</th> + <th>Region</th> + <th>Actions</th> + </tr> + </thead> + <tbody> + {% for address in view.get_queryset %} + <tr> + <td>{{ address.location_name }}</td> + <td>{{ address.street_address }}</td> + <td>{{ address.city }}</td> + <td>{{ address.postal_code }}</td> + <td>{{ address.get_region_display }}</td> + <td> + <a class="btn btn-secondary" href="{% url 'address_edit' address.id %}">Edit</a> + {% include 'championship/delete_confirmation.html' with object=address %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="6">No addresses found</td> + </tr> + {% endfor %} + </tbody> + </table> + <a class="btn btn-secondary" href="{% url 'address_create' %}">Add New Address</a> + </div> +{% endblock %} \ No newline at end of file diff --git a/championship/templates/championship/base.html b/championship/templates/championship/base.html index 99a17bd088b666e3da00daef00a1540d648a1878..4f8b93d222dbcad89e70272c4abbc38ff8e1a4bf 100644 --- a/championship/templates/championship/base.html +++ b/championship/templates/championship/base.html @@ -47,7 +47,7 @@ {% if user.eventorganizer %} <li><a class="dropdown-item" href="{% url 'events_create' %}">Create new event</a></li> <li><a class="dropdown-item" href="{% url 'results_create' %}">Upload results</a></li> - <li><a class="dropdown-item" href="{% url 'organizer_update' %}">Edit organizer profile</a></li> + <li><a class="dropdown-item" href="{% url 'organizer_details' user.eventorganizer.pk %}">My Events</a></li> <li><a class="dropdown-item" href="{% url 'invoice_list' %}">My invoices</a></li> {% endif %} {% if user.is_staff %} diff --git a/championship/templates/championship/delete_confirmation.html b/championship/templates/championship/delete_confirmation.html new file mode 100644 index 0000000000000000000000000000000000000000..aa1e26b3fb848c2767d3b5f2fbbb1a66ac45be0e --- /dev/null +++ b/championship/templates/championship/delete_confirmation.html @@ -0,0 +1,26 @@ +{% if object %} + <button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ object.id }}"> + Delete </button> + + <div class="modal fade" id="deleteModal{{ object.id }}"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title">Delete {{ object.display_name }}</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <h6>Are you sure you want to delete this {{ object.name|lower }}?</h6> + <p>{{ object }}</p> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> + <form method="post" action="{{ object.get_delete_url }}"> + {% csrf_token %} + <button class="btn btn-danger" type="submit">Delete</button> + </form> + </div> + </div> + </div> + </div> +{% endif %} \ No newline at end of file diff --git a/championship/templates/championship/event_details.html b/championship/templates/championship/event_details.html index fd361550d4d2aa6a06f54817ee3cc3d175241d53..ca4509ac4fdb9be7a30c216af96acf7ac4e476f7 100644 --- a/championship/templates/championship/event_details.html +++ b/championship/templates/championship/event_details.html @@ -13,6 +13,10 @@ <dd class="col-sm-9">{{ event.organizer.name }}</dd> <dt class="col-sm-3">Date</dt> <dd class="col-sm-9">{{ event.date }}</dd> + {% if event.address %} + <dt class="col-sm-3">Location</dt> + <dd class="col-sm-9">{{ event.address }} (<a href="{{ event.address.get_google_maps_url }}" target="_blank">View on Google Maps</a>)</dd> + {% endif %} <dt class="col-sm-3">Category</dt> <dd class="col-sm-9">{{ event.get_category_display }}</dd> <dt class="col-sm-3">Format</dt> diff --git a/championship/templates/championship/organizer_details.html b/championship/templates/championship/organizer_details.html new file mode 100644 index 0000000000000000000000000000000000000000..280cae92dc49172899ab03627f71c6f8b205b2c3 --- /dev/null +++ b/championship/templates/championship/organizer_details.html @@ -0,0 +1,63 @@ +{% extends "championship/base.html" %} + +{% block title %} + {{ eventorganizer.name }} +{% endblock %} + +{% block content %} + <h1>{{ eventorganizer.name }}</h1> + <dl class="row"> + {% if eventorganizer.user == user %} + <dt class="col-sm-3">TO actions (only shown to you)</dt> + <dd class="col-sm-9"> + <p><a href="{% url 'organizer_update' %}" class="btn btn-secondary">Edit Organizer</a></p> + <p><a href="{% url 'address_list' %}" class="btn btn-secondary">Edit Addresses</a></p> + </dd> + {% endif %} + {% if user.is_staff %} + <dt class="col-sm-3">Admin (shown only to staff users)</dt> + <dd class="col-sm-9"> + <a class="btn btn-warning" href="{% url 'admin:championship_eventorganizer_change' eventorganizer.id %}">Edit in admin</a> + </dd> + {% endif %} + {% if eventorganizer.default_address %} + <dt class="col-sm-3">Region</dt> + <dd class="col-sm-9">{{ eventorganizer.default_address.get_region_display }}</dd> + <dt class="col-sm-3">Address</dt> + <dd class="col-sm-9">{{ eventorganizer.default_address }} (<a href="{{ eventorganizer.default_address.get_google_maps_url }}" target="_blank">View on Google Maps</a>)</dd> + {% endif %} + </dl> + + {% if eventorganizer.description %} + <h3>About</h3> + <div class="event_description border rounded"> + {{eventorganizer.description|linebreaksbr }} + </div> + {% endif %} + + {% for event_type in all_events %} + <br> + <h2>{{ event_type.title }}</h2> + <div class="table-responsive"> + <table class="table table-striped"> + <thead> + <tr> + <th scope="col">Event</th> + <th scope="col">Date</th> + <th scope="col">Type</th> + </tr> + </thead> + <tbody> + {% for event in event_type.list %} + <tr> + <td><a href="{% url 'event_details' event.id %}">{{event.name }}</a></td> + <td>{{ event.date }}</td> + <td>{{ event.get_category_display }}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + {% endfor %} + +{% endblock %} \ No newline at end of file diff --git a/championship/templates/championship/update_organizer.html b/championship/templates/championship/update_organizer.html index 3ae24811d5e178df466589c785edcd9d5205eac7..31205f26d5f2f61581b603da1b85f208058d6666 100644 --- a/championship/templates/championship/update_organizer.html +++ b/championship/templates/championship/update_organizer.html @@ -2,8 +2,14 @@ {% load crispy_forms_tags %} +{% block extrahead %} + {{ form.media }} +{% endblock %} + {% block content %} <h1>Edit organizer settings</h1> + <a class="btn btn-secondary" href="{% url 'address_list' %}">Edit Addresses</a> + <br> <form action="{% url 'organizer_update' %}" method="post"> {% csrf_token %} {{ form|crispy }} diff --git a/championship/tests/test_address.py b/championship/tests/test_address.py new file mode 100644 index 0000000000000000000000000000000000000000..681ecdf1ec3b730f4269742f6c73205b3f228972 --- /dev/null +++ b/championship/tests/test_address.py @@ -0,0 +1,108 @@ +from django.test import TestCase, Client +from django.urls import reverse +from django.contrib.auth.models import User +from championship.factories import AddressFactory, EventOrganizerFactory +from championship.models import Address + + +class BaseSetupTest(TestCase): + def base_set_up(self, with_address=True, username="testuser"): + self.client = Client() + self.user = User.objects.create_user(username=username, password="testpass") + self.organizer = EventOrganizerFactory(user=self.user, addresses=[]) + if with_address: + self.address = AddressFactory(organizer=self.organizer) + self.client.login(username=username, password="testpass") + + +class AddressListViewTest(BaseSetupTest): + def setUp(self): + self.base_set_up() + + def test_view_url_exists(self): + response = self.client.get(reverse("address_list")) + self.assertEqual(response.status_code, 200) + + def test_view_shows_address(self): + response = self.client.get(reverse("address_list")) + self.assertContains(response, str(self.address)) + + +class AddressCreateViewTest(BaseSetupTest): + def setUp(self): + self.base_set_up(with_address=False) + + def test_view_url_exists(self): + response = self.client.get(reverse("address_create")) + self.assertEqual(response.status_code, 200) + + def test_create_new_address(self): + self.assertEqual(Address.objects.count(), 0) + response = self.client.post( + reverse("address_create"), + data={ + "location_name": "Test Location", + "street_address": "Test Street", + "city": "Test City", + "postal_code": "123456", + "region": Address.Region.AARGAU, + "country": Address.Country.SWITZERLAND, + "set_as_organizer_address": True, + }, + ) + self.assertEqual(Address.objects.count(), 1) + self.assertEqual(response.url, reverse("address_list")) + self.organizer.refresh_from_db() + self.assertEqual(self.organizer.default_address.location_name, "Test Location") + + +class AddressUpdateViewTest(BaseSetupTest): + def setUp(self): + self.base_set_up() + + def test_view_url_exists(self): + response = self.client.get(reverse("address_edit", args=[self.address.pk])) + self.assertEqual(response.status_code, 200) + + def test_update_address(self): + response = self.client.post( + reverse("address_edit", args=[self.address.pk]), + data={ + "location_name": "New Location", + "street_address": "New Street", + "city": "New City", + "postal_code": "654321", + "region": Address.Region.BERN, + "country": Address.Country.SWITZERLAND, + "set_as_organizer_address": False, + }, + ) + self.assertEqual(response.url, reverse("address_list")) + self.address.refresh_from_db() + self.assertEqual(self.address.location_name, "New Location") + + +class AddressDeleteViewTest(BaseSetupTest): + def setUp(self): + self.base_set_up() + + def test_delete_default_address(self): + self.assertEqual(Address.objects.count(), 1) + response = self.client.post(reverse("address_delete", args=[self.address.pk])) + self.assertEqual(response.status_code, 302) + self.assertEqual(Address.objects.count(), 0) + self.organizer.refresh_from_db() + self.assertEqual(self.organizer.default_address, None) + + def test_delete_not_owned_address(self): + self.client.logout() + self.base_set_up(with_address=False, username="testuser2") + response = self.client.post(reverse("address_delete", args=[self.address.pk])) + self.assertEqual(response.status_code, 403) + self.assertTrue(Address.objects.filter(pk=self.address.pk).exists()) + + def test_get_delete_view_not_allowed(self): + # To prevent csrf only post should be allowed + response = self.client.get(reverse("address_delete", args=[self.address.pk])) + self.assertEqual(response.status_code, 403) + self.assertTrue(Address.objects.filter(pk=self.address.pk).exists()) diff --git a/championship/tests/test_events_create.py b/championship/tests/test_events_create.py index 1cf1bad6a4d16458f67076a684376c384b8d2369..c556f65a47a532065381471550edda273cf66934 100644 --- a/championship/tests/test_events_create.py +++ b/championship/tests/test_events_create.py @@ -3,7 +3,7 @@ from django.test import TestCase, Client from django.contrib.auth.models import User from django.urls import reverse from championship.models import Event, EventOrganizer -from championship.factories import EventOrganizerFactory, EventFactory +from championship.factories import AddressFactory, EventOrganizerFactory, EventFactory class EventCreationTestCase(TestCase): @@ -182,6 +182,24 @@ class EventCreationTestCase(TestCase): resp = self.client.post(reverse("event_delete", args=[event.id])) self.assertEqual(404, resp.status_code) + def test_default_address_is_initial(self): + self.login() + to = EventOrganizerFactory(user=self.user) + respone = self.client.get(reverse("events_create")) + initial_address = respone.context["form"].initial["address"] + self.assertEquals(to.default_address.id, initial_address) + + def test_initial_address_not_overwritten_by_default_address(self): + to = EventOrganizerFactory(user=self.user) + not_default_address = to.addresses.all()[1] + event = EventFactory(address=not_default_address, organizer=to) + self.login() + response = self.client.get(reverse("event_update", args=[event.id])) + self.assertEqual(200, response.status_code) + initial_address = response.context["form"].initial["address"] + self.assertNotEquals(not_default_address, to.default_address) + self.assertEquals(not_default_address.id, initial_address) + class EventCopyTestCase(TestCase): def setUp(self): @@ -221,3 +239,12 @@ class EventCopyTestCase(TestCase): resp = self.client.get(reverse("event_details", args=[event.id])) self.assertIn("Copy event", resp.content.decode()) + + def test_initial_address_not_overwritten_by_default_address(self): + self.login() + not_default_address = self.organizer.get_addresses()[1] + event = EventFactory(address=not_default_address, organizer=self.organizer) + respone = self.client.get(reverse("event_copy", args=[event.id])) + initial_address = respone.context["form"].initial["address"] + self.assertNotEquals(not_default_address, self.organizer.default_address) + self.assertEquals(not_default_address.id, initial_address) diff --git a/championship/tests/test_organizer_details.py b/championship/tests/test_organizer_details.py new file mode 100644 index 0000000000000000000000000000000000000000..7517fb8476a71016801a0145bde6b0e62cdd21cc --- /dev/null +++ b/championship/tests/test_organizer_details.py @@ -0,0 +1,54 @@ +from django.test import TestCase, Client +from django.urls import reverse +from django.utils import timezone +from championship.factories import EventOrganizerFactory, EventFactory +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class EventOrganizerDetailViewTests(TestCase): + def setUp(self): + self.client = Client() + self.user = User.objects.create_user(username="testuser", password="12345") + self.client.login(username="testuser", password="12345") + + self.organizer = EventOrganizerFactory(user=self.user) + + tomorrow = timezone.now() + timezone.timedelta(days=1) + past_date = timezone.now() - timezone.timedelta(days=5) + + self.future_event = EventFactory(organizer=self.organizer, date=tomorrow) + self.past_event = EventFactory(organizer=self.organizer, date=past_date) + self.response = self.client.get( + reverse("organizer_details", args=[self.organizer.id]) + ) + + def test_organizer_detail_view(self): + self.assertEqual(self.response.status_code, 200) + self.assertTemplateUsed(self.response, "championship/organizer_details.html") + self.assertContains(self.response, self.organizer.name) + self.assertContains(self.response, self.future_event.name) + self.assertContains(self.response, self.past_event.name) + + def test_organizer_detail_future_and_past(self): + self.assertTrue("all_events" in self.response.context) + self.assertEqual(len(self.response.context["all_events"]), 2) + # Test Future Events + self.assertEqual( + self.response.context["all_events"][0]["list"][0], self.future_event + ) + # Test Past Events + self.assertEqual( + self.response.context["all_events"][1]["list"][0], self.past_event + ) + + def test_organizer_detail_view_no_organizer(self): + self.response = self.client.get( + reverse("organizer_details", args=[9999]) + ) # assuming 9999 is an invalid ID + self.assertEqual(self.response.status_code, 404) + + def test_organizer_reverse(self): + edit_organizer_url = reverse("organizer_update") + self.assertContains(self.response, f'href="{edit_organizer_url}"') diff --git a/championship/tests/test_organizer_edit.py b/championship/tests/test_organizer_edit.py index 0c1b4f757999f5122e271cbac4405014d3cbbac9..e8757a76e6cc82d0de9967c374ede28e36e3b58a 100644 --- a/championship/tests/test_organizer_edit.py +++ b/championship/tests/test_organizer_edit.py @@ -2,7 +2,7 @@ from django.test import TestCase, Client from django.contrib.auth.models import User from django.urls import reverse from championship.models import EventOrganizer -from championship.factories import EventOrganizerFactory +from championship.factories import AddressFactory, EventOrganizerFactory class EventCreationTestCase(TestCase): @@ -40,7 +40,7 @@ class EventCreationTestCase(TestCase): to = EventOrganizerFactory(user=self.user) response = self.client.get("/") self.assertIn( - reverse("organizer_update"), + reverse("organizer_details", args=(to.id,)), response.content.decode(), "Logged in users should get a link to creating events", ) @@ -48,11 +48,17 @@ class EventCreationTestCase(TestCase): def test_post_data(self): self.login() to = EventOrganizerFactory(user=self.user) + new_address = to.addresses.all()[1] + self.assertNotEquals(to.default_address.id, new_address.id) data = { "contact": "foo@foo.org", "name": "My test events", + "default_address": new_address.id, + "description": "This is a test description", } self.client.post(reverse("organizer_update"), data=data) to = EventOrganizer.objects.get(user=self.user) self.assertEqual(to.name, data["name"]) self.assertEqual(to.contact, data["contact"]) + self.assertEqual(to.default_address.id, new_address.id) + self.assertEqual(to.description, data["description"]) diff --git a/championship/urls.py b/championship/urls.py index ab3c613478ac2803288173759f18a4f24e50cbcc..b919b0fc4ec062c88f6fcb8794c4b493e95982fa 100644 --- a/championship/urls.py +++ b/championship/urls.py @@ -43,8 +43,21 @@ urlpatterns = [ name="event_clear_results", ), path( - "organizer/edit", views.OrganizerProfileEdit.as_view(), name="organizer_update" + "organizer/<int:pk>", + views.EventOrganizerDetailView.as_view(), + name="organizer_details", ), + path( + "organizer/edit", + views.OrganizerProfileEditView.as_view(), + name="organizer_update", + ), + path("address/", views.AddressListView.as_view(), name="address_list"), + path("address/create/", views.AddressCreateView.as_view(), name="address_create"), + path( + "address/<int:pk>/edit/", views.AddressUpdateView.as_view(), name="address_edit" + ), + path("address/<int:pk>/delete/", views.address_delete, name="address_delete"), path("api/", include(api_router.urls)), path("api/formats/", views.ListFormats.as_view(), name="formats-list"), ] diff --git a/championship/views.py b/championship/views.py index 97162b842723f716b90e64204f704453605b22ce..2c10f6a320fbc6f22b6b68cc53563c93dd6eec5a 100644 --- a/championship/views.py +++ b/championship/views.py @@ -1,5 +1,4 @@ import datetime -import math import re import logging import os @@ -10,7 +9,8 @@ from typing import * from django.core.exceptions import ImproperlyConfigured from django.shortcuts import render, get_object_or_404, redirect from django.views.generic.base import TemplateView -from django.views.generic.edit import DeleteView, FormView, UpdateView +from django.views.generic.list import ListView +from django.views.generic.edit import DeleteView, FormView, UpdateView, CreateView from django.views.generic import DetailView from django.http import HttpResponseRedirect, HttpResponseForbidden from django.urls import reverse, reverse_lazy @@ -262,6 +262,14 @@ class CreateEventView(LoginRequiredMixin, FormView): template_name = "championship/create_event.html" form_class = EventCreateForm + def get_form_kwargs(self): + kwargs = super(CreateEventView, self).get_form_kwargs() + kwargs["organizer"] = EventOrganizer.objects.get(user=self.request.user) + kwargs["initial"][ + "address" + ] = self.request.user.eventorganizer.default_address.id + return kwargs + def form_valid(self, form): event = form.save(commit=False) event.organizer = EventOrganizer.objects.get(user=self.request.user) @@ -782,11 +790,92 @@ class FutureEventView(TemplateView): template_name = "championship/future_events.html" -class OrganizerProfileEdit(LoginRequiredMixin, UpdateView): +class EventOrganizerDetailView(DetailView): model = EventOrganizer - fields = ["name", "contact"] + template_name = "championship/organizer_details.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + organizer = self.get_object() + + future_events = Event.objects.filter( + organizer=organizer, date__gte=datetime.date.today() + ).order_by("date") + past_events = Event.objects.filter( + organizer=organizer, date__lt=datetime.date.today() + ).order_by("-date") + + all_events = [] + if future_events: + all_events.append({"title": "Upcoming Events", "list": future_events}) + if past_events: + all_events.append({"title": "Past Events", "list": past_events}) + context["all_events"] = all_events + return context + + +class OrganizerProfileEditView(LoginRequiredMixin, UpdateView): template_name = "championship/update_organizer.html" - success_url = reverse_lazy("organizer_update") + form_class = OrganizerProfileEditForm def get_object(self): - return EventOrganizer.objects.get(user=self.request.user) + return get_object_or_404(EventOrganizer, user=self.request.user) + + def get_success_url(self): + return self.get_object().get_absolute_url() + + def form_valid(self, form): + messages.success(self.request, "Succesfully updated organizer profile!") + return super().form_valid(form) + + +class AddressListView(LoginRequiredMixin, ListView): + model = Address + template_name = "championship/address_list.html" + + def get_queryset(self): + return self.request.user.eventorganizer.get_addresses() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["organizer_url"] = self.request.user.eventorganizer.get_absolute_url() + return context + + +class AddressViewMixin: + model = Address + form_class = AddressForm + template_name = "championship/address_form.html" + success_url = reverse_lazy("address_list") + + def form_valid(self, form): + organizer = self.request.user.eventorganizer + form.instance.organizer = organizer + self.object = form.save() + if form.cleaned_data["set_as_organizer_address"]: + organizer.default_address = self.object + organizer.save() + return super().form_valid(form) + + +class AddressCreateView(LoginRequiredMixin, AddressViewMixin, CreateView): + pass + + +class AddressUpdateView(LoginRequiredMixin, AddressViewMixin, UpdateView): + def get_queryset(self): + return self.request.user.eventorganizer.get_addresses() + + +@login_required +def address_delete(request, pk): + address = get_object_or_404(Address, id=pk) + if ( + request.method == "POST" + and address in request.user.eventorganizer.get_addresses() + ): + address.delete() + messages.success(request, "Succesfully deleted address!") + return HttpResponseRedirect(reverse("address_list")) + else: + return HttpResponseForbidden("You are not authorized to delete this address!")