Compare commits

...

2 commits

Author SHA1 Message Date
selfhoster1312 ACAB
8d7ba5e616 Merge branch 'feat-distribution-place' into 'develop'
Draft: feature: Add placekey relationship for distribution spots

See merge request la-chariotte/la-chariotte!129
2025-03-02 19:15:45 +00:00
selfhoster1312
f2d8d9b9c2 feature: Add placekey relationship for distribution spots 2025-03-02 15:23:27 +01:00
24 changed files with 499 additions and 58 deletions

View file

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class LieuConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "la_chariotte.lieu"

View file

@ -0,0 +1,35 @@
import datetime
from django import forms
from django.contrib.auth import get_user_model
from django.forms.utils import to_current_timezone
from django.utils import timezone
from la_chariotte.lieu.models import Lieu
class LieuForm(forms.ModelForm):
class Meta:
model = Lieu
fields = [
"name",
"description",
"url",
]
widgets = {
"name": forms.TextInput(
attrs={"placeholder": "ex : Centre social Kropotkine"}
),
"description": forms.Textarea(
attrs={"placeholder": "Plus d'infos sur le lieu ? (facultatif)"}
),
"url": forms.TextInput(attrs={"placeholder": "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,28 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Lieu",
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")),
("url", models.CharField(max_length=20, verbose_name='Portion du lien pour le lieu', 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")),
],
),
]

View file

View file

@ -0,0 +1,27 @@
import random
import base36
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
from la_chariotte.order.models import GroupedOrder
from la_chariotte.settings import AUTH_USER_MODEL
class Lieu(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"
)
url = models.CharField(
max_length=20, verbose_name="Portion du lien pour le lieu", unique=True
)
description = models.TextField("Description", null=True, blank=True)
def __str__(self): # pragma: no cover
return self.name
def get_absolute_url(self):
return reverse("lieu:lieu_update", kwargs={"url": self.url})

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 'lieu:create_lieu' %}">
<i class="fa fa-plus-circle mr-3" aria-hidden="true"></i>
Créer un nouveau lieu de distribution</a>
</div>
{% if lieu_context.lieu_liste %}
<table class="table">
<thead>
<tr>
<th>Lieu</th>
<th>Commandes</th>
<th>Description</th>
<th>URL</th>
<th> </th>
</tr>
</thead>
<tbody>
{% for lieu in lieu_context.lieu_liste %}
<tr>
<td>
<a title="Détail du lieu de distribution" href="{% url 'lieu:lieu_update' lieu.url %}">{{ lieu }}</a>
</td>
<td>
{% if lieu.url in lieu_context.commandes.keys %}
{% for lieu_url, lieu_commandes in lieu_context.commandes.items %}
{% if lieu_url == lieu.url %}
{% for commande in lieu_commandes %}
{% url 'order:grouped_order_detail' code=commande.code as order_url %}
{% if order_url %}
<a href="{{ order_url }}">{{commande.name}}</a><br>
{% else %}
{{ commande.name }}<br>
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% else %}
Aucune
{% endif %}
</td>
<td>
{{ lieu.description }}
</td>
<td>
{{ lieu.url }}
</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">Nouvelle 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 'lieu:index' %}">Annuler</a>
<input class="button is-primary" type="submit" value="Suivant">
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}{{ lieu }}{% 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">{{ lieu.name }} - modifier</p>
<form method="post">{% csrf_token %}
{{ form | crispy }}
<div class="buttons">
<a class="button is-light" href="{% url 'lieu:index' %}">Annuler</a>
<input class="button is-primary" type="submit" value="Suivant">
</div>
</form>
</div>
{% 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">{{ lieu.name }} - modifier</p>
<form method="post">{% csrf_token %}
{{ form | crispy }}
<div class="buttons">
<a class="button is-light" href="{% url 'lieu:index' %}">Annuler</a>
<input class="button is-primary" type="submit" value="Suivant">
</div>
</form>
</div>
{% endblock %}

14
la_chariotte/lieu/urls.py Normal file
View file

@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = "lieu"
urlpatterns = [
path("", views.IndexView.as_view(), name="index"),
path(
"<str:url>/",
views.LieuUpdateView.as_view(),
name="lieu_update",
),
path("creer", views.LieuCreateView.as_view(), name="create_lieu"),
]

View file

@ -0,0 +1,6 @@
from .lieu import (
IndexView,
LieuCreateView,
LieuDetailView,
LieuUpdateView,
)

View file

@ -0,0 +1,87 @@
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 LieuForm
from ..models import Lieu
class IndexView(LoginRequiredMixin, generic.ListView):
"""View showing all the grouped orders managed by the authenticated user"""
template_name = "lieu/index.html"
context_object_name = "lieu_context"
def get_queryset(self):
lieux = Lieu.objects.filter(orga=self.request.user)
commandes_par_lieu = dict()
for lieu in lieux:
orders = GroupedOrder.objects.all().filter(placekey=lieu)
if orders:
commandes_par_lieu[lieu.url] = orders
return {
"lieu_liste": lieux,
"commandes": commandes_par_lieu,
}
class LieuDetailView(generic.DetailView):
model = Lieu
template_name = "lieu/lieu_detail.html"
context_object_name = "lieu_context"
form_class = LieuForm
def get_object(self, queryset=None):
return get_object_or_404(Lieu, url=self.kwargs.get("url"))
def get_context_data(self, **kwargs):
lieu = self.get_object()
context = super().get_context_data(**kwargs)
return context
class LieuUpdateView(UserIsOrgaMixin, generic.UpdateView):
model = Lieu
template_name = "lieu/lieu_update.html"
context_object_name = "lieu"
form_class = LieuForm
# Can't change URL after creation
form_class.base_fields["url"].disabled = True
def get_object(self, queryset=None):
return get_object_or_404(Lieu, url=self.kwargs.get("url"))
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
return kwargs
class LieuCreateView(LoginRequiredMixin, generic.CreateView):
model = Lieu
form_class = LieuForm
template_name = "lieu/lieu_create.html"
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)

View file

@ -1,10 +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.lieu.models import Lieu
from la_chariotte.order.models import GroupedOrder, Item
@ -22,6 +24,12 @@ class GroupedOrderForm(forms.ModelForm):
label="Numéro de téléphone obligatoire pour les participants",
required=False,
)
placekey = forms.ModelMultipleChoiceField(
label="Lieux de distribution",
queryset=Lieu.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
)
class Meta:
model = GroupedOrder
@ -34,6 +42,7 @@ class GroupedOrderForm(forms.ModelForm):
"place",
"description",
"is_phone_mandatory",
"placekey",
]
widgets = {
"name": forms.TextInput(

View file

@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("order", "0029_set_phone_mandatory_for_existing_orders"),
("lieu", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="groupedorder",
name="placekey",
field=models.ManyToManyField(
verbose_name="Lieu de distribution",
to="lieu.lieu",
),
),
migrations.AddField(
model_name="order",
name="placekey",
field=models.ForeignKey(blank=True, null=True, on_delete=models.deletion.CASCADE, to='lieu.lieu'),
),
]

View file

@ -21,9 +21,17 @@ class GroupedOrder(models.Model):
max_length=50, null=True, blank=True, verbose_name="Créneau de distribution"
)
deadline = models.DateTimeField("Date limite de commande")
# Try to associate with a saved distribution place in DB
placekey = models.ManyToManyField(
"lieu.Lieu",
verbose_name="Lieu de distribution",
)
# Alternatively, propose a free-text distribution place field
place = models.CharField(
max_length=100, null=True, blank=True, verbose_name="Lieu de livraison"
)
description = models.TextField("Description", null=True, blank=True)
code = models.CharField(auto_created=True)
is_phone_mandatory = models.BooleanField(
@ -123,6 +131,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)
placekey = models.ForeignKey(
"lieu.lieu", on_delete=models.CASCADE, null=True, blank=True
)
@property
def articles_nb(self):

View file

@ -171,6 +171,17 @@
<p><label for="note">Note à l'organisateur·ice<em> (facultatif)</em> :</label>
<textarea id="note" rows=3 name="note">{{ note }}</textarea></p>
{% if placekey %}
<label for="placekey">Point de distribution:</label>
<select name="placekey" id="placekey-select">
{% for place in placekey %}
<option value="{{ place.url }}">{{ 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.placekey %}<p class="has-text-grey-dark">Lieu: {{ order.placekey }}</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 by_lieu %}
{% for lieu_name, ordered_dict in lieux.items %}
<h3>{{ lieu_name }}</h3>
{% include 'order/grouped_order_sheet_list.html' with orders_dict=lieu_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

@ -11,6 +11,8 @@ from django.views import generic
from django_weasyprint import WeasyTemplateResponseMixin
from icalendar import Calendar, Event, vCalAddress, vText
from la_chariotte.lieu.models import Lieu
from ..forms import GroupedOrderForm, Item, JoinGroupedOrderForm
from ..models import GroupedOrder, OrderAuthor
from .mixins import UserIsOrgaMixin
@ -136,6 +138,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,
"placekey": grouped_order.placekey.all(),
}
)
return context
@ -198,6 +201,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"] = Lieu.objects.filter(orga=self.request.user)
return context
class GroupedOrderDuplicateView(UserIsOrgaMixin, generic.RedirectView):
def get_object(self, queryset=None):
@ -296,6 +304,8 @@ class GroupedOrderExportView(UserIsOrgaMixin, generic.DetailView):
context = super(GroupedOrderExportView, self).get_context_data(**kwargs)
grouped_order = self.get_object()
by_lieu = len(grouped_order.placekey.all()) > 0
items = [
item
for item in grouped_order.item_set.all().order_by("name")
@ -316,6 +326,14 @@ class GroupedOrderExportView(UserIsOrgaMixin, generic.DetailView):
context["items"] = items
context["orders_dict"] = orders_dict
context["by_lieu"] = by_lieu
if by_lieu:
lieux = dict()
for order, order_items in orders_dict.items():
if order.placekey.name not in lieux:
lieux[order.placekey.name] = dict()
lieux[order.placekey.name][order] = order_items
context["lieux"] = lieux
return context
@ -355,6 +373,8 @@ class ExportGroupOrderEmailAdressesToDownloadView(UserPassesTestMixin, generic.V
class ExportGroupedOrderToCSVView(GroupedOrderExportView):
def get(self, request, *args, **kwargs):
grouped_order = self.get_object()
enableLieu = len(grouped_order.placekey.all()) > 0
super(ExportGroupedOrderToCSVView, self).get(self, request, *args, **kwargs)
context = self.get_context_data()
@ -376,6 +396,8 @@ class ExportGroupedOrderToCSVView(GroupedOrderExportView):
row.append("Note")
row.append("Date")
row.append("Heure")
if enableLieu:
row.append("Lieu")
writer.writerow(row)
row = ["", "Prix unitaire TTC (€)"]
@ -398,6 +420,8 @@ class ExportGroupedOrderToCSVView(GroupedOrderExportView):
row.append(order.note)
row.append(order.created_date.strftime("%d/%m/%Y"))
row.append(order.created_date.strftime("%H:%M"))
if enableLieu:
row.append(order.placekey.name)
writer.writerow(row)
# write total row

View file

@ -5,6 +5,7 @@ from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.views import generic
from la_chariotte.lieu.models import Lieu
from la_chariotte.mail.utils import send_order_confirmation_mail
from ..models import GroupedOrder, Order, OrderAuthor, OrderedItem
@ -13,6 +14,7 @@ from ..models import GroupedOrder, Order, OrderAuthor, OrderedItem
def place_order(request, code):
# Creates an AnonymousUser and an Order with related OrderedItems
grouped_order = get_object_or_404(GroupedOrder, code=code)
places = grouped_order.placekey.all()
# Handle permissions
user_is_orga = request.user == grouped_order.orga
@ -46,6 +48,19 @@ def place_order(request, code):
phone = request.POST["phone"]
email = request.POST["email"]
note = request.POST["note"]
# Make sure requested location is valid in this group order (and exists at all)
# If no placekey is enabled for this group order, placekey is always null
if len(places) == 0:
placekey = None
else:
placekey = get_object_or_404(Lieu, url=request.POST["placekey"])
if placekey not in places:
raise http.Http404(
"Le lieu demandé n'est pas valide pour cette commande: %s"
% requested_placekey
)
author = OrderAuthor.objects.create(
first_name=first_name, last_name=last_name, email=email, phone=phone
)
@ -54,6 +69,7 @@ def place_order(request, code):
grouped_order=grouped_order,
note=note,
created_date=timezone.now(),
placekey=placekey,
)
# add items to the order
@ -80,6 +96,7 @@ def place_order(request, code):
"error_message": error_message,
"note": order.note,
"author": author,
"placekey": placekey,
},
)
@ -98,6 +115,7 @@ def place_order(request, code):
"error_message": error_message,
"note": order.note,
"author": author,
"placekey": placekey,
},
)

View file

@ -27,6 +27,7 @@ if os.getenv("ALLOWED_HOSTS"):
# Applications & middlewares
INSTALLED_APPS = [
"la_chariotte.lieu",
"la_chariotte.order",
"la_chariotte.accounts",
"la_chariotte.mail",

View file

@ -30,6 +30,7 @@ from la_chariotte.order.views.stats import stats
urlpatterns = [
path("admin/", admin.site.urls),
path("commande/", include("la_chariotte.order.urls")),
path("lieu/", include("la_chariotte.lieu.urls")),
path("comptes/", include("la_chariotte.accounts.urls")),
# Some paths for accounts are easier to leave here
# - PasswordResetView sends the mail