Merge branch 'feat-distribution-place' into 'develop'

Draft: feature: Add placekey relationship for distribution spots

See merge request la-chariotte/la-chariotte!129
This commit is contained in:
selfhoster1312 ACAB 2025-04-17 15:43:03 +00:00
commit fb1731fe73
26 changed files with 1781 additions and 138 deletions

View file

@ -22,7 +22,7 @@
<p><strong>Rendez-vous pour la distribution</strong> :
le {{ order.grouped_order.delivery_date }}{% if order.grouped_order.delivery_slot %}, {{ order.grouped_order.delivery_slot }}{% endif %}
{% if order.grouped_order.place %}<br>Lieu : {{ order.grouped_order.place }}{% endif %}
{% if order.place %}<br>Lieu : {{ order.place }}{% endif %}
</p>
<p><strong>Une question sur cette commande groupée ?</strong><br>Vous pouvez contacter l'organisateur·ice de la commande, <strong>{{ order.grouped_order.orga }}</strong> :
@ -32,4 +32,4 @@
<a class="button" href="{{ base_url }}{% url 'order:grouped_order_detail' order.grouped_order.code %}">Voir la page de commande</a>
</div>
{% endblock %}
{% endblock %}

View file

@ -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)

View file

@ -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)

View file

@ -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"),
),
]

View file

@ -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()

View file

@ -27,10 +27,6 @@
{% endif %}
</div>
<div class="column">
{% if grouped_order.place %}
<p><i class="fa fa-map-pin mr-3" aria-label="Lieu" title="Lieu"
aria-hidden="true"></i>{{ grouped_order.place }}</p>
{% endif %}
<p><i class="fa fa-calendar-check-o mr-3" aria-label="Date limite de commande"
title="Date limite de commande" aria-hidden="true"></i>
Commandes avant le {{ grouped_order.deadline|date:'d M Y' }} à {{ grouped_order.deadline|date:'H:i' }}
@ -171,6 +167,16 @@
<p><label for="note">Note à l'organisateur·ice<em> (facultatif)</em> :</label>
<textarea id="note" rows=3 name="note">{{ note }}</textarea></p>
{% if places %}
<label for="place">Point de distribution:</label>
<select name="place" id="place-select">
{% for place in places %}
<option value="{{ place.code }}">{{ place.name }}</option>
{% endfor %}
</select>
{% endif %}
<hr>
<div class="buttons">
<button id="submit" type="submit" value="Order" class="button is-primary">
<i class="fa fa-shopping-basket mr-3" aria-hidden="true"></i>Commander

View file

@ -184,6 +184,7 @@
<header class="modal-card-head has-background-info">
<div class="modal-card-title-container">
<p class="modal-card-title mb-2">Commande de {{ order.author }}</p>
{% if order.place %}<p class="has-text-grey-dark">Lieu: {{ order.place }}</p>{% endif %}
<p class="has-text-grey-dark">Le {{ order.created_date|date:'d M Y' }} à {{ order.created_date|date:'H:i' }}</p>
</div>
<button class="delete" aria-label="close"></button>
@ -370,4 +371,4 @@
});
{% endblock %}
</script>
</script>

View file

@ -75,64 +75,13 @@
<h2 style="text-align: center">
{{ grouped_order.name }} - {{ grouped_order.delivery_date }}
</h2>
{% if items %}
<table>
<thead>
<tr>
<th style="font-size: 0.5em; width: 2em">OK</th>
<th style="text-align: center; width: 20%">Nom</th>
{% for item in items %}
<th class="item_name" style="font-weight: normal;">
<div>{{ item.name }}</div>
</th>
{% endfor %}
<th style="width: 2cm">Prix</th>
</tr>
</thead>
<tbody>
<tr style="background-color: #bababa">
<td></td>
<td style="text-align: left">Prix unitaire</td>
{% for item in items %}
<td>
{{ item.price }} €
</td>
{% endfor %}
<td></td>
</tr>
<tr style="background-color: #bababa">
<th></th>
<th style="text-align: left">TOTAL</th>
{% for item in items %}
<th>
{{ item.ordered_nb }}
</th>
{% endfor %}
<th>{{ grouped_order.total_price }} €</th>
</tr>
{% for order, ordered_items in orders_dict.items %}
<tr>
<td></td>
<td>
{{ order.author.last_name|upper }} {{ order.author.first_name }}
</td>
{% for ordered_item in ordered_items %}
<td>
{% if ordered_item > 0 %}
{{ ordered_item }}
{% endif %}
</td>
{% endfor %}
<td>
{{ order.price }} €
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if places %}
{% for place_name, place_orders in places.items %}
<h3>{{ place_name }}</h3>
{% 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 %}
</body>
</html>

View file

@ -0,0 +1,58 @@
{% if items %}
<table>
<thead>
<tr>
<th style="font-size: 0.5em; width: 2em">OK</th>
<th style="text-align: center; width: 20%">Nom</th>
{% for item in items %}
<th class="item_name" style="font-weight: normal;">
<div>{{ item.name }}</div>
</th>
{% endfor %}
<th style="width: 2cm">Prix</th>
</tr>
</thead>
<tbody>
<tr style="background-color: #bababa">
<td></td>
<td style="text-align: left">Prix unitaire</td>
{% for item in items %}
<td>
{{ item.price }} €
</td>
{% endfor %}
<td></td>
</tr>
<tr style="background-color: #bababa">
<th></th>
<th style="text-align: left">TOTAL</th>
{% for item in items %}
<th>
{{ item.ordered_nb }}
</th>
{% endfor %}
<th>{{ grouped_order.total_price }} €</th>
</tr>
{% for order, ordered_items in orders_dict.items %}
<tr>
<td></td>
<td>
{{ order.author.last_name|upper }} {{ order.author.first_name }}
</td>
{% for ordered_item in ordered_items %}
<td>
{% if ordered_item > 0 %}
{{ ordered_item }}
{% endif %}
</td>
{% endfor %}
<td>
{{ order.price }} €
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
Aucun produit n'a été commandé
{% endif %}

View file

@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block title %}Mes lieux de livraison{% endblock %}
{% block content %}
<p class="desktop-hidden mobile-content-title">
{% block content_title %}Lieux de distribution que vous organisez{% endblock %}
</p>
<div class="buttons is-pulled-right">
<a class="button is-primary" href="{% url 'order:place_create' %}">
<i class="fa fa-plus-circle mr-3" aria-hidden="true"></i>
Créer un nouveau lieu de distribution</a>
</div>
{% if context.places %}
<table class="table">
<thead>
<tr>
<th>Lieu</th>
<th>Commandes</th>
<th>Description</th>
<th>URL</th>
<th> </th>
</tr>
</thead>
<tbody>
{% for place in context.places %}
<tr>
<td>
(<a href="{% url 'order:place_update' place.code %}">modifier</a>) <a title="Détail du lieu de distribution" href="{% url 'order:place_overview' place.code %}">{{ place }}</a>
</td>
<td>
{% 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 %}
<a href="{{ order_url }}">{{order.name}}</a><br>
{% else %}
{{ order.name }}<br>
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% else %}
Aucune
{% endif %}
</td>
<td>
{{ place.description }}
</td>
<td>
{{ place.code }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Pas de lieux de distribution pour l'instant</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Nouveau lieu de distribution{% endblock %}
{% block content %}
<p class="desktop-hidden mobile-content-title">
{% block content_title %}Créer un lieu de distribution{% endblock %}
</p>
<div class="box">
<p class="title">Nouveau lieu</p>
<div class="columns">
<div class="column is-8">
<form method="post" onsubmit="deadlinePassedCheck(event)">
{% csrf_token %}
{{ form | crispy }}
<div class="buttons">
<a class="button is-light" href="{% url 'order:place_index' %}">Annuler</a>
<input class="button is-primary" type="submit" value="Suivant">
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% extends 'base.html' %}
{% block title %}Commandes groupées à: {{ place.name }}{% endblock %}
{% block content %}
{% if user.is_authenticated %}
<div class="buttons">
<a class="button is-light" href="{% url 'order:place_index' %}">
<i class="fa fa-arrow-left mr-3" aria-hidden="true"></i>Retour aux lieux
</a>
<a class="button is-primary" href="{% url 'order:index' %}">
<i class="fa fa-shopping-basket mr-3" aria-hidden="true"></i>Mes commandes
</a>
</div>
<div>
</div>
{% endif %}
{% if user == place.orga %}
<div class="buttons is-pulled-right">
<a class="button is-primary" href="{% url 'order:place_update' place.code %}">
<i class="fa fa-plus-circle mr-3" aria-hidden="true"></i>
Modifier</a>
</div>
{% endif %}
<p class="desktop-hidden mobile-content-title">
{% block content_title %}{{ place.name }}: Commandes groupées{% endblock %}
</p>
{% if active_orders %}
<p class="title">{{ place.name }} ({{ active_orders | length }} commandes groupées en cours)</p>
{% for order in active_orders %}
<div class="box">
<h4><a href="{% url "order:grouped_order_detail" order.code %}">{{ order.name }}</a> ({{ order.item_set.all|length }} produit{% if order.item_set.all|length > 1 %}s{% endif %})</h4>
<p>
<i class="fa fa-calendar" aria-hidden="true"></i>&nbsp;&nbsp;<i>Fin des commandes</i> le {{ order.deadline }}
<br><i class="fa fa-truck" aria-hidden="true"></i>&nbsp;&nbsp;<i>Livraison</i> le {{ order.delivery_date }}
{% if order.places.all|length > 1 %}
<br><i class="fa fa-building" aria-hidden="true"></i>&nbsp;&nbsp;<i>Autres lieux de livraison:</i>
{% for other_place in order.places.all %}
{% if other_place.code != place.code %}
<a href="{% url 'order:place_overview' other_place.code %}">{{ other_place.name }}</a>
{% if forloop.counter0|add:"2" < order.places.all|length %}, {% endif %}
{% endif %}
{% endfor %}
{% endif %}
</p>
<div class="buttons">
<a class="button is-primary" href="{% url 'order:grouped_order_detail' order.code %}">
<i class="fa fa-shopping-basket mr-3" aria-hidden="true"></i>
Commander</a>
</div>
</div>
{% endfor %}
{% else %}
<p>Aucune commande groupée en cours dans ce lieu.</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Modifier le lieu de livraison {% endblock %}
{% block content %}
<p class="desktop-hidden mobile-content-title">
{% block content_title %}Modifier le lieu de livraison{% endblock %}
</p>
<div class="box">
<p class="title">{{ place.name }} - modifier</p>
<form method="post">{% csrf_token %}
{{ form | crispy }}
<div class="buttons">
<a class="button is-light" href="{% url 'order:place_index' %}">Annuler</a>
<input class="button is-primary" type="submit" value="Suivant">
</div>
</form>
</div>
{% endblock %}

View file

@ -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(
"<str:code>/",
"commande/<str:code>/",
views.GroupedOrderDetailView.as_view(),
name="grouped_order_detail",
),
path(
"<str:code>/ics/",
"commande/<str:code>/ics/",
views.GroupedOrderEventView.as_view(),
name="grouped_order_event",
),
path(
"<str:code>/gerer",
"commande/<str:code>/gerer",
views.GroupedOrderOverview.as_view(),
name="grouped_order_overview",
),
path("<str:code>/commander/", views.place_order, name="order"),
path("commande/<str:code>/commander/", views.place_order, name="order"),
path(
"<str:code>/<int:pk>/confirmation/",
"commande/<str:code>/<int:pk>/confirmation/",
views.OrderDetailView.as_view(),
name="order_confirm",
),
path(
"<str:code>/gerer/<int:pk>/supprimer",
"commande/<str:code>/gerer/<int:pk>/supprimer",
views.OrderDeleteView.as_view(),
name="order_delete",
),
path("creer", views.GroupedOrderCreateView.as_view(), name="create_grouped_order"),
path(
"<str:code>/gerer-produits",
"commande/creer",
views.GroupedOrderCreateView.as_view(),
name="create_grouped_order",
),
path(
"commande/<str:code>/gerer-produits",
views.GroupedOrderAddItemsView.as_view(),
name="manage_items",
),
path(
"<str:code>/modifier",
"commande/<str:code>/modifier",
views.GroupedOrderUpdateView.as_view(),
name="update_grouped_order",
),
path(
"<str:code>/supprimer",
"commande/<str:code>/supprimer",
views.GroupedOrderDeleteView.as_view(),
name="delete_grouped_order",
),
path(
"<str:code>/dupliquer",
"commande/<str:code>/dupliquer",
views.GroupedOrderDuplicateView.as_view(),
name="duplicate_grouped_order",
),
path(
"<str:code>/gerer-produits/nouveau",
"commande/<str:code>/gerer-produits/nouveau",
views.ItemCreateView.as_view(),
name="item_create",
),
path(
"<str:code>/gerer-produits/<int:pk>/supprimer",
"commande/<str:code>/gerer-produits/<int:pk>/supprimer",
views.ItemDeleteView.as_view(),
name="item_delete",
),
path(
"<str:code>/gerer/imprimer",
"commande/<str:code>/gerer/imprimer",
views.DownloadGroupedOrderSheetView.as_view(),
name="grouped_order_sheet",
),
path(
"<str:code>/gerer/liste-mails",
"commande/<str:code>/gerer/liste-mails",
views.ExportGroupOrderEmailAdressesToDownloadView.as_view(),
name="email_list",
),
path(
"<str:code>/gerer/csv",
"commande/<str:code>/gerer/csv",
views.ExportGroupedOrderToCSVView.as_view(),
name="grouped_order_csv_export",
),
path("lieu/", views.PlaceIndexView.as_view(), name="place_index"),
path(
"lieu/<str:code>/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/<str:code>",
views.PlaceOverviewView.as_view(),
name="place_overview",
),
]

View file

@ -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

View file

@ -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"]

View file

@ -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,
},
)

View file

@ -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"))

View file

@ -86,6 +86,9 @@
<a class="navbar-item" href="{% url 'order:index' %}">
<i class="fa fa-shopping-basket mr-3" aria-hidden="true"></i>Mes commandes groupées
</a>
<a class="navbar-item" href="{% url 'order:place_index' %}">
<i class="fa fa-home mr-3" aria-hidden="true"></i>Mes lieux de distribution
</a>
{% endif %}
<a class="navbar-item" href="{% url 'about_chariotte' %}">
<i class="fa fa-info-circle mr-3" aria-hidden="true"></i>La Chariotte, c'est quoi&nbsp;?

View file

@ -5,6 +5,17 @@
{% block content %}
{% load static %}
{% if user.is_authenticated %}
<div class="buttons is-centered mt-3">
<a class="button is-primary" href="{% url 'order:index' %}">
<i class="fa fa-shopping-basket mr-3" aria-hidden="true"></i>Mes commandes groupées
</a>
<a class="button is-primary" href="{% url 'order:place_index' %}">
<i class="fa fa-home mr-3" aria-hidden="true"></i>Mes lieux de distribution
</a>
</div>
{% endif %}
<p class="desktop-hidden mobile-content-title">
{% block content_title %}Bienvenue{% if user.is_authenticated %}, {{ user.first_name }}{% else %} sur la Chariotte !{% endif %}{% endblock %}
</p>
@ -15,15 +26,15 @@
<p>Si vous avez un code à 6 caractères, entrez-le ici :</p>
<form method="post">
{% csrf_token %}
{% for error in form.code.errors %}
{% for error in form.order_code.errors %}
<p class="help is-danger">{{ error }}</p>
{% endfor %}
<div class="field has-addons">
<div class="control">
<input name="code" class="input {% if form.code.errors %}is-danger{% endif %}" type="text">
<input name="order_code" class="input {% if form.order_code.errors %}is-danger{% endif %}" type="text">
</div>
<div class="control">
<input class="button is-primary" type="submit" value="Rejoindre">
<input class="button is-primary" type="submit" value="Rejoindre une commande">
</div>
</div>
</form>
@ -31,23 +42,42 @@
</div>
<div class="column is-half">
<div class="box full-height">
{% if user.is_authenticated %}
<p class="subtitle">... Ou accédez à vos commandes groupées</p>
<a class="button is-primary" href="{% url 'order:index' %}">
<i class="fa fa-shopping-basket mr-3" aria-hidden="true"></i>Mes commandes groupées
</a>
{% else %}
<p class="subtitle">... Ou connectez-vous pour organiser une commande groupée</p>
<a class="button is-primary" href="{% url 'accounts:login' %}">
<i class="fa fa-sign-in mr-3" aria-hidden="true"></i>Se connecter
</a>
<a class="button is-light" href="{% url 'accounts:signup' %}">
<strong>Créer un compte</strong>
</a>
{% endif %}
<p class="subtitle">Rejoindre un lieu de distribution</p>
<p>Si vous avez un code de lieu, entrez-le ici :</p>
<form method="post">
{% csrf_token %}
{% for error in form.place_code.errors %}
<p class="help is-danger">{{ error }}</p>
{% endfor %}
<div class="field has-addons">
<div class="control">
<input name="place_code" class="input {% if form.place_code.errors %}is-danger{% endif %}" type="text">
</div>
<div class="control">
<input class="button is-primary" type="submit" value="Rejoindre un lieu">
</div>
</div>
</form>
</div>
</div>
</div>
{% if not user.is_authenticated %}
<div class="columns is-centered">
<div class="column is-centered is-half">
<div class="box">
<p class="subtitle">... Ou connectez-vous pour organiser une commande groupée</p>
<div class="buttons is-centered mt-3">
<a class="button is-primary" href="{% url 'accounts:login' %}">
<i class="fa fa-sign-in mr-3" aria-hidden="true"></i>Se connecter
</a>
<a class="button is-light" href="{% url 'accounts:signup' %}">
<strong>Créer un compte</strong>
</a>
</div>
</div>
</div>
</div>
{% endif %}
<div class="columns is-centered">
<div class="column is-10">
<div class="box">

View file

@ -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

View file

@ -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"],
]

View file

@ -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"]

View file

@ -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</a> (2 produits)' in body
assert '">secondorder</a> (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

View file

@ -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)

View file

@ -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