diff --git a/la_chariotte/mail/templates/mail/order_confirm_mail.html b/la_chariotte/mail/templates/mail/order_confirm_mail.html index 9398d3d..398ae48 100644 --- a/la_chariotte/mail/templates/mail/order_confirm_mail.html +++ b/la_chariotte/mail/templates/mail/order_confirm_mail.html @@ -22,7 +22,7 @@

Rendez-vous pour la distribution : le {{ order.grouped_order.delivery_date }}{% if order.grouped_order.delivery_slot %}, {{ order.grouped_order.delivery_slot }}{% endif %} - {% if order.grouped_order.place %}
Lieu : {{ order.grouped_order.place }}{% endif %} + {% if order.place %}
Lieu : {{ order.place }}{% endif %}

Une question sur cette commande groupée ?
Vous pouvez contacter l'organisateur·ice de la commande, {{ order.grouped_order.orga }} : @@ -32,4 +32,4 @@ Voir la page de commande -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/la_chariotte/order/admin.py b/la_chariotte/order/admin.py index 8182731..96905e1 100644 --- a/la_chariotte/order/admin.py +++ b/la_chariotte/order/admin.py @@ -1,9 +1,10 @@ from django.contrib import admin -from .models import GroupedOrder, Item, Order, OrderAuthor, OrderedItem +from .models import GroupedOrder, Item, Order, OrderAuthor, OrderedItem, Place admin.site.register(GroupedOrder) admin.site.register(Order) admin.site.register(Item) admin.site.register(OrderedItem) admin.site.register(OrderAuthor) +admin.site.register(Place) diff --git a/la_chariotte/order/forms.py b/la_chariotte/order/forms.py index 2cf2817..ecb6bf2 100644 --- a/la_chariotte/order/forms.py +++ b/la_chariotte/order/forms.py @@ -1,11 +1,12 @@ import datetime from django import forms +from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.auth import get_user_model from django.forms.utils import to_current_timezone from django.utils import timezone -from la_chariotte.order.models import GroupedOrder, Item +from la_chariotte.order.models import GroupedOrder, Item, Place class GroupedOrderForm(forms.ModelForm): @@ -22,6 +23,13 @@ class GroupedOrderForm(forms.ModelForm): label="Numéro de téléphone obligatoire pour les participants", required=False, ) + places = forms.ModelMultipleChoiceField( + label="Lieux de distribution", + # TODO: filter own places + queryset=Place.objects.all(), + widget=forms.CheckboxSelectMultiple, + required=False, + ) class Meta: model = GroupedOrder @@ -31,7 +39,7 @@ class GroupedOrderForm(forms.ModelForm): "deadline_time", "delivery_date", "delivery_slot", - "place", + "places", "description", "is_phone_mandatory", ] @@ -46,7 +54,6 @@ class GroupedOrderForm(forms.ModelForm): "delivery_slot": forms.TextInput( attrs={"placeholder": "14h - 17h (facultatif)"} ), - "place": forms.TextInput(attrs={"placeholder": "(facultatif)"}), "description": forms.Textarea( attrs={ "placeholder": "Plus d'infos sur la commande groupée ? (facultatif)" @@ -94,12 +101,57 @@ class ItemCreateForm(forms.ModelForm): class JoinGroupedOrderForm(forms.Form): - code = forms.CharField(label="Code pour rejoindre la commande", max_length=6) + order_code = forms.CharField( + label="Code pour rejoindre la commande", max_length=6, required=False + ) + place_code = forms.CharField( + label="Code pour rejoindre le lieu", max_length=20, required=False + ) - def clean_code(self): - form_code = self.cleaned_data["code"] - if not GroupedOrder.objects.filter(code=form_code).exists(): + def clean_place_code(self): + form_place_code = self.cleaned_data["place_code"] + if form_place_code and not Place.objects.filter(code=form_place_code).exists(): + raise forms.ValidationError( + "Désolé, nous ne trouvons aucun lieu avec ce code" + ) + return form_place_code + + def clean_order_code(self): + form_order_code = self.cleaned_data["order_code"] + if ( + form_order_code + and not GroupedOrder.objects.filter(code=form_order_code).exists() + ): raise forms.ValidationError( "Désolé, nous ne trouvons aucune commande avec ce code" ) - return form_code + return form_order_code + + +class PlaceForm(forms.ModelForm): + class Meta: + model = Place + fields = [ + "name", + "description", + "code", + ] + widgets = { + "name": forms.TextInput( + attrs={"placeholder": "ex : Centre social Kropotkine"} + ), + "description": forms.Textarea( + attrs={"placeholder": "Plus d'infos sur le lieu ? (facultatif)"} + ), + "code": forms.TextInput( + attrs={"placeholder": "Identifiant unique du lieu (raccourci)"} + ), + } + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user") + super().__init__(*args, **kwargs) + + def save(self, commit=True): + self.instance.orga = get_user_model().objects.get(id=self.user.pk) + return super().save(commit=commit) diff --git a/la_chariotte/order/migrations/0030_add_place.py b/la_chariotte/order/migrations/0030_add_place.py new file mode 100644 index 0000000..69005c2 --- /dev/null +++ b/la_chariotte/order/migrations/0030_add_place.py @@ -0,0 +1,83 @@ +from django.conf import settings +from django.db import migrations, models + +import base36 +import random + +def random_code(): + return base36.dumps( + random.randint(pow(36, code_length - 2), pow(36, code_length - 1) - 1) + ) + +# Migrate existing GroupedOrder `place` to the new `place` table +def link_existing_place(apps, schema_editor): + GroupedOrder = apps.get_model('order', 'GroupedOrder') + Place = apps.get_model('order', 'Place') + for grouped_order in GroupedOrder.objects.all(): + if grouped_order.place: + # Generate new random code for this existing place + code = random_code() + while Place.objects.all().filter(code=code): + # Random code already exists, try a new random code + code = random_code() + + place = Place.objects.create( + name=grouped_order.place, + code=code, + ) + + grouped_order.places.add(place) + grouped_order.save() + +class Migration(migrations.Migration): + dependencies = [ + ("order", "0029_set_phone_mandatory_for_existing_orders"), + ] + + operations = [ + migrations.CreateModel( + name="Place", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="Nom du lieu de distribution")), + ("code", models.CharField(max_length=20, verbose_name="Identifiant unique du lieu (raccourci)", unique=True)), + ("orga", models.ForeignKey(on_delete=models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Organisateur·ice')), + ("description", models.TextField(blank=True, null=True, verbose_name="Description")), + ], + ), + migrations.AddField( + model_name="groupedorder", + name="places", + field=models.ManyToManyField( + to="order.place", + verbose_name="Lieux de distribution", + related_name="orders", + ) + ), + migrations.RunPython(link_existing_place), + migrations.RemoveField( + model_name='groupedorder', + name='place', + ), + migrations.AddField( + model_name="order", + name="place", + field=models.ForeignKey(blank=True, null=True, on_delete=models.deletion.CASCADE, to='order.place'), + ), + migrations.AddIndex( + model_name="groupedorder", + index=models.Index(fields=["code"], name="order_group_code_50902d_idx"), + ), + migrations.AddIndex( + model_name="place", + index=models.Index(fields=["code"], name="order_place_code_4e2b27_idx"), + ), + ] diff --git a/la_chariotte/order/models.py b/la_chariotte/order/models.py index 5450f72..57bc2ef 100644 --- a/la_chariotte/order/models.py +++ b/la_chariotte/order/models.py @@ -1,3 +1,4 @@ +import logging import random import base36 @@ -8,6 +9,8 @@ from django.utils import timezone from la_chariotte.settings import AUTH_USER_MODEL +logger = logging.getLogger(__name__) + class GroupedOrder(models.Model): name = models.CharField( @@ -21,15 +24,21 @@ class GroupedOrder(models.Model): max_length=50, null=True, blank=True, verbose_name="Créneau de distribution" ) deadline = models.DateTimeField("Date limite de commande") - place = models.CharField( - max_length=100, null=True, blank=True, verbose_name="Lieu de livraison" + + # Associate with zero/more saved distribution places + places = models.ManyToManyField( + "order.Place", verbose_name="Lieux de distribution", related_name="orders" ) + description = models.TextField("Description", null=True, blank=True) code = models.CharField(auto_created=True) is_phone_mandatory = models.BooleanField( default=False, verbose_name="Numéro de téléphone obligatoire" ) + class Meta: + indexes = [models.Index(fields=["code"])] + def create_code_from_pk(self): """When a grouped order is created, a unique code is generated, to be used to build the order URL. @@ -123,6 +132,9 @@ class Order(models.Model): author = models.ForeignKey(OrderAuthor, on_delete=models.CASCADE) created_date = models.DateTimeField("Date et heure de commande", auto_now_add=True) note = models.TextField(max_length=200, null=True, blank=True) + place = models.ForeignKey( + "order.place", on_delete=models.CASCADE, null=True, blank=True + ) @property def articles_nb(self): @@ -196,3 +208,47 @@ class OrderedItem(models.Model): def __str__(self): # pragma: no cover return f"{self.nb} {self.item}, dans la commande {self.order.pk}" + + +class Place(models.Model): + name = models.CharField(max_length=100, verbose_name="Nom du lieu de distribution") + orga = models.ForeignKey( + AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="Organisateur·ice" + ) + code = models.CharField( + max_length=20, + verbose_name="Identifiant unique du lieu (raccourci)", + unique=True, + ) + description = models.TextField("Description", null=True, blank=True) + + class Meta: + indexes = [models.Index(fields=["code"])] + + def __str__(self): # pragma: no cover + return self.name + + def get_absolute_url(self): + return reverse("order:place_overview", kwargs={"code": self.code}) + + def active_orders(self): + # Only return currently active orders (not yet delivered) + active_orders = ( + self.orders.all() + .filter(delivery_date__gt=timezone.now()) + .order_by("-delivery_date") + ) + return active_orders + + def save(self, *args, **kwargs): + # Check that self.code was not changed + if self.pk is not None: + original = Place.objects.get(pk=self.pk) + if original.code != self.code: + # Restore the old place code + self.code = original.code + logger.warn( + "Le code du lieu '%s' ne peut pas changer après la création" + % self.name + ) + super().save() diff --git a/la_chariotte/order/templates/order/grouped_order_detail.html b/la_chariotte/order/templates/order/grouped_order_detail.html index b73d467..0d5d5cc 100644 --- a/la_chariotte/order/templates/order/grouped_order_detail.html +++ b/la_chariotte/order/templates/order/grouped_order_detail.html @@ -27,10 +27,6 @@ {% endif %}

- {% if grouped_order.place %} -

{{ grouped_order.place }}

- {% endif %}

Commandes avant le {{ grouped_order.deadline|date:'d M Y' }} à {{ grouped_order.deadline|date:'H:i' }} @@ -171,6 +167,16 @@

+ {% if places %} + + + {% endif %} +
+
@@ -370,4 +371,4 @@ }); {% endblock %} - \ No newline at end of file + diff --git a/la_chariotte/order/templates/order/grouped_order_sheet.html b/la_chariotte/order/templates/order/grouped_order_sheet.html index 3b5a276..881ca06 100644 --- a/la_chariotte/order/templates/order/grouped_order_sheet.html +++ b/la_chariotte/order/templates/order/grouped_order_sheet.html @@ -75,64 +75,13 @@

{{ grouped_order.name }} - {{ grouped_order.delivery_date }}

- {% if items %} - - - - - - {% for item in items %} - - {% endfor %} - - - - - - - - {% for item in items %} - - {% endfor %} - - - - - - {% for item in items %} - - {% endfor %} - - - {% for order, ordered_items in orders_dict.items %} - - - - {% for ordered_item in ordered_items %} - - {% endfor %} - - - {% endfor %} - -
OKNom -
{{ item.name }}
-
Prix
Prix unitaire - {{ item.price }} € -
TOTAL - {{ item.ordered_nb }} - {{ grouped_order.total_price }} €
- {{ order.author.last_name|upper }} {{ order.author.first_name }} - - {% if ordered_item > 0 %} - {{ ordered_item }} - {% endif %} - - {{ order.price }} € -
+ {% if places %} + {% for place_name, place_orders in places.items %} +

{{ place_name }}

+ {% include 'order/grouped_order_sheet_list.html' with orders_dict=place_orders items=items grouped_order=grouped_order %} + {% endfor %} {% else %} - Aucun produit n'a été commandé + {% include 'order/grouped_order_sheet_list.html' with orders_dict=orders_dict items=items grouped_order=grouped_order %} {% endif %} - diff --git a/la_chariotte/order/templates/order/grouped_order_sheet_list.html b/la_chariotte/order/templates/order/grouped_order_sheet_list.html new file mode 100644 index 0000000..8f70d32 --- /dev/null +++ b/la_chariotte/order/templates/order/grouped_order_sheet_list.html @@ -0,0 +1,58 @@ +{% if items %} + + + + + + {% for item in items %} + + {% endfor %} + + + + + + + + {% for item in items %} + + {% endfor %} + + + + + + {% for item in items %} + + {% endfor %} + + + {% for order, ordered_items in orders_dict.items %} + + + + {% for ordered_item in ordered_items %} + + {% endfor %} + + + {% endfor %} + +
OKNom +
{{ item.name }}
+
Prix
Prix unitaire + {{ item.price }} € +
TOTAL + {{ item.ordered_nb }} + {{ grouped_order.total_price }} €
+ {{ order.author.last_name|upper }} {{ order.author.first_name }} + + {% if ordered_item > 0 %} + {{ ordered_item }} + {% endif %} + + {{ order.price }} € +
+{% else %} + Aucun produit n'a été commandé +{% endif %} diff --git a/la_chariotte/order/templates/place/index.html b/la_chariotte/order/templates/place/index.html new file mode 100644 index 0000000..f1aeace --- /dev/null +++ b/la_chariotte/order/templates/place/index.html @@ -0,0 +1,62 @@ +{% extends 'base.html' %} + +{% block title %}Mes lieux de livraison{% endblock %} + +{% block content %} +

+ {% block content_title %}Lieux de distribution que vous organisez{% endblock %} +

+ + {% if context.places %} + + + + + + + + + + + + {% for place in context.places %} + + + + + + + {% endfor %} + +
LieuCommandesDescriptionURL
+ (modifier) {{ place }} + + {% if place.code in context.orders.keys %} + {% for place_code, place_orders in context.orders.items %} + {% if place_code == place.code %} + {% for order in place_orders %} + {% url 'order:grouped_order_detail' code=order.code as order_url %} + {% if order_url %} + {{order.name}}
+ {% else %} + {{ order.name }}
+ {% endif %} + {% endfor %} + {% endif %} + {% endfor %} + {% else %} + Aucune + {% endif %} +
+ {{ place.description }} + + {{ place.code }} +
+ {% else %} +

Pas de lieux de distribution pour l'instant

+ {% endif %} +{% endblock %} diff --git a/la_chariotte/order/templates/place/place_create.html b/la_chariotte/order/templates/place/place_create.html new file mode 100644 index 0000000..013f75c --- /dev/null +++ b/la_chariotte/order/templates/place/place_create.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} + +{% load crispy_forms_tags %} + +{% block title %}Nouveau lieu de distribution{% endblock %} + +{% block content %} +

+ {% block content_title %}Créer un lieu de distribution{% endblock %} +

+
+

Nouveau lieu

+
+
+
+ {% csrf_token %} + {{ form | crispy }} +
+ Annuler + +
+
+
+
+
+{% endblock %} diff --git a/la_chariotte/order/templates/place/place_overview.html b/la_chariotte/order/templates/place/place_overview.html new file mode 100644 index 0000000..2a36740 --- /dev/null +++ b/la_chariotte/order/templates/place/place_overview.html @@ -0,0 +1,58 @@ +{% extends 'base.html' %} + +{% block title %}Commandes groupées à: {{ place.name }}{% endblock %} + +{% block content %} +{% if user.is_authenticated %} + +
+
+{% endif %} +{% if user == place.orga %} + +{% endif %} +

+ {% block content_title %}{{ place.name }}: Commandes groupées{% endblock %} +

+{% if active_orders %} +

{{ place.name }} ({{ active_orders | length }} commandes groupées en cours)

+ {% for order in active_orders %} +
+

{{ order.name }} ({{ order.item_set.all|length }} produit{% if order.item_set.all|length > 1 %}s{% endif %})

+

+   Fin des commandes le {{ order.deadline }} +
  Livraison le {{ order.delivery_date }} + {% if order.places.all|length > 1 %} +
  Autres lieux de livraison: + {% for other_place in order.places.all %} + {% if other_place.code != place.code %} + {{ other_place.name }} + {% if forloop.counter0|add:"2" < order.places.all|length %}, {% endif %} + {% endif %} + {% endfor %} + {% endif %} +

+ +
+ {% endfor %} +{% else %} +

Aucune commande groupée en cours dans ce lieu.

+{% endif %} + +{% endblock %} diff --git a/la_chariotte/order/templates/place/place_update.html b/la_chariotte/order/templates/place/place_update.html new file mode 100644 index 0000000..5de0fb1 --- /dev/null +++ b/la_chariotte/order/templates/place/place_update.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} + +{% load crispy_forms_tags %} + +{% block title %}Modifier le lieu de livraison {% endblock %} + +{% block content %} +

+ {% block content_title %}Modifier le lieu de livraison{% endblock %} +

+
+

{{ place.name }} - modifier

+
{% csrf_token %} + {{ form | crispy }} +
+ Annuler + +
+
+
+ +{% endblock %} diff --git a/la_chariotte/order/urls.py b/la_chariotte/order/urls.py index e49629d..3ef769a 100644 --- a/la_chariotte/order/urls.py +++ b/la_chariotte/order/urls.py @@ -1,80 +1,103 @@ +import logging + from django.urls import path from . import views +logger = logging.getLogger(__name__) + app_name = "order" urlpatterns = [ - path("", views.IndexView.as_view(), name="index"), + path("commande/", views.IndexView.as_view(), name="index"), path( - "/", + "commande//", views.GroupedOrderDetailView.as_view(), name="grouped_order_detail", ), path( - "/ics/", + "commande//ics/", views.GroupedOrderEventView.as_view(), name="grouped_order_event", ), path( - "/gerer", + "commande//gerer", views.GroupedOrderOverview.as_view(), name="grouped_order_overview", ), - path("/commander/", views.place_order, name="order"), + path("commande//commander/", views.place_order, name="order"), path( - "//confirmation/", + "commande///confirmation/", views.OrderDetailView.as_view(), name="order_confirm", ), path( - "/gerer//supprimer", + "commande//gerer//supprimer", views.OrderDeleteView.as_view(), name="order_delete", ), - path("creer", views.GroupedOrderCreateView.as_view(), name="create_grouped_order"), path( - "/gerer-produits", + "commande/creer", + views.GroupedOrderCreateView.as_view(), + name="create_grouped_order", + ), + path( + "commande//gerer-produits", views.GroupedOrderAddItemsView.as_view(), name="manage_items", ), path( - "/modifier", + "commande//modifier", views.GroupedOrderUpdateView.as_view(), name="update_grouped_order", ), path( - "/supprimer", + "commande//supprimer", views.GroupedOrderDeleteView.as_view(), name="delete_grouped_order", ), path( - "/dupliquer", + "commande//dupliquer", views.GroupedOrderDuplicateView.as_view(), name="duplicate_grouped_order", ), path( - "/gerer-produits/nouveau", + "commande//gerer-produits/nouveau", views.ItemCreateView.as_view(), name="item_create", ), path( - "/gerer-produits//supprimer", + "commande//gerer-produits//supprimer", views.ItemDeleteView.as_view(), name="item_delete", ), path( - "/gerer/imprimer", + "commande//gerer/imprimer", views.DownloadGroupedOrderSheetView.as_view(), name="grouped_order_sheet", ), path( - "/gerer/liste-mails", + "commande//gerer/liste-mails", views.ExportGroupOrderEmailAdressesToDownloadView.as_view(), name="email_list", ), path( - "/gerer/csv", + "commande//gerer/csv", views.ExportGroupedOrderToCSVView.as_view(), name="grouped_order_csv_export", ), + path("lieu/", views.PlaceIndexView.as_view(), name="place_index"), + path( + "lieu//modifier", + views.PlaceUpdateView.as_view(), + name="place_update", + ), + # TODO: It's not great that we reserve special keywords as we go + # It would be better to use different HTTP verbs, or have a predefined + # list of reserved keywords, or use an entirely different route... + path("lieu/creer", views.PlaceCreateView.as_view(), name="place_create"), + path( + "lieu/", + views.PlaceOverviewView.as_view(), + name="place_overview", + ), ] diff --git a/la_chariotte/order/views/__init__.py b/la_chariotte/order/views/__init__.py index 7522900..6732fcc 100644 --- a/la_chariotte/order/views/__init__.py +++ b/la_chariotte/order/views/__init__.py @@ -16,3 +16,4 @@ from .grouped_order import ( ) from .item import ItemCreateView, ItemDeleteView from .order import OrderDeleteView, OrderDetailView, place_order +from .place import PlaceCreateView, PlaceIndexView, PlaceOverviewView, PlaceUpdateView diff --git a/la_chariotte/order/views/grouped_order.py b/la_chariotte/order/views/grouped_order.py index 55d9cca..13f71e7 100644 --- a/la_chariotte/order/views/grouped_order.py +++ b/la_chariotte/order/views/grouped_order.py @@ -12,7 +12,7 @@ from django_weasyprint import WeasyTemplateResponseMixin from icalendar import Calendar, Event, vCalAddress, vText from ..forms import GroupedOrderForm, Item, JoinGroupedOrderForm -from ..models import GroupedOrder, OrderAuthor +from ..models import GroupedOrder, OrderAuthor, Place from .mixins import UserIsOrgaMixin @@ -55,11 +55,31 @@ class JoinGroupedOrderView(generic.FormView, generic.RedirectView, LoginRequired template_name = "dashboard.html" def form_valid(self, form): - return redirect( - reverse_lazy( - "order:grouped_order_detail", kwargs={"code": form.cleaned_data["code"]} + # Give priority to order code, for arbitrary reasons + if form.cleaned_data["order_code"]: + return redirect( + reverse_lazy( + "order:grouped_order_detail", + kwargs={"code": form.cleaned_data["order_code"]}, + ) ) - ) + elif form.cleaned_data["place_code"]: + return redirect( + reverse_lazy( + "order:place_overview", + kwargs={"code": form.cleaned_data["place_code"]}, + ) + ) + else: + # No valid data found. Either no data was supplied, or they failed validation. + if ( + "order_code" not in form.data + or not form.data["order_code"] + or "place_code" not in form.data + or not form.data["place_code"] + ): + # No data supplied, redirect to dashboard + return redirect(reverse_lazy("dashboard")) class GroupedOrderEventView(generic.DetailView): @@ -75,7 +95,8 @@ class GroupedOrderEventView(generic.DetailView): event.add("dtstart", self.object.delivery_date) event.add("dtend", self.object.delivery_date) event.add("date", self.object.delivery_date) - event.add("location", vText(self.object.place)) + # TODO + # event.add("location", vText(self.object.place)) description = "" if self.object.delivery_slot: @@ -136,6 +157,7 @@ class GroupedOrderDetailView(generic.DetailView): "order_author": order_author, # Used to set if the phone is required in the form "is_phone_mandatory": grouped_order.is_phone_mandatory, + "places": grouped_order.places.all(), } ) return context @@ -198,6 +220,11 @@ class GroupedOrderUpdateView(UserIsOrgaMixin, generic.UpdateView): kwargs["user"] = self.request.user return kwargs + def get_context_data(self, **kwargs): + context = super(GroupedOrderUpdateView, self).get_context_data(**kwargs) + context["places"] = Place.objects.filter(orga=self.request.user) + return context + class GroupedOrderDuplicateView(UserIsOrgaMixin, generic.RedirectView): def get_object(self, queryset=None): @@ -214,10 +241,11 @@ class GroupedOrderDuplicateView(UserIsOrgaMixin, generic.RedirectView): orga=self.request.user, delivery_date=initial_grouped_order.delivery_date, deadline=initial_grouped_order.deadline, - place=initial_grouped_order.place, description=initial_grouped_order.description, ) + # duplicate the places set + new_grouped_order.places.set(initial_grouped_order.places.all()) # create a unique code for the new grouped order new_grouped_order.create_code_from_pk() new_grouped_order.save() @@ -317,6 +345,20 @@ class GroupedOrderExportView(UserIsOrgaMixin, generic.DetailView): context["items"] = items context["orders_dict"] = orders_dict + # Query the DB only once for this grouped order's places + places = [x for x in grouped_order.places.all()] + if len(places) > 0: + # Initialize empty list of orders for every place enabled for this GroupedOrder + places_orders = {x.code: {} for x in places} + + for order, order_items in orders_dict.items(): + places_orders[order.place.code][order] = order_items + + # places contains the full places objects + # places_orders contains the order filtered by place.code + context["places"] = {x.code: x for x in places} + context["places_orders"] = places_orders + return context @@ -355,6 +397,7 @@ class ExportGroupOrderEmailAdressesToDownloadView(UserPassesTestMixin, generic.V class ExportGroupedOrderToCSVView(GroupedOrderExportView): def get(self, request, *args, **kwargs): + grouped_order = self.get_object() super(ExportGroupedOrderToCSVView, self).get(self, request, *args, **kwargs) context = self.get_context_data() @@ -376,6 +419,8 @@ class ExportGroupedOrderToCSVView(GroupedOrderExportView): row.append("Note") row.append("Date") row.append("Heure") + if "places" in context: + row.append("Lieu") writer.writerow(row) row = ["", "Prix unitaire TTC (€)"] @@ -386,8 +431,7 @@ class ExportGroupedOrderToCSVView(GroupedOrderExportView): row = ["Nom", "Prénom"] writer.writerow(row) - # write ordered values rows - for order, ordered_items in context["orders_dict"].items(): + def format_csv_order_row(order, ordered_items, place=None): row = [order.author.last_name] row.append(order.author.first_name) for ordered_nb in ordered_items: @@ -398,7 +442,23 @@ class ExportGroupedOrderToCSVView(GroupedOrderExportView): row.append(order.note) row.append(order.created_date.strftime("%d/%m/%Y")) row.append(order.created_date.strftime("%H:%M")) - writer.writerow(row) + if place: + row.append(place.name) + return row + + # Write ordered values rows. + # Avoid extra queries by reusing GroupedOrderExportView context + if "places" in context: + for place_code, place_orders in context["places_orders"].items(): + for order, ordered_items in place_orders.items(): + row = format_csv_order_row( + order, ordered_items, place=context["places"][place_code] + ) + writer.writerow(row) + else: + for order, ordered_items in context["orders_dict"].items(): + row = format_csv_order_row(order, ordered_items) + writer.writerow(row) # write total row row = ["", "TOTAL"] diff --git a/la_chariotte/order/views/order.py b/la_chariotte/order/views/order.py index 032b219..c844134 100644 --- a/la_chariotte/order/views/order.py +++ b/la_chariotte/order/views/order.py @@ -1,5 +1,6 @@ from django import http from django.contrib.auth.mixins import UserPassesTestMixin +from django.core.exceptions import SuspiciousOperation from django.shortcuts import get_object_or_404, render from django.urls import reverse, reverse_lazy from django.utils import timezone @@ -7,7 +8,7 @@ from django.views import generic from la_chariotte.mail.utils import send_order_confirmation_mail -from ..models import GroupedOrder, Order, OrderAuthor, OrderedItem +from ..models import GroupedOrder, Order, OrderAuthor, OrderedItem, Place def place_order(request, code): @@ -46,14 +47,55 @@ def place_order(request, code): phone = request.POST["phone"] email = request.POST["email"] note = request.POST["note"] + + error_message = None author = OrderAuthor.objects.create( first_name=first_name, last_name=last_name, email=email, phone=phone ) + + # Make sure requested place is valid in this group order + # Query grouped order places only once to avoid many DB roundtrips + grouped_order_places = grouped_order.places.all() + place = None + if len(grouped_order_places) > 0: + # Places are enabled for this grouped order, make sure one was supplied + if "place" not in request.POST or request.POST["place"] == "": + error_message = ( + "Aucun lieu n'est spécifié alors que la commande groupée en exige un" + ) + else: + place = request.POST["place"] + try: + # QuerySet is already queried from DB so this is a cheap operation. + # Could fail if no entry is found, or multiple entries are found + place = grouped_order_places.get(code=place) + except Exception as e: + # The requested place, whether it exists or not, + # is not enabled for this GroupedOrder + error_message = ( + "Le lieu demandé n'est pas valide pour cette commande: %s" % place + ) + + if error_message: + author.delete() + return render( + request, + "order/grouped_order_detail.html", + { + "grouped_order": grouped_order, + "error_message": error_message, + "note": note, + "author": author, + "place": place, + }, + ) + order = Order.objects.create( author=author, grouped_order=grouped_order, note=note, created_date=timezone.now(), + place=place, ) # add items to the order @@ -80,6 +122,7 @@ def place_order(request, code): "error_message": error_message, "note": order.note, "author": author, + "place": place, }, ) @@ -98,6 +141,7 @@ def place_order(request, code): "error_message": error_message, "note": order.note, "author": author, + "place": place, }, ) diff --git a/la_chariotte/order/views/place.py b/la_chariotte/order/views/place.py new file mode 100644 index 0000000..ab22b67 --- /dev/null +++ b/la_chariotte/order/views/place.py @@ -0,0 +1,110 @@ +import csv +import json + +from django import http +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.core.serializers.json import DjangoJSONEncoder +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse, reverse_lazy +from django.utils import timezone +from django.views import generic +from django_weasyprint import WeasyTemplateResponseMixin +from icalendar import Calendar, Event, vCalAddress, vText + +from la_chariotte.order.models import GroupedOrder +from la_chariotte.order.views.mixins import UserIsOrgaMixin + +from ..forms import PlaceForm +from ..models import Place + + +class PlaceIndexView(LoginRequiredMixin, generic.ListView): + """View showing all the distribution places managed by the authenticated user""" + + template_name = "place/index.html" + context_object_name = "context" + + def get_queryset(self): + places = Place.objects.filter(orga=self.request.user) + + # Let's filter orders by distribution place (for UI grouping) + orders = dict() + for place in places: + # Only get currently-active grouped orders + active_orders = place.active_orders() + if active_orders: + orders[place.code] = active_orders + + return { + "places": places, + "orders": orders, + } + + +class PlaceUpdateView(UserIsOrgaMixin, generic.UpdateView): + """View showing details and allowing updates to a distribution place""" + + model = Place + template_name = "place/place_update.html" + context_object_name = "place" + form_class = PlaceForm + + # Prevent URL change after creation + # TODO: is this working? + form_class.base_fields["code"].disabled = True + + def get_object(self, queryset=None): + return get_object_or_404(Place, code=self.kwargs.get("code")) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + + return kwargs + + def form_valid(self, form): + self.object = form.save() + return super().form_valid(form) + + +class PlaceOverviewView(generic.DetailView): + """View details about orders available on a specific place""" + + model = Place + template_name = "place/place_overview.html" + context_object_name = "place" + + def get_object(self, queryset=None): + return get_object_or_404(Place, code=self.kwargs.get("code")) + + def get_context_data(self, **kwargs): + place = self.get_object() + active_orders = place.active_orders() + + context = super().get_context_data(**kwargs) + context.update( + { + "active_orders": active_orders, + } + ) + return context + + +class PlaceCreateView(LoginRequiredMixin, generic.CreateView): + """View for creating a new distribution place""" + + model = Place + form_class = PlaceForm + template_name = "place/place_create.html" + + # Allow setting URL for creation + form_class.base_fields["code"].disabled = False + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + + def form_valid(self, form): + self.object = form.save() + return redirect(reverse_lazy("order:place_index")) diff --git a/la_chariotte/templates/base.html b/la_chariotte/templates/base.html index 53ad4fa..bba8abf 100644 --- a/la_chariotte/templates/base.html +++ b/la_chariotte/templates/base.html @@ -86,6 +86,9 @@ Mes commandes groupées + + Mes lieux de distribution + {% endif %} La Chariotte, c'est quoi ? diff --git a/la_chariotte/templates/dashboard.html b/la_chariotte/templates/dashboard.html index f4675cc..4b1848d 100644 --- a/la_chariotte/templates/dashboard.html +++ b/la_chariotte/templates/dashboard.html @@ -5,6 +5,17 @@ {% block content %} {% load static %} +{% if user.is_authenticated %} + +{% endif %} +

{% block content_title %}Bienvenue{% if user.is_authenticated %}, {{ user.first_name }}{% else %} sur la Chariotte !{% endif %}{% endblock %}

@@ -15,15 +26,15 @@

Si vous avez un code à 6 caractères, entrez-le ici :

{% csrf_token %} - {% for error in form.code.errors %} + {% for error in form.order_code.errors %}

{{ error }}

{% endfor %}
- +
- +
@@ -31,23 +42,42 @@
- {% if user.is_authenticated %} -

... Ou accédez à vos commandes groupées

- - Mes commandes groupées - - {% else %} -

... Ou connectez-vous pour organiser une commande groupée

- - Se connecter - - - Créer un compte - - {% endif %} +

Rejoindre un lieu de distribution

+

Si vous avez un code de lieu, entrez-le ici :

+
+ {% csrf_token %} + {% for error in form.place_code.errors %} +

{{ error }}

+ {% endfor %} +
+
+ +
+
+ +
+
+
+{% if not user.is_authenticated %} +
+
+
+

... Ou connectez-vous pour organiser une commande groupée

+ +
+
+
+{% endif %}
diff --git a/la_chariotte/tests/test_order_models.py b/la_chariotte/tests/test_order_models.py index 9dd5c4f..5844e8e 100644 --- a/la_chariotte/tests/test_order_models.py +++ b/la_chariotte/tests/test_order_models.py @@ -5,7 +5,7 @@ from django.contrib import auth from django.urls import reverse from django.utils import timezone -from la_chariotte.order.models import GroupedOrder, Item +from la_chariotte.order.models import GroupedOrder, Item, Place from .utils import create_grouped_order @@ -140,3 +140,51 @@ class TestItemModel: assert grouped_order.item_set.first() == item assert grouped_order.item_set.all()[1] == item3 assert grouped_order.item_set.all()[2] == item2 + + +class TestPlaceModel: + def test_create_place(self, client_log): + assert Place.objects.count() == 0 + create_place_url = reverse("order:place_create") + response = client_log.get(create_place_url) + assert response.status_code == 200 + response = client_log.post( + create_place_url, + { + "name": "Centre social Kropotkine", + "code": "kropotkine", + }, + ) + assert response.status_code == 302 + assert Place.objects.count() == 1 + + def test_place_active_orders(self, client_log): + now = timezone.now() + + place = Place.objects.create( + name="Centre Social Kropotkine", + orga=auth.get_user(client_log), + code="kropotkine", + ) + + grouped_order_active = GroupedOrder.objects.create( + name="test", + orga=auth.get_user(client_log), + delivery_date=now.date() + datetime.timedelta(days=30), + deadline=now + datetime.timedelta(days=30), + ) + grouped_order_active.places.add(place.id) + + grouped_order_inactive = GroupedOrder.objects.create( + name="test", + orga=auth.get_user(client_log), + delivery_date=now.date() - datetime.timedelta(days=30), + deadline=now + datetime.timedelta(days=30), + ) + grouped_order_inactive.places.add(place.id) + + assert place.orders.all().count() == 2 + + active_orders = place.active_orders() + assert active_orders.count() == 1 + assert active_orders.first().id == grouped_order_active.id diff --git a/la_chariotte/tests/test_order_views_grouped_order.py b/la_chariotte/tests/test_order_views_grouped_order.py index ce6fa36..9c98c98 100644 --- a/la_chariotte/tests/test_order_views_grouped_order.py +++ b/la_chariotte/tests/test_order_views_grouped_order.py @@ -191,7 +191,7 @@ class TestGroupedOrderIndexView: class TestJoinGroupedOrderView: - def test_correct_code_redirects_properly(self, client, client_log): + def test_correct_order_code_redirects_properly(self, client, client_log): logged_user = auth.get_user(client_log) grouped_order = create_grouped_order( days_before_delivery_date=5, @@ -200,7 +200,7 @@ class TestJoinGroupedOrderView: orga_user=logged_user, ) join_url = reverse("dashboard") - response = client.post(join_url, {"code": grouped_order.code}) + response = client.post(join_url, {"order_code": grouped_order.code}) expected_url = reverse( "order:grouped_order_detail", kwargs={"code": grouped_order.code} @@ -208,17 +208,75 @@ class TestJoinGroupedOrderView: assert response.status_code == 302 assert response.url == expected_url - def test_incorrect_code_errors_out(self, client): + def test_incorrect_order_code_errors_out(self, client): assert len(models.GroupedOrder.objects.all()) == 0 join_url = reverse("dashboard") - response = client.post(join_url, {"code": "123456"}) + response = client.post(join_url, {"order_code": "123456"}) assert ( "Désolé, nous ne trouvons aucune commande avec ce code" in response.content.decode() ) + def test_correct_place_code_redirects_properly(self, client, client_log): + place = models.Place.objects.create( + name="foo", + orga=auth.get_user(client_log), + code="foo", + ) + + join_url = reverse("dashboard") + response = client.post(join_url, {"place_code": place.code}) + + expected_url = reverse("order:place_overview", kwargs={"code": place.code}) + assert response.status_code == 302 + assert response.url == expected_url + + def test_incorrect_place_code_errors_out(self, client): + assert len(models.Place.objects.all()) == 0 + + join_url = reverse("dashboard") + response = client.post(join_url, {"place_code": "123456"}) + + assert ( + "Désolé, nous ne trouvons aucun lieu avec ce code" + in response.content.decode() + ) + + def test_correct_order_code_has_precedence(self, client, client_log): + logged_user = auth.get_user(client_log) + + place = models.Place.objects.create( + name="foo", + orga=logged_user, + code="foo", + ) + grouped_order = create_grouped_order( + days_before_delivery_date=5, + days_before_deadline=2, + name="test", + orga_user=logged_user, + ) + + join_url = reverse("dashboard") + response = client.post( + join_url, {"place_code": place.code, "order_code": grouped_order.code} + ) + + expected_url = reverse( + "order:grouped_order_detail", kwargs={"code": grouped_order.code} + ) + assert response.status_code == 302 + assert response.url == expected_url + + def test_no_code_redirects_to_dashboard(self, client_log): + join_url = reverse("dashboard") + response = client_log.post(join_url, {}) + + assert response.status_code == 302 + assert response.url == join_url + class TestGroupedOrderDetailView: def test_order_item_with_authenticated_user(self, client, connected_grouped_order): @@ -920,7 +978,7 @@ class TestGroupedOrderUpdateView: grouped_order.save() assert models.GroupedOrder.objects.count() == 1 assert models.GroupedOrder.objects.first().name == "gr order test" - assert models.GroupedOrder.objects.first().place == None + assert models.GroupedOrder.objects.first().places.count() == 0 # get the update form update_grouped_order_url = reverse( @@ -942,7 +1000,7 @@ class TestGroupedOrderUpdateView: ) assert models.GroupedOrder.objects.count() == 1 assert models.GroupedOrder.objects.first().name == "gr order test" - assert models.GroupedOrder.objects.first().place == None + assert models.GroupedOrder.objects.first().places.count() == 0 # get the update form update_grouped_order_url = reverse( @@ -954,6 +1012,9 @@ class TestGroupedOrderUpdateView: # post the update form date = timezone.now().date() + datetime.timedelta(days=42) deadline = timezone.now() + datetime.timedelta(days=32) + place = models.Place.objects.create( + code="foobar", name="quelque part", orga=auth.get_user(client_log) + ) response = client_log.post( update_grouped_order_url, { @@ -961,14 +1022,15 @@ class TestGroupedOrderUpdateView: "deadline_date": deadline.date(), "deadline_time": deadline.time().strftime("%H:%M"), "delivery_date": date, - "place": "quelque part", + "places": place.id, }, ) + # assert response.content.decode() == "" assert response.status_code == 302 assert response.url.endswith("gerer-produits") assert models.GroupedOrder.objects.count() == 1 assert models.GroupedOrder.objects.first().name == "titre test modifié" - assert models.GroupedOrder.objects.first().place == "quelque part" + assert models.GroupedOrder.objects.first().places.first().name == "quelque part" def test_update_grouped_order__delivery_date_passed(self, client_log): """ @@ -983,7 +1045,7 @@ class TestGroupedOrderUpdateView: ) assert models.GroupedOrder.objects.count() == 1 assert models.GroupedOrder.objects.first().name == "gr order test" - assert models.GroupedOrder.objects.first().place == None + assert models.GroupedOrder.objects.first().places.count() == 0 # get the update form update_grouped_order_url = reverse( @@ -995,6 +1057,9 @@ class TestGroupedOrderUpdateView: # post the update form date = timezone.now().date() + datetime.timedelta(days=-1) deadline = timezone.now() + datetime.timedelta(days=-3) + place = models.Place.objects.create( + code="foobar", name="quelque part", orga=auth.get_user(client_log) + ) response = client_log.post( update_grouped_order_url, { @@ -1002,14 +1067,14 @@ class TestGroupedOrderUpdateView: "deadline_date": deadline.date(), "deadline_time": deadline.time().strftime("%H:%M"), "delivery_date": date, - "place": "quelque part", + "places": place.id, }, ) assert response.status_code == 302 assert response.url.endswith("gerer-produits") assert models.GroupedOrder.objects.count() == 1 assert models.GroupedOrder.objects.first().name == "titre test modifié" - assert models.GroupedOrder.objects.first().place == "quelque part" + assert models.GroupedOrder.objects.first().places.first().name == "quelque part" def test_update_grouped_order__not_orga(self, client_log, other_user): """A user that is not organiszer of the GO accesses update page. @@ -1315,7 +1380,9 @@ class TestGroupedOrderDuplicateView: assert new_grouped_order.name == "gr order test - copie" assert new_grouped_order.delivery_date == grouped_order.delivery_date assert new_grouped_order.deadline == grouped_order.deadline - assert new_grouped_order.place == grouped_order.place + assert [x.code for x in new_grouped_order.places.all()] == [ + x.code for x in grouped_order.places.all() + ] assert new_grouped_order.orga == auth.get_user(client_log) assert new_grouped_order.description == grouped_order.description assert new_grouped_order.item_set.count() == grouped_order.item_set.count() @@ -1428,6 +1495,7 @@ class TestGroupedOrderSheetView: assert response.context["grouped_order"] == grouped_order assert len(response.context["items"]) == 0 assert len(response.context["orders_dict"]) == 0 + assert "places" not in response.context.keys() # we order some items in the grouped order order = order_items_in_grouped_order(grouped_order) @@ -1437,6 +1505,7 @@ class TestGroupedOrderSheetView: assert len(response.context["items"]) == 2 assert response.context["orders_dict"][order] == [3, 2] assert response.context["grouped_order"].total_price == 35 + assert "places" not in response.context.keys() # test if the orders are sorted by last names orders = list(response.context["orders_dict"].keys()) @@ -1446,6 +1515,76 @@ class TestGroupedOrderSheetView: assert orders[1].author.first_name == "bobby" assert orders[2].author.last_name == "lescargot" + def test_get_pdf_sheet_with_places(self, client_log, other_user): + """The orga of the grouped models.Order accesses the pdf sheet""" + + place1 = models.Place.objects.create( + name="Centre social Kropotkine", + orga=auth.get_user(client_log), + code="kropotkine", + ) + place2 = models.Place.objects.create( + name="Centre social Luxemburg", + orga=auth.get_user(client_log), + code="luxemburg", + ) + + grouped_order = create_grouped_order( + days_before_delivery_date=5, + days_before_deadline=2, + name="gr order test", + orga_user=auth.get_user(client_log), + ) + grouped_order.places.add(place1.id) + grouped_order.places.add(place2.id) + generate_sheet_url = reverse( + "order:grouped_order_sheet", + kwargs={ + "code": grouped_order.code, + }, + ) + response = client_log.get(generate_sheet_url) + assert response.status_code == 200 + assert response.context["grouped_order"] == grouped_order + assert len(response.context["items"]) == 0 + assert len(response.context["orders_dict"]) == 0 + assert "places" in response.context.keys() + assert "places_orders" in response.context.keys() + assert len(response.context["places"]) == 2 + assert len(response.context["places_orders"]) == 2 + assert len(response.context["places_orders"]["kropotkine"]) == 0 + assert len(response.context["places_orders"]["luxemburg"]) == 0 + + # we order some items in the grouped order + order = order_items_in_grouped_order(grouped_order, place=place2) + response = client_log.get(generate_sheet_url) + assert response.status_code == 200 + assert response.context["grouped_order"] == grouped_order + assert len(response.context["items"]) == 2 + assert response.context["orders_dict"][order] == [3, 2] + assert response.context["grouped_order"].total_price == 35 + assert "places" in response.context.keys() + + # test that orders are split by distribution place + places_orders = response.context["places_orders"] + assert len(places_orders["kropotkine"]) == 0 + assert len(places_orders["luxemburg"]) == 3 + + orders = list(places_orders["luxemburg"]) + assert orders[0].author.last_name == "alescargot" + assert orders[0].author.first_name == "bob" + assert orders[1].author.last_name == "alescargot" + assert orders[1].author.first_name == "bobby" + assert orders[2].author.last_name == "lescargot" + + # test that splitting by place does not affect orders_dict overall + orders = list(response.context["orders_dict"].keys()) + assert orders[0].author.last_name == "alescargot" + assert orders[0].author.first_name == "bob" + assert orders[1].author.last_name == "alescargot" + assert orders[1].author.first_name == "bobby" + assert orders[2].author.last_name == "lescargot" + class TestExportGroupOrderEmailAdressesToDownloadView: def test_user_not_logged_gets_redirected(self, client, other_user): @@ -1554,6 +1693,67 @@ class TestExportGroupOrderEmailAdressesToDownloadView: content = response.content.decode() assert "test@mail.fr\r\n" in content + def test_export_format_csv_with_places(self, client_log): + place = models.Place.objects.create( + name="Centre social Kropotkine", + orga=auth.get_user(client_log), + code="kropotkine", + ) + grouped_order = create_grouped_order( + days_before_delivery_date=5, + days_before_deadline=2, + name="gr order test", + orga_user=auth.get_user(client_log), + ) + grouped_order.places.add(place.id) + grouped_order.save() + item = models.Item.objects.create( + name="test item 1", grouped_order=grouped_order, price=2 + ) + + overview_url = reverse( + "order:grouped_order_overview", + kwargs={ + "code": grouped_order.code, + }, + ) + order_url = reverse( + "order:order", + kwargs={ + "code": grouped_order.code, + }, + ) + response = client_log.post( + order_url, + { + f"quantity_{item.pk}": [4, 0], + "first_name": "Prénom", + "last_name": "Nom", + "phone": "0645632569", + "email": "test@mail.fr", + "note": "", + "place": place.code, + }, + ) + + assert response.content.decode() == "" + assert response.status_code == 302 + + email_list_view_url = ( + reverse( + "order:email_list", + kwargs={ + "code": grouped_order.code, + }, + ) + + "?format=csv" + ) + response = client_log.get(email_list_view_url) + assert response.status_code == 200 + assert response["Content-Type"] == "text/csv" + content = response.content.decode() + assert "test@mail.fr\r\n" in content + def test_export_format_default(self, client_log): grouped_order = create_grouped_order( days_before_delivery_date=5, @@ -1716,3 +1916,93 @@ class TestExportGroupedOrderToCSVView: ], ["", "TOTAL", "4", "3", "35,00"], ] + + def test_csv_export_with_places(self, client_log): + """ + The grouped order orga accesses the csv view + """ + place = models.Place.objects.create( + name="Centre social Kropotkine", + orga=auth.get_user(client_log), + code="kropotkine", + ) + grouped_order = create_grouped_order( + days_before_delivery_date=5, + days_before_deadline=2, + name="gr order test", + orga_user=auth.get_user(client_log), + ) + grouped_order.places.add(place.id) + + order = order_items_in_grouped_order(grouped_order, place=place) + csv_view_url = reverse( + "order:grouped_order_csv_export", + kwargs={ + "code": grouped_order.code, + }, + ) + response = client_log.get(csv_view_url) + assert response.status_code == 200 + + content = response.content.decode("utf-8") + csv_reader = csv.reader(StringIO(content), delimiter=";") + body = list(csv_reader) + created_date = f"{timezone.now().strftime('%d/%m/%Y')}" + created_time = f"{timezone.now().strftime('%H:%M')}" + assert body == [ + [ + "", + "", + "test item", + "test item 2", + "Prix de la commande", + "Mail", + "Téléphone", + "Note", + "Date", + "Heure", + "Lieu", + ], + ["", "Prix unitaire TTC (€)", "2,00", "9,00"], + ["Nom", "Prénom"], + [ + "alescargot", + "bob", + "1", + "0", + "2,00", + "bob2@escargot.fr", + "'000", + "", + created_date, + created_time, + "Centre social Kropotkine", + ], + [ + "alescargot", + "bobby", + "0", + "1", + "9,00", + "bob3@escargot.fr", + "'000", + "", + created_date, + created_time, + "Centre social Kropotkine", + ], + [ + "lescargot", + "bob", + "3", + "2", + "24,00", + "bob@escargot.fr", + "'000", + "", + created_date, + created_time, + "Centre social Kropotkine", + ], + ["", "TOTAL", "4", "3", "35,00"], + ] diff --git a/la_chariotte/tests/test_order_views_order.py b/la_chariotte/tests/test_order_views_order.py index 5ff4017..b4d62f2 100644 --- a/la_chariotte/tests/test_order_views_order.py +++ b/la_chariotte/tests/test_order_views_order.py @@ -268,3 +268,173 @@ class TestOrder: assert grouped_order.order_set.all().count() == 2 assert grouped_order.total_price == 35 - order_price assert item.ordered_nb == 1 + + def test_order_valid_place(self, client_log): + """The orga user requests a non-existing place for distribution. It fails""" + grouped_order = create_grouped_order( + days_before_delivery_date=5, + days_before_deadline=2, + name="gr order test", + orga_user=auth.get_user(client_log), + ) + place = models.Place.objects.create( + name="Centre social Kropotkine", + code="kropotkine", + orga=auth.get_user(client_log), + ) + grouped_order.places.add(place.id) + + item = models.Item.objects.create( + name="test item 1", grouped_order=grouped_order, price=1 + ) + + order_url = reverse( + "order:order", + kwargs={ + "code": grouped_order.code, + }, + ) + response = client_log.post( + order_url, + { + f"quantity_{item.pk}": [4, 0], + "first_name": "Prénom", + "last_name": "Nom", + "phone": "0645632569", + "email": "test@mail.fr", + "note": "", + "place": place.code, + }, + ) + assert response.status_code == 302 + + # TODO: How to test trying to use a place from a different user/orga? + def test_order_invalid_place(self, client_log): + """The orga user requests a non-existing place for distribution. It fails""" + grouped_order = create_grouped_order( + days_before_delivery_date=5, + days_before_deadline=2, + name="gr order test", + orga_user=auth.get_user(client_log), + ) + place = models.Place.objects.create( + name="Centre social Kropotkine", + code="kropotkine", + orga=auth.get_user(client_log), + ) + grouped_order.places.add(place.id) + + item = models.Item.objects.create( + name="test item 1", grouped_order=grouped_order, price=1 + ) + + order_url = reverse( + "order:order", + kwargs={ + "code": grouped_order.code, + }, + ) + response = client_log.post( + order_url, + { + f"quantity_{item.pk}": [4, 0], + "first_name": "Prénom", + "last_name": "Nom", + "phone": "0645632569", + "email": "test@mail.fr", + "note": "", + "place": "foobar", + }, + ) + assert response.status_code == 200 + assert ( + response.context["error_message"] + == "Le lieu demandé n'est pas valide pour cette commande: foobar" + ) + + def test_order_unaffiliated_place(self, client_log): + """The orga user requests a place for distribution unrelated to this grouped order. It fails""" + grouped_order = create_grouped_order( + days_before_delivery_date=5, + days_before_deadline=2, + name="gr order test", + orga_user=auth.get_user(client_log), + ) + place = models.Place.objects.create( + name="Centre social Kropotkine", + code="kropotkine", + orga=auth.get_user(client_log), + ) + place2 = models.Place.objects.create( + name="Centre social Luxemburg", + code="luxemburg", + orga=auth.get_user(client_log), + ) + grouped_order.places.add(place.id) + + item = models.Item.objects.create( + name="test item 1", grouped_order=grouped_order, price=1 + ) + + order_url = reverse( + "order:order", + kwargs={ + "code": grouped_order.code, + }, + ) + response = client_log.post( + order_url, + { + f"quantity_{item.pk}": [4, 0], + "first_name": "Prénom", + "last_name": "Nom", + "phone": "0645632569", + "email": "test@mail.fr", + "note": "", + "place": place2.code, + }, + ) + assert response.status_code == 200 + assert ( + response.context["error_message"] + == "Le lieu demandé n'est pas valide pour cette commande: %s" % place2.code + ) + + def test_order_no_place(self, client_log): + """The orga user requests a non-existing place for distribution. It fails""" + grouped_order = create_grouped_order( + days_before_delivery_date=5, + days_before_deadline=2, + name="gr order test", + orga_user=auth.get_user(client_log), + ) + place = models.Place.objects.create( + name="Centre social Kropotkine", + code="kropotkine", + orga=auth.get_user(client_log), + ) + grouped_order.places.add(place.id) + + item = models.Item.objects.create( + name="test item 1", grouped_order=grouped_order, price=1 + ) + + order_url = reverse( + "order:order", + kwargs={ + "code": grouped_order.code, + }, + ) + response = client_log.post( + order_url, + { + f"quantity_{item.pk}": [4, 0], + "first_name": "Prénom", + "last_name": "Nom", + "phone": "0645632569", + "email": "test@mail.fr", + "note": "", + }, + ) + assert response.status_code == 200 + assert "Aucun lieu n'est spécifié" in response.context["error_message"] diff --git a/la_chariotte/tests/test_order_views_place.py b/la_chariotte/tests/test_order_views_place.py new file mode 100644 index 0000000..08ab33f --- /dev/null +++ b/la_chariotte/tests/test_order_views_place.py @@ -0,0 +1,489 @@ +import re + +import pytest +from django.contrib import auth +from django.urls import reverse + +from la_chariotte.order import models + +from .utils import create_grouped_order, order_items_in_grouped_order + +pytestmark = pytest.mark.django_db + + +class TestPlaceOverviewView: + def test_overview_place(self, client_log): + """A user checks out a place successfully""" + place = models.Place.objects.create( + name="foo", + orga=auth.get_user(client_log), + code="foo", + ) + + overview_place_view_url = reverse( + "order:place_overview", + kwargs={ + "code": place.code, + }, + ) + + response = client_log.get(overview_place_view_url) + assert response.status_code == 200 + + body = response.content.decode() + assert "foo: Commandes groupées" + assert "Aucune commande groupée en cours" + + def test_overview_with_orders(self, client_log): + """A user checks out a place with several orders""" + place = models.Place.objects.create( + name="foo", + orga=auth.get_user(client_log), + code="foo", + ) + + overview_place_view_url = reverse( + "order:place_overview", + kwargs={ + "code": place.code, + }, + ) + + grouped_order1 = create_grouped_order( + 31, 30, "firstorder", auth.get_user(client_log) + ) + grouped_order1.places.add(place) + grouped_order2 = create_grouped_order( + 31, 30, "secondorder", auth.get_user(client_log) + ) + grouped_order2.places.add(place) + + # Generate some items + order = order_items_in_grouped_order(grouped_order1, place=place) + + response = client_log.get(overview_place_view_url) + assert response.status_code == 200 + + body = response.content.decode() + assert "foo: Commandes groupées" + assert "foo (2 commandes groupées en cours)" in body + assert '">firstorder (2 produits)' in body + assert '">secondorder (0 produit)' in body + + def test_overview_place__anonymous_user(self, client, other_user): + """ + A user that is not logged can check out a place + """ + place = models.Place.objects.create( + name="foo", + orga=other_user, + code="foo", + ) + + overview_place_view_url = reverse( + "order:place_overview", + kwargs={ + "code": place.code, + }, + ) + + response = client.get(overview_place_view_url) + assert response.status_code == 200 + + body = response.content.decode() + assert "foo: Commandes groupées" + assert "Aucune commande groupée en cours" + + +class TestPlaceUpdateView: + def test_update_place(self, client_log): + """A user updates their place successfully""" + place = models.Place.objects.create( + name="foo", + orga=auth.get_user(client_log), + code="foo", + ) + + update_place_view_url = reverse( + "order:place_update", + kwargs={ + "code": place.code, + }, + ) + + overview_place_view_url = reverse( + "order:place_overview", + kwargs={ + "code": place.code, + }, + ) + + response = client_log.post( + update_place_view_url, + { + "name": "bar", + "description": "longer bar", + "code": place.code, + }, + ) + + assert response.status_code == 302 + assert response.url == overview_place_view_url + + place = models.Place.objects.all().filter(code=place.code).first() + + assert place.name == "bar" + assert place.description == "longer bar" + assert place.code == "foo" + + def test_update_place_code_silently_fails(self, client_log): + """A user tries to change a place's identifier, which fails because it's immutable""" + place = models.Place.objects.create( + name="foo", + orga=auth.get_user(client_log), + code="foo", + ) + + update_place_view_url = reverse( + "order:place_update", + kwargs={ + "code": place.code, + }, + ) + + response = client_log.post( + update_place_view_url, + { + "name": "bar", + "description": "longer bar", + "code": "newcode", + }, + ) + + # Ideally, response should look successful, but not alter the place's code + # But for now this just returns 404 + assert response.content.decode() == "" + assert response.status_code == 302 + + place = models.Place.objects.all().filter(code=place.code).first() + + assert place.name == "bar" + assert place.description == "longer bar" + assert place.code == "foo" + + def test_update_place_does_not_exist_fails(self, client_log): + """A user tries to update a place which does not exist, which fails""" + place = models.Place.objects.create( + name="foo", + orga=auth.get_user(client_log), + code="foo", + ) + + update_place_view_url = reverse( + "order:place_update", + kwargs={ + "code": "notfoo", + }, + ) + + response = client_log.post( + update_place_view_url, + { + "name": "bar", + "description": "longer bar", + "code": "newcode", + }, + ) + + # Response should look successful, but not alter the place's code + assert response.status_code == 404 + + place = models.Place.objects.all().filter(code=place.code).first() + + assert place.name == "foo" + assert place.description == None + assert place.code == "foo" + + def test_update_place__not_orga(self, client_log, other_user): + """ + A user that is not orga cannot edit a place. They get a 403 error. + """ + place = models.Place.objects.create( + name="foo", + code="foo", + orga=other_user, + ) + + update_place_view_url = reverse( + "order:place_update", + kwargs={ + "code": "foo", + }, + ) + response = client_log.post( + update_place_view_url, {"name": "notfoo", "code": "foo"} + ) + assert response.status_code == 403 + + +class TestPlaceCreateView: + def test_create_place(self, client_log): + """A user creates a new place.""" + create_place_view_url = reverse( + "order:place_create", + ) + response = client_log.post( + create_place_view_url, {"name": "foo", "code": "foo"} + ) + assert response.status_code == 302 + assert response.url == reverse( + "order:place_index", + ) + assert models.Place.objects.first().name == "foo" + response = client_log.get(response.url) + assert "foo" in response.content.decode() + + def test_create_place__anonymous_user(self, client, other_user): + """ + A user that is not logged cannot create a place. They get a redirected to loginview. + """ + create_place_view_url = reverse( + "order:place_create", + ) + response = client.post(create_place_view_url, {"name": "foo", "code": "foo"}) + assert response.status_code == 302 + assert ( + response.url == f"{reverse('accounts:login')}?next={create_place_view_url}" + ) + + def test_create_place__already_exists(self, client_log): + """A user creates a new place whose code already exists. It fails""" + create_place_view_url = reverse( + "order:place_create", + ) + response = client_log.post( + create_place_view_url, {"name": "foo", "code": "foo"} + ) + # TODO: is this correct???? + assert response.status_code == 302 + assert response.url == reverse( + "order:place_index", + ) + assert models.Place.objects.first().name == "foo" + response = client_log.get(response.url) + assert "foo" in response.content.decode() + + +class TestPlaceIndexView: + def test_index_place(self, client_log): + """A user lists their places.""" + place1 = models.Place.objects.create( + name="foo", + code="foo", + orga=auth.get_user(client_log), + ) + place2 = models.Place.objects.create( + name="bar", + code="bar", + orga=auth.get_user(client_log), + ) + index_place_view_url = reverse( + "order:place_index", + ) + response = client_log.get(index_place_view_url) + assert response.status_code == 200 + + body = response.content.decode() + + place1_overview_view_url = reverse( + "order:place_overview", + kwargs={ + "code": place1.code, + }, + ) + place2_overview_view_url = reverse( + "order:place_overview", + kwargs={ + "code": place2.code, + }, + ) + + place1_update_view_url = reverse( + "order:place_update", + kwargs={ + "code": place1.code, + }, + ) + place2_update_view_url = reverse( + "order:place_update", + kwargs={ + "code": place2.code, + }, + ) + + assert place1_overview_view_url in body + assert place1_update_view_url in body + assert place2_overview_view_url in body + assert place2_update_view_url in body + + def test_index_place_active_order(self, client_log): + """A user lists their places, only one has an active order. Inactive orders are not shown.""" + place1 = models.Place.objects.create( + name="foo", + code="foo", + orga=auth.get_user(client_log), + ) + place2 = models.Place.objects.create( + name="bar", + code="bar", + orga=auth.get_user(client_log), + ) + grouped_order1 = create_grouped_order( + 31, 30, "order", auth.get_user(client_log) + ) + grouped_order1.places.add(place1) + grouped_order2 = create_grouped_order( + -2, -1, "inactive_order", auth.get_user(client_log) + ) + grouped_order2.places.add(place1) + + index_place_view_url = reverse( + "order:place_index", + ) + response = client_log.get(index_place_view_url) + assert response.status_code == 200 + + body = response.content.decode() + + place1_overview_view_url = reverse( + "order:place_overview", + kwargs={ + "code": place1.code, + }, + ) + place2_overview_view_url = reverse( + "order:place_overview", + kwargs={ + "code": place2.code, + }, + ) + + place1_update_view_url = reverse( + "order:place_update", + kwargs={ + "code": place1.code, + }, + ) + place2_update_view_url = reverse( + "order:place_update", + kwargs={ + "code": place2.code, + }, + ) + + grouped_order1_detail_view_url = reverse( + "order:grouped_order_detail", + kwargs={ + "code": grouped_order1.code, + }, + ) + grouped_order2_detail_view_url = reverse( + "order:grouped_order_detail", + kwargs={ + "code": grouped_order2.code, + }, + ) + + assert place1_overview_view_url in body + assert place1_update_view_url in body + assert grouped_order1_detail_view_url in body + assert place2_overview_view_url in body + assert place2_update_view_url in body + + # Inactive order is not displayed + assert "inactive_order" not in body + assert str(grouped_order2_detail_view_url) not in body + + def test_index_place_active_order_in_correct_order(self, client_log): + """A user lists their places. One has two active orders. The latest delivery date is displayed first.""" + place = models.Place.objects.create( + name="foo", + code="foo", + orga=auth.get_user(client_log), + ) + grouped_order1 = create_grouped_order( + 31, 30, "order", auth.get_user(client_log) + ) + grouped_order1.places.add(place) + grouped_order2 = create_grouped_order( + 61, 60, "more_recent_order", auth.get_user(client_log) + ) + grouped_order2.places.add(place) + + index_place_view_url = reverse( + "order:place_index", + ) + response = client_log.get(index_place_view_url) + assert response.status_code == 200 + + body = response.content.decode() + + place_update_view_url = reverse( + "order:place_update", + kwargs={ + "code": place.code, + }, + ) + + place_overview_view_url = reverse( + "order:place_overview", + kwargs={ + "code": place.code, + }, + ) + + grouped_order1_detail_view_url = reverse( + "order:grouped_order_detail", + kwargs={ + "code": grouped_order1.code, + }, + ) + grouped_order2_detail_view_url = reverse( + "order:grouped_order_detail", + kwargs={ + "code": grouped_order2.code, + }, + ) + + assert place_overview_view_url in body + assert place_update_view_url in body + assert grouped_order1_detail_view_url in body + assert grouped_order2_detail_view_url in body + + def test_index_place__not_orga(self, client_log, other_user): + """ + A user that is not orga cannot view places they don't own. + """ + place = models.Place.objects.create( + name="NOTMYPLACE", + code="NOTMYPLACE", + orga=other_user, + ) + + update_place_view_url = reverse( + "order:place_update", + kwargs={ + "code": "NOTMYPLACE", + }, + ) + + index_place_view_url = reverse( + "order:place_index", + ) + + response = client_log.get(index_place_view_url) + assert response.status_code == 200 + + body = response.content.decode() + assert place.name not in body + assert update_place_view_url not in body diff --git a/la_chariotte/tests/utils.py b/la_chariotte/tests/utils.py index 6cdeca8..4df334c 100644 --- a/la_chariotte/tests/utils.py +++ b/la_chariotte/tests/utils.py @@ -29,7 +29,7 @@ def create_grouped_order( return grouped_order -def order_items_in_grouped_order(grouped_order): +def order_items_in_grouped_order(grouped_order, place=None): """Creates 2 OrderedItems and orders in the given grouped order. Returns the order""" item_1 = grouped_order.item_set.create(name="test item", price="2") item_2 = grouped_order.item_set.create(name="test item 2", price="9") @@ -45,9 +45,9 @@ def order_items_in_grouped_order(grouped_order): phone="000", email="bob3@escargot.fr", ) - order = grouped_order.order_set.create(author=author_1) - order_2 = grouped_order.order_set.create(author=author_2) - order_3 = grouped_order.order_set.create(author=author_3) + order = grouped_order.order_set.create(author=author_1, place=place) + order_2 = grouped_order.order_set.create(author=author_2, place=place) + order_3 = grouped_order.order_set.create(author=author_3, place=place) models.OrderedItem.objects.create(order=order, item=item_1, nb=3) models.OrderedItem.objects.create(order=order, item=item_2, nb=2) models.OrderedItem.objects.create(order=order_2, item=item_1, nb=1) diff --git a/la_chariotte/urls.py b/la_chariotte/urls.py index a10cf50..74fdb9e 100644 --- a/la_chariotte/urls.py +++ b/la_chariotte/urls.py @@ -29,7 +29,8 @@ from la_chariotte.order.views.stats import stats urlpatterns = [ path("admin/", admin.site.urls), - path("commande/", include("la_chariotte.order.urls")), + # No route specified because there are multiple subroutes defined in order.urls + path("", include("la_chariotte.order.urls")), path("comptes/", include("la_chariotte.accounts.urls")), # Some paths for accounts are easier to leave here # - PasswordResetView sends the mail