diff --git a/pyproject.toml b/pyproject.toml index a729fcb8..3aef89a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ sentry-sdk = "^1.14.0" [tool.black] line-length = 119 -target-version = ["py310"] +target-version = ["py311"] exclude = ''' /( migrations @@ -62,5 +62,5 @@ exclude = ''' ''' [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/website/borrel/urls.py b/website/borrel/urls.py index ca7d64ed..116e80dc 100644 --- a/website/borrel/urls.py +++ b/website/borrel/urls.py @@ -1,9 +1,6 @@ -from django.urls import path, register_converter +from django.urls import path from borrel import views -from venues.converters import VenueConverter - -register_converter(VenueConverter, "venue") urlpatterns = [ diff --git a/website/orders/api/v1/filters.py b/website/orders/api/v1/filters.py index 4d92cfd1..ca02cec6 100644 --- a/website/orders/api/v1/filters.py +++ b/website/orders/api/v1/filters.py @@ -54,6 +54,7 @@ class Meta: ), "ready": ("exact",), "paid": ("exact",), + "picked_up": ("exact",), "type": ("exact",), "product": ("exact",), } diff --git a/website/orders/api/v1/serializers.py b/website/orders/api/v1/serializers.py index e00b5354..c9e3f7fc 100644 --- a/website/orders/api/v1/serializers.py +++ b/website/orders/api/v1/serializers.py @@ -108,10 +108,22 @@ class Meta: "ready_at", "paid", "paid_at", + "picked_up", + "picked_up_at", "type", "priority", ] - read_only_fields = ["id", "created", "user", "product", "order_price", "ready_at", "paid_at"] + read_only_fields = [ + "id", + "created", + "user", + "product", + "order_price", + "ready_at", + "paid_at", + "picked_up_at", + "prioritize", + ] class ShiftSerializer(WritableModelSerializer): diff --git a/website/orders/api/v1/views.py b/website/orders/api/v1/views.py index 863220f2..5046aaea 100644 --- a/website/orders/api/v1/views.py +++ b/website/orders/api/v1/views.py @@ -42,10 +42,9 @@ class OrderListCreateAPIView(ListCreateAPIView): "GET": ["orders:order"], "POST": ["orders:manage"], } - filter_backends = [ - django_filters.rest_framework.DjangoFilterBackend, - ] + filter_backends = [django_filters.rest_framework.DjangoFilterBackend, filters.OrderingFilter] filterset_class = OrderFilter + ordering_fields = ["paid_at", "ready_at", "picked_up_at"] queryset = Order.objects.select_related("user", "product") def get_queryset(self): @@ -72,7 +71,7 @@ def perform_create(self, serializer): # Save the order while ignoring the order_type, user, paid and ready argument as the user does not have # permissions to save orders for all users in the shift. order = serializer.save( - shift=shift, type=Order.TYPE_ORDERED, user=self.request.user, paid=False, ready=False + shift=shift, type=Order.TYPE_ORDERED, user=self.request.user, paid=False, ready=False, picked_up=False ) log_action(self.request.user, order, CHANGE, "Created order via API.") @@ -107,6 +106,7 @@ class OrderRetrieveUpdateDestroyAPIView(LoggedRetrieveUpdateDestroyAPIView): "properties": { "ready": {"type": "boolean"}, "paid": {"type": "boolean"}, + "picked_up": {"type": "boolean"}, "priority": {"type": "number"}, }, } diff --git a/website/orders/migrations/0003_order_picked_up_order_picked_up_at.py b/website/orders/migrations/0003_order_picked_up_order_picked_up_at.py new file mode 100644 index 00000000..6a5eb9a5 --- /dev/null +++ b/website/orders/migrations/0003_order_picked_up_order_picked_up_at.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.4 on 2024-04-28 19:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("orders", "0002_initial"), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="picked_up", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="order", + name="picked_up_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/website/orders/models.py b/website/orders/models.py index 8bb84dd5..8f5f1831 100644 --- a/website/orders/models.py +++ b/website/orders/models.py @@ -438,7 +438,9 @@ def _clean(self): elif old_instance is not None and not old_instance.finalized and self.finalized: # Shift was not finalized yet but will be made finalized now if not self.shift_done: - raise ValidationError({"finalized": "Shift can't be finalized if not all Orders are paid and ready"}) + raise ValidationError( + {"finalized": "Shift can't be finalized if not all Orders are paid, ready and picked up."} + ) if self.end <= self.start: raise ValidationError({"end": "End date cannot be before start date."}) @@ -545,6 +547,9 @@ class Order(models.Model): paid = models.BooleanField(default=False) paid_at = models.DateTimeField(null=True, blank=True) + picked_up = models.BooleanField(default=False) + picked_up_at = models.DateTimeField(null=True, blank=True) + type = models.PositiveIntegerField(choices=TYPES, default=TYPE_ORDERED) priority = models.PositiveIntegerField(choices=PRIORITIES, default=PRIORITY_NORMAL) @@ -587,12 +592,22 @@ def venue(self): """ return self.shift.venue + @property + def completed(self) -> bool: + """ + Check if an Order is completed. + + :return: True if this Order is paid, ready and picked up, False otherwise. + :rtype: boolean + """ + return self.paid and self.ready and self.picked_up + @property def done(self): """ Check if an Order is done. - :return: True if this Order is paid and ready, False otherwise + :return: True if this Order is paid, ready and picked up, False otherwise :rtype: boolean """ return self.paid and self.ready diff --git a/website/orders/services.py b/website/orders/services.py index 37fdf0b5..acf21bfa 100644 --- a/website/orders/services.py +++ b/website/orders/services.py @@ -56,7 +56,7 @@ def execute_data_minimisation(dry_run=False): return users -def add_scanned_order(product: Product, shift: Shift, ready=True, paid=True) -> Order: +def add_scanned_order(product: Product, shift: Shift, ready=True, paid=True, picked_up=True) -> Order: """ Add a single Scanned Order (of type TYPE_SCANNED). @@ -64,6 +64,7 @@ def add_scanned_order(product: Product, shift: Shift, ready=True, paid=True) -> :param shift: The shift for which the Orders have to be created :param ready: Whether the Order should be directly made ready :param paid: Whether the Order should be directly made paid + :param picked_up: Whether the Order should be directly made picked up :return: The created Order """ # Check if Shift is not finalized @@ -79,7 +80,14 @@ def add_scanned_order(product: Product, shift: Shift, ready=True, paid=True) -> raise OrderException("This Product is not available in this Shift") return Order.objects.create( - product=product, shift=shift, type=Order.TYPE_SCANNED, user=None, user_association=None, ready=ready, paid=paid + product=product, + shift=shift, + type=Order.TYPE_SCANNED, + user=None, + user_association=None, + ready=ready, + paid=paid, + picked_up=picked_up, ) @@ -90,6 +98,7 @@ def add_user_order( priority: int = Order.PRIORITY_NORMAL, paid: bool = False, ready: bool = False, + picked_up: bool = False, **kwargs, ) -> Order: """ @@ -101,6 +110,7 @@ def add_user_order( :param priority: Which priority the Order should have :param paid: Whether the order should be set as paid :param ready: Whether the order should be set as ready + :param picked_up: Whether the order should be set as picked up :return: The created Order """ # Check order permissions @@ -146,6 +156,7 @@ def add_user_order( user_association=user.association, paid=paid, ready=ready, + picked_up=picked_up, priority=priority, ) diff --git a/website/orders/templates/orders/order_admin_list.html b/website/orders/templates/orders/order_admin_list.html index d70fb6d7..e007fda6 100644 --- a/website/orders/templates/orders/order_admin_list.html +++ b/website/orders/templates/orders/order_admin_list.html @@ -220,6 +220,20 @@

Orders total

}); return orders_ready; }, + orders_picked_up() { + return this.orders.filter(order => order.picked_up); + }, + orders_picked_up_grouped() { + let orders_picked_up = {}; + this.orders_picked_up.forEach(order => { + if (order.product.name in orders_picked_up) { + orders_picked_up[order.product.name].amount += 1; + } else { + orders_picked_up[order.product.name] = {"product": order.product, "amount": 1}; + } + }); + return orders_picked_up; + }, orders_grouped() { let orders_total = {}; this.orders.forEach(order => { @@ -232,10 +246,10 @@

Orders total

return orders_total; }, orders_finished() { - return this.orders.filter(order => order.ready && order.paid && order.type !== 1); + return this.orders.filter(order => order.ready && order.paid && order.picked_up && order.type !== 1); }, orders_to_process() { - return this.orders.filter(order => !order.ready || !order.paid && order.type !== 1); + return this.orders.filter(order => !order.ready || !order.picked_up || !order.paid && order.type !== 1); }, orders_scanned() { return this.orders.filter(order => order.type === 1); @@ -378,6 +392,32 @@

Orders total

} }).catch(error => show_error_from_api(error)); }, + toggle_picked_up(order) { + fetch( + `/api/v1/shifts/{{ shift.id }}/orders/${order.id}/`, + { + method: 'PATCH', + headers: { + "X-CSRFToken": get_csrf_token(), + "Accept": 'application/json', + "Content-Type": 'application/json', + }, + body: JSON.stringify({ + picked_up: !order.picked_up + }) + } + ).then(response => { + if (response.status === 200) { + return response; + } else { + throw response; + } + }).then(() => { + if (typeof (update_refresh_list) !== 'undefined') { + update_refresh_list(); + } + }).catch(error => show_error_from_api(error)); + }, delete_order(order) { if (window.confirm('Do you want to delete this order?')) { fetch( diff --git a/website/orders/templates/orders/order_admin_order.html b/website/orders/templates/orders/order_admin_order.html index 20d94fff..a7b4cf18 100644 --- a/website/orders/templates/orders/order_admin_order.html +++ b/website/orders/templates/orders/order_admin_order.html @@ -1,30 +1,37 @@ \ No newline at end of file diff --git a/website/status_screen/__init__.py b/website/status_screen/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/status_screen/apps.py b/website/status_screen/apps.py new file mode 100644 index 00000000..dad9fa38 --- /dev/null +++ b/website/status_screen/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class StatusScreenConfig(AppConfig): + """Status Screen App Config.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "status_screen" diff --git a/website/status_screen/migrations/__init__.py b/website/status_screen/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/status_screen/static/status_screen/css/status-screen.css b/website/status_screen/static/status_screen/css/status-screen.css new file mode 100644 index 00000000..f8816bbe --- /dev/null +++ b/website/status_screen/static/status_screen/css/status-screen.css @@ -0,0 +1,43 @@ +.site-header { + height: 84px; +} + +.user-order-list { + font-size: 30pt; + list-style-type: none; + padding-inline-start: unset; + padding: unset; + display: flex; + justify-content: center; + align-content: flex-start; + gap: 10px; + flex-wrap: wrap; +} + +.user-order-list li.user-order-item { + display: inline-flex; + background-color: var(--primary); + border-radius: 15px; + padding: 10px 15px; + justify-content: center; +} + +.user-order-list li .order-user-name { + margin-right: 20px; +} + +.order-list { + list-style-type: none; + padding-inline-start: unset; + padding: unset; +} + +.order-list li.order-item { + display: inline; +} + +@media screen and (max-width: 992px) { + .site-header { + height: 59px; + } +} \ No newline at end of file diff --git a/website/status_screen/templates/status_screen/status_screen.html b/website/status_screen/templates/status_screen/status_screen.html new file mode 100644 index 00000000..d65879a5 --- /dev/null +++ b/website/status_screen/templates/status_screen/status_screen.html @@ -0,0 +1,185 @@ +{% extends 'tosti/base.html' %} +{% load static %} + +{% block styles %} + +{% endblock %} + +{% block header %} + +{% endblock %} + +{% block page %} +
+
+
+

In progress

+ +
  • +
    ${user_orders_object.user.first_name}$
    +
      +
    • + + +
    • +
    +
  • +
    +
    +
    +

    Ready

    + +
  • +
    ${user_orders_object.user.first_name}$
    +
      +
    • + + +
    • +
    +
  • +
    +
    +
    +
    + +{% endblock %} + +{% block footer %} + +{% endblock %} + +{% block js %} + +{% endblock %} \ No newline at end of file diff --git a/website/status_screen/urls.py b/website/status_screen/urls.py new file mode 100644 index 00000000..cc8d1313 --- /dev/null +++ b/website/status_screen/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from status_screen import views + +urlpatterns = [ + path("redirect//", views.VenueRedirectView.as_view(), name="venue-redirect"), + path("/", views.StatusScreen.as_view(), name="status"), +] diff --git a/website/status_screen/views.py b/website/status_screen/views.py new file mode 100644 index 00000000..7196f1d6 --- /dev/null +++ b/website/status_screen/views.py @@ -0,0 +1,31 @@ +from django.http import Http404 +from django.shortcuts import render, redirect +from django.urls import reverse +from django.views import View +from django.views.generic import TemplateView + +from orders.templatetags.start_shift import currently_active_shift_for_venue + + +class StatusScreen(TemplateView): + """Status screen for a Shift.""" + + template_name = "status_screen/status_screen.html" + + def get(self, request, **kwargs): + """GET request for status screen view.""" + shift = kwargs.get("shift") + return render(request, self.template_name, {"shift": shift}) + + +class VenueRedirectView(View): + """Redirect to the current shift of a venue.""" + + def get(self, request, **kwargs): + """Redirect a user to the active status screen of a shift.""" + venue = kwargs.get("venue") + shift = currently_active_shift_for_venue(venue) + if shift is None: + raise Http404() + else: + return redirect(reverse("status_screen:status", kwargs={"shift": shift})) diff --git a/website/tosti/settings/base.py b/website/tosti/settings/base.py index 547dc47e..46b28b3d 100644 --- a/website/tosti/settings/base.py +++ b/website/tosti/settings/base.py @@ -37,6 +37,7 @@ "transactions", "orders", "silvasoft", + "status_screen", "oauth2_provider", "corsheaders", "yivi", @@ -195,7 +196,8 @@ ), "VENUES_SEND_RESERVATION_REQUEST_EMAILS_TO": ( "noreply@example.com, noreply@example.com", - "Where to send venue reservation request notifications to (e-mail address), enter multiple addresses by using a comma (,)", + "Where to send venue reservation request notifications to (e-mail address), enter multiple addresses by using " + "a comma (,)", str, ), "SHIFTS_DEFAULT_MAX_ORDERS_TOTAL": (70, "Default maximum number of orders per shift", int), @@ -282,4 +284,4 @@ AGE_VERIFICATION_INSTITUTE_VALUE = "ru.nl" YIVI_SERVER_URL = os.environ.get("YIVI_SERVER_URL") -YIVI_SERVER_TOKEN = os.environ.get("YIVI_SERVER_TOKEN") \ No newline at end of file +YIVI_SERVER_TOKEN = os.environ.get("YIVI_SERVER_TOKEN") diff --git a/website/tosti/templates/tosti/base.html b/website/tosti/templates/tosti/base.html index 6f4f1749..c2959625 100644 --- a/website/tosti/templates/tosti/base.html +++ b/website/tosti/templates/tosti/base.html @@ -185,6 +185,77 @@

    Identify yourself


    + {% endblock %}
    {% block page %}{% endblock %} @@ -204,76 +275,5 @@

    Identify yourself


    {% block js %}{% endblock %} - diff --git a/website/tosti/urls.py b/website/tosti/urls.py index 5514de71..24c18fe7 100644 --- a/website/tosti/urls.py +++ b/website/tosti/urls.py @@ -50,6 +50,10 @@ "fridges/", include(("fridges.urls", "fridges"), namespace="fridges"), ), + path( + "status/", + include(("status_screen.urls", "status_screen"), namespace="status_screen"), + ), path("api/", include("tosti.api.urls")), path("saml/", include("djangosaml2.urls")), path(