From c1eefc639b0f7aaacc14ffebd0ea7c0995037f01 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Thu, 19 Sep 2024 10:56:20 +0200 Subject: [PATCH] add PosProduct(Cost), PosTransaction, PosSale models, import code, and backoffice views to interact with point-of-sale sales data --- src/backoffice/forms.py | 6 + src/backoffice/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/import_pos_sales_json.py | 29 ++ src/backoffice/mixins.py | 1 + .../templates/includes/pos_list_table.html | 8 +- .../templates/includes/table_pagination.html | 10 + src/backoffice/templates/index.html | 18 +- src/backoffice/templates/pos_detail.html | 25 +- src/backoffice/templates/pos_list.html | 11 +- .../templates/pos_product_cost_list.html | 44 +++ .../templates/pos_product_list.html | 43 +++ src/backoffice/templates/pos_sale_list.html | 44 +++ .../templates/pos_sales_json_upload_form.html | 19 + .../templates/pos_transaction_list.html | 46 +++ src/backoffice/templates/posproduct_form.html | 21 + .../templates/posproductcost_form.html | 21 + src/backoffice/templates/posreport_list.html | 30 +- src/backoffice/urls.py | 62 +++ src/backoffice/views/orga.py | 2 + src/backoffice/views/pos.py | 227 +++++++++++ src/bornhack/settings.py | 15 + src/camps/middleware.py | 23 ++ src/camps/mixins.py | 4 - src/camps/utils.py | 43 +++ src/economy/admin.py | 37 ++ src/economy/filters.py | 228 +++++++++++ ...pos_external_id_alter_pos_team_and_more.py | 285 ++++++++++++++ src/economy/models.py | 179 +++++++++ src/economy/tables.py | 363 ++++++++++++++++++ src/economy/utils.py | 157 +++++++- src/phonebook/views.py | 1 - src/requirements/production.txt | 1 + src/shop/models.py | 7 + .../management/commands/bootstrap_devsite.py | 5 +- src/utils/querystring.py | 11 + src/utils/templatetags/bornhack.py | 2 - src/utils/templatetags/querystring.py | 48 +++ 38 files changed, 2035 insertions(+), 41 deletions(-) create mode 100644 src/backoffice/management/__init__.py create mode 100644 src/backoffice/management/commands/__init__.py create mode 100644 src/backoffice/management/commands/import_pos_sales_json.py create mode 100644 src/backoffice/templates/includes/table_pagination.html create mode 100644 src/backoffice/templates/pos_product_cost_list.html create mode 100644 src/backoffice/templates/pos_product_list.html create mode 100644 src/backoffice/templates/pos_sale_list.html create mode 100644 src/backoffice/templates/pos_sales_json_upload_form.html create mode 100644 src/backoffice/templates/pos_transaction_list.html create mode 100644 src/backoffice/templates/posproduct_form.html create mode 100644 src/backoffice/templates/posproductcost_form.html create mode 100644 src/camps/middleware.py create mode 100644 src/economy/filters.py create mode 100644 src/economy/migrations/0039_posproduct_pos_external_id_alter_pos_team_and_more.py create mode 100644 src/economy/tables.py create mode 100644 src/utils/querystring.py create mode 100644 src/utils/templatetags/querystring.py diff --git a/src/backoffice/forms.py b/src/backoffice/forms.py index 7cb4398e8..95ac1376e 100644 --- a/src/backoffice/forms.py +++ b/src/backoffice/forms.py @@ -220,3 +220,9 @@ class Meta: form=TicketGroupRefundForm, extra=0, ) + + +class PosSalesJSONForm(forms.Form): + sales = forms.FileField( + help_text="POS sales.json file. Previously imported sales will be skipped and will not create duplicates.", + ) diff --git a/src/backoffice/management/__init__.py b/src/backoffice/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backoffice/management/commands/__init__.py b/src/backoffice/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backoffice/management/commands/import_pos_sales_json.py b/src/backoffice/management/commands/import_pos_sales_json.py new file mode 100644 index 000000000..a4f4c8652 --- /dev/null +++ b/src/backoffice/management/commands/import_pos_sales_json.py @@ -0,0 +1,29 @@ +import json +import logging + +from django.core.management.base import BaseCommand + +from economy.utils import import_pos_sales_json + +logger = logging.getLogger("bornhack.%s" % __name__) + + +class Command(BaseCommand): + args = "none" + help = "Import Pos sales JSON" + + def add_arguments(self, parser): + parser.add_argument( + "jsonpath", + type=str, + help="The path to the Pos sales json file to import. The import is idempotent, no duplicates will be created.", + ) + + def handle(self, *args, **options): + with open(options["jsonpath"]) as f: + data = json.load(f) + products, transactions, sales, costs = import_pos_sales_json(data) + self.stdout.write(f"{products} new products created") + self.stdout.write(f"{transactions} new transactions created") + self.stdout.write(f"{sales} new sales created") + self.stdout.write(f"{costs} new product_costs created") diff --git a/src/backoffice/mixins.py b/src/backoffice/mixins.py index 06e396138..b9bb33eaf 100644 --- a/src/backoffice/mixins.py +++ b/src/backoffice/mixins.py @@ -4,6 +4,7 @@ from camps.mixins import CampViewMixin from economy.models import Pos +from economy.models import PosSale from utils.mixins import RaisePermissionRequiredMixin diff --git a/src/backoffice/templates/includes/pos_list_table.html b/src/backoffice/templates/includes/pos_list_table.html index 29e19dc73..5f9b4ddb8 100644 --- a/src/backoffice/templates/includes/pos_list_table.html +++ b/src/backoffice/templates/includes/pos_list_table.html @@ -3,7 +3,9 @@ Name Team - Slug + Pos Reports + Total Transactions + Total Sales Actions @@ -12,7 +14,9 @@ {{ pos.name }} {{ pos.team }} - {{ pos.slug }} + {{ pos.pos_reports.count }} reports + {{ pos.pos_transactions.count }} transactions + {{ pos.sales.count }} sales for {{ pos.total_sales|default:0 }} HAX
Details diff --git a/src/backoffice/templates/includes/table_pagination.html b/src/backoffice/templates/includes/table_pagination.html new file mode 100644 index 000000000..2fb2e8c82 --- /dev/null +++ b/src/backoffice/templates/includes/table_pagination.html @@ -0,0 +1,10 @@ +{% load querystring %} +
+ Per page: +
+ 25 + 100 + 250 + 1000 +
+
diff --git a/src/backoffice/templates/index.html b/src/backoffice/templates/index.html index ae8ee5ae8..7ff81371b 100644 --- a/src/backoffice/templates/index.html +++ b/src/backoffice/templates/index.html @@ -254,9 +254,25 @@

Accounting Exports

{% if perms.camps.orgateam_permission or perms.camps.infoteam_permission or perms.camps.barteam_permission %}

Point of Sale

-

Point of Sale

+

Point of Sale Locations

Use this view to see a list of Pos objects, and to see and submit PosReports

+ +

Point of Sale Products

+

Use this view to see a list of Pos products.

+
+ +

Point of Sale TransactionsProducts

+

Use this view to see a list of Pos transactions.

+
+ +

Point of Sale Sales

+

Use this view to see a list of Pos sales.

+
+ +

Point of Sale Product Costs

+

Use this view to see and update Pos Product Cost objects. They are used when calculating the profits for Pos sales.

+
{% endif %} {% if perms.camps.gameteam_permission %} diff --git a/src/backoffice/templates/pos_detail.html b/src/backoffice/templates/pos_detail.html index 3345d2a5c..eb69f316d 100644 --- a/src/backoffice/templates/pos_detail.html +++ b/src/backoffice/templates/pos_detail.html @@ -18,24 +18,35 @@

{{ pos.name }} | Pos | BackOffice

- + + + + + + + + + + + + +
Pos NameName {{ pos.name }}
Team {{ pos.team }}

Pos Reports {{ pos.pos_reports.count }} reports

+
Transactions {{ pos.pos_transactions.count }} transactions

+
Sales {{ pos.sales.count }} products sold

+
Total Sales{{ pos.total_sales|default:0 }} HAX

+
-

Pos Reports

- {% if pos.pos_reports.exists %} - {% include "includes/posreport_list_table.html" with posreport_list=pos.pos_reports.all %} - {% else %} - None found - {% endif %}
{% if perms.camps.orgateam_permission %} Create PosReport + Import Pos Sales JSON {% endif %}
diff --git a/src/backoffice/templates/pos_list.html b/src/backoffice/templates/pos_list.html index 70a2d08d6..e86938c89 100644 --- a/src/backoffice/templates/pos_list.html +++ b/src/backoffice/templates/pos_list.html @@ -9,14 +9,19 @@

Pos List - BackOffice

-

A Pos is a place where we sell stuff for DKK and/or HAX.

{% if not pos_list %}

No Pos found.

{% else %}

- Backoffice - {% include "includes/pos_list_table.html" %} + Backoffice + Pos List + Pos Product List + Pos Product Cost List + Pos Transaction List + Pos Sales List

+

A Pos is a place where we sell stuff for DKK and/or HAX.

+ {% include "includes/pos_list_table.html" %} {% endif %}

{% if perms.camps.orgateam_permission %} diff --git a/src/backoffice/templates/pos_product_cost_list.html b/src/backoffice/templates/pos_product_cost_list.html new file mode 100644 index 000000000..00796adb4 --- /dev/null +++ b/src/backoffice/templates/pos_product_cost_list.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% load render_table from django_tables2 %} +{% load bootstrap3 %} +{% load django_tables2 %} + +{% block title %} + Pos Product Costs | BackOffice | {{ block.super }} +{% endblock %} + +{% block content %} +

+
+

Pos Product Costs | BackOffice

+
+
+

+ Backoffice + Pos List + Pos Product List + Pos Product Cost List + Pos Transaction List + Pos Sales List +

+

A Pos Product Cost shows the cost of selling one of a product. Pos Product Costs are applied to all sales after the timestamp, until a newer Pos Product Cost is created.

+
+
Filter Pos Product Costs
+
+ {% if filter %} +
+ {% bootstrap_form filter.form layout='inline' %} +
+ + Clear +
+ {% endif %} +
+
+
Showing {{ object_list.count }} costs out of {{ total_costs }} costs for {{ camp.title }}
+ {% include "includes/table_pagination.html" %} + {% render_table table %} + {% include "includes/table_pagination.html" %} +
+
+{% endblock %} diff --git a/src/backoffice/templates/pos_product_list.html b/src/backoffice/templates/pos_product_list.html new file mode 100644 index 000000000..856baa375 --- /dev/null +++ b/src/backoffice/templates/pos_product_list.html @@ -0,0 +1,43 @@ +{% extends 'base.html' %} +{% load render_table from django_tables2 %} +{% load bootstrap3 %} + +{% block title %} + Pos Products | BackOffice | {{ block.super }} +{% endblock %} + +{% block content %} +
+
+

Pos Products | BackOffice

+
+
+

+ Backoffice + Pos List + Pos Product List + Pos Product Cost List + Pos Transaction List + Pos Sales List +

+

A Pos Product is something sold at a Pos. Price, name and other product details might change over time. The sales numbers shown in this table only include transactions related to {{ camp.title }}.

+
+
Filter Pos Products
+
+ {% if filter %} +
+ {% bootstrap_form filter.form layout='inline' %} +
+ + Clear +
+ {% endif %} +
+
+
Showing {{ object_list|length }} products ({{ filtered_sales_count }} sales for {{ filtered_sales_sum }} HAX) out of total {{ total_products }} products ({{ total_sales_count }} sales for {{ total_sales_sum }} HAX) for {{ camp.title }}
+ {% include "includes/table_pagination.html" %} + {% render_table table %} + {% include "includes/table_pagination.html" %} +
+
+{% endblock %} diff --git a/src/backoffice/templates/pos_sale_list.html b/src/backoffice/templates/pos_sale_list.html new file mode 100644 index 000000000..d4b04f190 --- /dev/null +++ b/src/backoffice/templates/pos_sale_list.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% load render_table from django_tables2 %} +{% load bootstrap3 %} +{% load django_tables2 %} + +{% block title %} + {{ pos.name }} | Pos Sales | BackOffice | {{ block.super }} +{% endblock %} + +{% block content %} +
+
+

Pos Sales | BackOffice

+
+
+

+ Backoffice + Pos List + Pos Product List + Pos Product Cost List + Pos Transaction List + Pos Sales List +

+

A Pos Sale is created every time an item is sold and paid with HAX at a Pos. Buying two Mate creates one Pos Transaction with two Pos Sales.

+
+
Filter Pos Sales
+
+ {% if filter %} +
+ {% bootstrap_form filter.form layout='inline' %} +
+ + Clear +
+ {% endif %} +
+
+
Showing {{ possale_list.count }} sales ({{ filtered_sales_sum }} HAX) out of {{ total_sales_count }} sales ({{total_sales_sum}} HAX) for {{ camp.title }}
+ {% include "includes/table_pagination.html" %} + {% render_table table %} + {% include "includes/table_pagination.html" %} +
+
+{% endblock %} diff --git a/src/backoffice/templates/pos_sales_json_upload_form.html b/src/backoffice/templates/pos_sales_json_upload_form.html new file mode 100644 index 000000000..6e879a3ad --- /dev/null +++ b/src/backoffice/templates/pos_sales_json_upload_form.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +
+
+

Pos Sales JSON Upload

+
+
+

Pos Sales JSON Upload

+
+ {% csrf_token %} + {% bootstrap_form form %} + + Cancel +
+
+
+{% endblock content %} diff --git a/src/backoffice/templates/pos_transaction_list.html b/src/backoffice/templates/pos_transaction_list.html new file mode 100644 index 000000000..cfe0c8970 --- /dev/null +++ b/src/backoffice/templates/pos_transaction_list.html @@ -0,0 +1,46 @@ +{% extends 'base.html' %} +{% load render_table from django_tables2 %} +{% load bootstrap3 %} +{% load django_tables2 %} + +{% block title %} + Pos Transactions | BackOffice | {{ block.super }} +{% endblock %} + +{% block content %} +
+
+

Pos Transactions | BackOffice

+
+
+

+ Backoffice + Pos List + Pos Product List + Pos Product Cost List + Pos Transaction List + Pos Sales List +

+

A Pos Transaction is created every time one or more items are sold and paid with HAX. Buying two Mate creates one Pos Transaction with two Pos Sales.

+
+
Filter Pos Transactions
+
+ {% if filter %} +
+ {% bootstrap_form filter.form layout='inline' %} +
+ + + Clear + +
+ {% endif %} +
+
+
Filter showing {{ object_list|length }} transactions ({{ filtered_sales_count }} sales for {{ filtered_sales_sum }} HAX) of {{ total_transactions }} transactions ({{ total_sales_count }} sales for {{ total_sales_sum }} HAX)
+ {% include "includes/table_pagination.html" %} + {% render_table table %} + {% include "includes/table_pagination.html" %} +
+
+{% endblock %} diff --git a/src/backoffice/templates/posproduct_form.html b/src/backoffice/templates/posproduct_form.html new file mode 100644 index 000000000..5b5c87184 --- /dev/null +++ b/src/backoffice/templates/posproduct_form.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} +{% load static %} + +{% block content %} +
+
+

{% if request.resolver_match.url_name == "posproduct_update" %}Update{% else %}Create new{% endif %} Pos Product

+
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + Cancel + {% endbuttons %} +
+
+
+{% endblock content %} diff --git a/src/backoffice/templates/posproductcost_form.html b/src/backoffice/templates/posproductcost_form.html new file mode 100644 index 000000000..5ce9957cd --- /dev/null +++ b/src/backoffice/templates/posproductcost_form.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} +{% load static %} + +{% block content %} +
+
+

{% if request.resolver_match.url_name == "posrestock_update" %}Update{% else %}Create new{% endif %} Pos Restock

+
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + Cancel + {% endbuttons %} +
+
+
+{% endblock content %} diff --git a/src/backoffice/templates/posreport_list.html b/src/backoffice/templates/posreport_list.html index 9b7811175..b7c9d30c0 100644 --- a/src/backoffice/templates/posreport_list.html +++ b/src/backoffice/templates/posreport_list.html @@ -1,25 +1,29 @@ {% extends 'base.html' %} -{% load bornhack %} {% block title %} - PosReport List for {{ pos.name }} | Backoffice | {{ block.super }} + {{ pos.name }} | Pos Reports | BackOffice | {{ block.super }} {% endblock %} {% block content %}
-

PosReport List for {{ pos.name }} - BackOffice

+
+

{{ pos.name }} | Pos Reports | BackOffice

+
-

A PosReport contains the start and end counts of HAX+DKK from a point-of-sale and the exported JSON file from the Pos.

- {% if not pos_list %} -

No Pos found.

- {% else %} -

- {% include "includes/posreport_list_table.html" %} -

- {% endif %}

- Backoffice + Pos Details

+ +

Pos Reports

+ {% if pos.pos_reports.exists %} + {% include "includes/posreport_list_table.html" with posreport_list=pos.pos_reports.all %} + {% else %} + None found + {% endif %} +
+ {% if perms.camps.orgateam_permission %} + Create PosReport + {% endif %}
-{% endblock content %} +{% endblock %} diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index 6c9bf836e..75930390a 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -100,13 +100,21 @@ from .views import PosDeleteView from .views import PosDetailView from .views import PosListView +from .views import PosProductCostListView +from .views import PosProductCostUpdateView +from .views import PosProductListView +from .views import PosProductUpdateView from .views import PosReportBankCountEndView from .views import PosReportBankCountStartView from .views import PosReportCreateView from .views import PosReportDetailView +from .views import PosReportListView from .views import PosReportPosCountEndView from .views import PosReportPosCountStartView from .views import PosReportUpdateView +from .views import PosSaleListView +from .views import PosSalesImportView +from .views import PosTransactionListView from .views import PosUpdateView from .views import RefundDetailView from .views import RefundListView @@ -1071,6 +1079,55 @@ PosCreateView.as_view(), name="pos_create", ), + path( + "products/", + include( + [ + path( + "", + PosProductListView.as_view(), + name="posproduct_list", + ), + path( + "/update/", + PosProductUpdateView.as_view(), + name="posproduct_update", + ), + ], + ), + ), + path( + "product_costs/", + include( + [ + path( + "", + PosProductCostListView.as_view(), + name="posproductcost_list", + ), + path( + "/update/", + PosProductCostUpdateView.as_view(), + name="posproductcost_update", + ), + ], + ), + ), + path( + "transactions/", + PosTransactionListView.as_view(), + name="postransaction_list", + ), + path( + "sales/", + PosSaleListView.as_view(), + name="possale_list", + ), + path( + "sales/import/", + PosSalesImportView.as_view(), + name="possale_import", + ), path( "/", include( @@ -1094,6 +1151,11 @@ "reports/", include( [ + path( + "", + PosReportListView.as_view(), + name="posreport_list", + ), path( "create/", PosReportCreateView.as_view(), diff --git a/src/backoffice/views/orga.py b/src/backoffice/views/orga.py index 29d049491..d881c4bfd 100644 --- a/src/backoffice/views/orga.py +++ b/src/backoffice/views/orga.py @@ -209,6 +209,8 @@ def get_queryset(self): ############## # TICKET STATS + + class ShopTicketStatsView(CampViewMixin, OrgaTeamPermissionMixin, ListView): model = TicketType template_name = "ticket_stats.html" diff --git a/src/backoffice/views/pos.py b/src/backoffice/views/pos.py index af09dc118..c48ae8dac 100644 --- a/src/backoffice/views/pos.py +++ b/src/backoffice/views/pos.py @@ -1,21 +1,41 @@ +import json import logging from django.contrib import messages from django.core.exceptions import PermissionDenied +from django.db import models from django.shortcuts import redirect from django.urls import reverse from django.views.generic import DetailView from django.views.generic import ListView from django.views.generic.edit import CreateView from django.views.generic.edit import DeleteView +from django.views.generic.edit import FormView from django.views.generic.edit import UpdateView +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin +from ..forms import PosSalesJSONForm from ..mixins import OrgaTeamPermissionMixin from ..mixins import PosViewMixin from ..mixins import RaisePermissionRequiredMixin from camps.mixins import CampViewMixin +from economy.filters import PosProductCostFilter +from economy.filters import PosProductFilter +from economy.filters import PosSaleFilter +from economy.filters import PosTransactionFilter +from economy.models import Expense from economy.models import Pos +from economy.models import PosProduct +from economy.models import PosProductCost from economy.models import PosReport +from economy.models import PosSale +from economy.models import PosTransaction +from economy.tables import PosProductCostTable +from economy.tables import PosProductTable +from economy.tables import PosSaleTable +from economy.tables import PosTransactionTable +from economy.utils import import_pos_sales_json from teams.models import Team logger = logging.getLogger("bornhack.%s" % __name__) @@ -81,6 +101,9 @@ def get_success_url(self): return reverse("backoffice:pos_list", kwargs={"camp_slug": self.camp.slug}) +# ################## POSREPORT VIEWS START ######################### + + class PosReportCreateView(PosViewMixin, CreateView): """Use this view to create new PosReports.""" @@ -119,6 +142,14 @@ def form_valid(self, form): ) +class PosReportListView(PosViewMixin, DetailView): + """Show a list of PosReports for a Pos.""" + + model = Pos + template_name = "posreport_list.html" + slug_url_kwarg = "pos_slug" + + class PosReportUpdateView(PosViewMixin, UpdateView): """Use this view to update PosReports.""" @@ -237,3 +268,199 @@ def setup(self, *args, **kwargs): super().setup(*args, **kwargs) if self.request.user != self.get_object().pos_responsible: raise PermissionDenied("Only the pos responsible can do this") + + +# ################## POSREPORT VIEWS END ######################### + + +class PosTransactionListView( + CampViewMixin, + OrgaTeamPermissionMixin, + SingleTableMixin, + FilterView, +): + """A list of PosTransation objects.""" + + model = PosTransaction + template_name = "pos_transaction_list.html" + table_class = PosTransactionTable + filterset_class = PosTransactionFilter + + def get_context_data(self, *args, **kwargs): + """Include the total (unfiltered) count.""" + context = super().get_context_data(*args, **kwargs) + # transactions + context["total_transactions"] = PosTransaction.objects.filter( + pos__team__camp=self.camp, + ).count() + # total sales + context["total_sales_count"] = PosSale.objects.filter( + transaction__pos__team__camp=self.camp, + ).count() + context["total_sales_sum"] = PosSale.objects.filter( + transaction__pos__team__camp=self.camp, + ).aggregate(models.Sum("sales_price"))["sales_price__sum"] + # filtered sales + filtered_totals = context["filter"].qs.aggregate( + filtered_sales_count=models.Count("pos_sales"), + filtered_sales_sum=models.Sum("pos_sales__sales_price"), + ) + context.update(filtered_totals) + return context + + +class PosSaleListView( + CampViewMixin, + OrgaTeamPermissionMixin, + SingleTableMixin, + FilterView, +): + """A list of PosSale objects.""" + + model = PosSale + template_name = "pos_sale_list.html" + table_class = PosSaleTable + filterset_class = PosSaleFilter + + def get_context_data(self, *args, **kwargs): + """Include the total (unfiltered) count and sums.""" + context = super().get_context_data(*args, **kwargs) + context["total_sales_count"] = PosSale.objects.filter( + transaction__pos__team__camp=self.camp, + ).count() + context["total_sales_sum"] = PosSale.objects.filter( + transaction__pos__team__camp=self.camp, + ).aggregate(models.Sum("sales_price"))["sales_price__sum"] + context["filtered_sales_sum"] = context["filter"].qs.aggregate( + models.Sum("sales_price"), + )["sales_price__sum"] + return context + + +class PosSalesImportView(CampViewMixin, OrgaTeamPermissionMixin, FormView): + form_class = PosSalesJSONForm + template_name = "pos_sales_json_upload_form.html" + + def form_valid(self, form): + if "sales" in form.files: + sales_data = json.loads(form.files["sales"].read().decode()) + products, transactions, sales = import_pos_sales_json(sales_data) + messages.success( + self.request, + f"PoS sales json processed OK. Created {products} new products and {transactions} new transactions containing {sales} new sales.", + ) + return redirect( + reverse( + "backoffice:epaytransaction_list", + kwargs={"camp_slug": self.camp.slug}, + ), + ) + + +class PosProductListView( + CampViewMixin, + OrgaTeamPermissionMixin, + SingleTableMixin, + FilterView, +): + """A list of PosProduct objects.""" + + model = PosProduct + template_name = "pos_product_list.html" + table_class = PosProductTable + filterset_class = PosProductFilter + + def get_context_data(self, *args, **kwargs): + """Include the total (unfiltered) count.""" + context = super().get_context_data(*args, **kwargs) + campfilter = models.Q(pos_sales__transaction__pos__team__camp=self.request.camp) + context["total_products"] = ( + PosProduct.objects.filter(campfilter).distinct().count() + ) + filtered_totals = context["filter"].qs.aggregate( + filtered_sales_count=models.Count("pos_sales", filter=campfilter), + filtered_sales_sum=models.Sum("pos_sales__sales_price", filter=campfilter), + ) + context.update(filtered_totals) + context["total_sales_count"] = PosProduct.objects.aggregate( + total_sales_count=models.Count("pos_sales", filter=campfilter), + )["total_sales_count"] + context["total_sales_sum"] = PosProduct.objects.aggregate( + total_sales_sum=models.Sum("pos_sales__sales_price", filter=campfilter), + )["total_sales_sum"] + return context + + +class PosProductUpdateView(CampViewMixin, OrgaTeamPermissionMixin, UpdateView): + """Use this view to update PosProduct objects.""" + + model = PosProduct + fields = [ + "brand_name", + "name", + "description", + "sales_price", + "unit_size", + "size_unit", + "abv", + "tags", + "expenses", + ] + template_name = "posproduct_form.html" + pk_url_kwarg = "posproduct_uuid" + + def get_success_url(self): + return reverse( + "backoffice:posproduct_list", + kwargs={"camp_slug": self.camp.slug}, + ) + + def get_context_data(self, **kwargs): + """Only show relevant expenses.""" + context = super().get_context_data(**kwargs) + pos_teams = Team.objects.filter(camp=self.camp, points_of_sale__isnull=False) + expenses = Expense.objects.filter( + camp=self.request.camp, + responsible_team__in=pos_teams, + ) + context["form"].fields["expenses"].queryset = expenses + return context + + +class PosProductCostListView( + CampViewMixin, + OrgaTeamPermissionMixin, + SingleTableMixin, + FilterView, +): + """A list of PosProductCost objects.""" + + model = PosProductCost + template_name = "pos_product_cost_list.html" + table_class = PosProductCostTable + filterset_class = PosProductCostFilter + + def get_context_data(self, **kwargs): + """Include total number of costs.""" + context = super().get_context_data(**kwargs) + context["total_costs"] = ( + PosProductCost.objects.filter(camp=self.camp).count() + ) + return context + +class PosProductCostUpdateView(CampViewMixin, OrgaTeamPermissionMixin, UpdateView): + """Use this view to update PosProductCost objects.""" + + model = PosProductCost + fields = [ + "product_cost", + "timestamp", + ] + template_name = "posproductcost_form.html" + pk_url_kwarg = "posproductcost_uuid" + + def get_success_url(self): + return reverse( + "backoffice:posproductcost_list", + kwargs={"camp_slug": self.camp.slug}, + ) diff --git a/src/bornhack/settings.py b/src/bornhack/settings.py index 67ca8350e..3ec19dbcb 100644 --- a/src/bornhack/settings.py +++ b/src/bornhack/settings.py @@ -66,6 +66,8 @@ "leaflet", "oauth2_provider", "taggit", + "django_tables2", + "django_filters", ] # MEDIA_URL = '/media/' @@ -135,6 +137,7 @@ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "utils.middleware.RedirectExceptionMiddleware", + "camps.middleware.RequestCampMiddleware", "oauth2_provider.middleware.OAuth2TokenMiddleware", "django_prometheus.middleware.PrometheusAfterMiddleware", ] @@ -217,3 +220,15 @@ } UPCOMING_CAMP_YEAR = 2025 + +# django-tables2 settings +DJANGO_TABLES2_TEMPLATE = "django_tables2/bootstrap-responsive.html" +DJANGO_TABLES2_TABLE_ATTRS = { + "class": "table table-hover table-striped", +} + +# fallback settings for views/pages/places where l10n is disabled +DATE_FORMAT = "l, M jS, Y" +DATETIME_FORMAT = "l, M jS, Y, H:i (e)" +SHORT_DATE_FORMAT = "Ymd" +TIME_FORMAT = "H:i" diff --git a/src/camps/middleware.py b/src/camps/middleware.py new file mode 100644 index 000000000..8a89db9c4 --- /dev/null +++ b/src/camps/middleware.py @@ -0,0 +1,23 @@ +from django.shortcuts import get_object_or_404 + + +class RequestCampMiddleware: + """Add camp as an attribute on the request object.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_view(self, request, view_func, view_args, view_kwargs): + from camps.models import Camp + + if ( + hasattr(request, "resolver_match") + and request.resolver_match + and "camp_slug" in request.resolver_match.kwargs + ): + camp = get_object_or_404(Camp, slug=view_kwargs["camp_slug"]) + request.camp = camp diff --git a/src/camps/mixins.py b/src/camps/mixins.py index c8edf6bd7..b7b0918a8 100644 --- a/src/camps/mixins.py +++ b/src/camps/mixins.py @@ -16,10 +16,6 @@ def setup(self, *args, **kwargs): def get_queryset(self): queryset = super().get_queryset() - # if this queryset is empty return it right away, because nothing for us to do - if not queryset: - return queryset - # do we have a camp_filter on this model if not hasattr(self.model, "camp_filter"): return queryset diff --git a/src/camps/utils.py b/src/camps/utils.py index 65d3fd9d8..7b59dfb09 100644 --- a/src/camps/utils.py +++ b/src/camps/utils.py @@ -2,6 +2,7 @@ from django.utils import timezone from camps.models import Camp +from economy.models import Pos def get_current_camp(): @@ -48,3 +49,45 @@ def queryset(self, request, queryset): if item.camp != camp: queryset = queryset.exclude(pk=item.pk) return queryset + + +def get_closest_camp(timestamp): + """Return the Camp object happening closest to the provided datetime.""" + # is the timestamp during a camp? + try: + return Camp.objects.get( + buildup__startswith__lt=timestamp, + teardown__endswith__gt=timestamp, + ) + except Camp.DoesNotExist: + pass + + # get the upcoming/next camp after the timestamp + try: + next_camp = Camp.objects.filter(buildup__startswith__gt=timestamp).last() + except Pos.DoesNotExist: + next_camp = None + + # get the previous camp before the timestamp + try: + prev_camp = Camp.objects.filter(teardown__endswith__lt=timestamp).first() + except Pos.DoesNotExist: + prev_camp = None + + if not prev_camp: + # no bornhack happened before the first bornhack + return next_camp + + if not next_camp: + # no bornhack happened after the timestamp + return prev_camp + + # calculate timedeltas + time_since_prev = timestamp - prev_camp.teardown.upper + time_until_next = next_camp.buildup.lower - timestamp + + if time_since_prev < time_until_next: + # timestamp is closer to the previous camp + return prev_camp + # timestamp is closer to the upcoming/next camp + return next_camp diff --git a/src/economy/admin.py b/src/economy/admin.py index 47f208682..29f5c2850 100644 --- a/src/economy/admin.py +++ b/src/economy/admin.py @@ -11,7 +11,10 @@ from .models import EpayTransaction from .models import Expense from .models import Pos +from .models import PosProduct from .models import PosReport +from .models import PosSale +from .models import PosTransaction from .models import Reimbursement from .models import Revenue from .models import ZettleBalance @@ -145,6 +148,40 @@ class PosReportAdmin(admin.ModelAdmin): list_display = ["uuid", "pos"] +@admin.register(PosProduct) +class PosProductAdmin(admin.ModelAdmin): + list_display = [ + "uuid", + "external_id", + "brand_name", + "name", + "description", + "sales_price", + "unit_size", + "size_unit", + "abv", + "tags", + ] + + +@admin.register(PosTransaction) +class PosTransactionAdmin(admin.ModelAdmin): + list_display = [ + "uuid", + "pos", + "external_transaction_id", + "external_user_id", + "timestamp", + ] + list_filter = ["pos", "external_user_id"] + date_hierarchy = "timestamp" + + +@admin.register(PosSale) +class PosSaleAdmin(admin.ModelAdmin): + list_display = ["uuid", "transaction", "product", "sales_price"] + + ################################ # bank diff --git a/src/economy/filters.py b/src/economy/filters.py new file mode 100644 index 000000000..651d5cf2e --- /dev/null +++ b/src/economy/filters.py @@ -0,0 +1,228 @@ +from django.db import models +from django_filters import filters +from django_filters import FilterSet + +from .models import Pos +from .models import PosProduct +from .models import PosProductCost +from .models import PosSale +from .models import PosTransaction + + +def get_size_unit_widget_data(request): + """Size unit dropdown data.""" + return ( + PosProduct.objects.filter( + pos_sales__transaction__pos__team__camp=request.camp, + pos_sales__isnull=False, + ) + .values_list("size_unit", flat=True) + .distinct() + ) + + +class PosProductFilter(FilterSet): + brand = filters.CharFilter( + field_name="brand_name", + label="Brand Name Contains", + lookup_expr="icontains", + ) + name = filters.CharFilter(label="Name Contains", lookup_expr="icontains") + description = filters.CharFilter( + label="Description Contains", + lookup_expr="icontains", + ) + prodid = filters.CharFilter(field_name="external_id", label="Product ID") + price = filters.RangeFilter(field_name="sales_price", label="Price") + size = filters.RangeFilter(field_name="unit_size", label="Size") + unit = filters.ModelChoiceFilter( + field_name="size_unit", + queryset=get_size_unit_widget_data, + to_field_name="size_unit", + ) + abv = filters.RangeFilter(label="ABV %") + tags = filters.CharFilter(label="Tags Contain", lookup_expr="icontains") + sales_count = filters.RangeFilter(label="# of Sales") + sales_sum = filters.RangeFilter(label="Sales Sum") + cost = filters.RangeFilter(label="Product Cost") + + + class Meta: + model = PosProduct + fields = {} + + @property + def qs(self): + """Only show products sold at least once during current camp. Add annotations here to make sure they are filterable.""" + # define a reusable camp filter + campfilter = models.Q(pos_sales__transaction__pos__team__camp=self.request.camp) + # define subq for getting latest product cost + latest_cost = PosProductCost.objects.filter( + product__uuid=models.OuterRef("uuid"), + camp=self.request.camp, + ).order_by("-timestamp") + # annotate sales count, sales sum, and cost + self.queryset = PosProduct.objects.filter( + campfilter, + pos_sales__isnull=False, + ).annotate( + sales_count=models.Count("pos_sales", filter=campfilter), + sales_sum=models.Sum("pos_sales__sales_price", filter=campfilter), + # default cost to 0 if we have no costs for this product + cost=models.functions.Coalesce( + models.Subquery(latest_cost.values("product_cost")[:1]), + 0, + output_field=models.DecimalField(), + ), + ) + return super().qs + + +def get_pos_widget_data(request): + """Pos picker dropdown data.""" + return Pos.objects.filter(team__camp=request.camp).values_list("name", flat=True) + + +class PosTransactionFilter(FilterSet): + """FilterSet for PosTransaction filtering.""" + + pos = filters.ModelChoiceFilter( + field_name="pos__name", + queryset=get_pos_widget_data, + to_field_name="name", + ) + timestamp = filters.DateTimeFromToRangeFilter() + seller = filters.CharFilter(field_name="external_user_id", label="Seller") + products = filters.RangeFilter(label="# Products sold (between)") + price = filters.RangeFilter(field_name="total", label="Total (between)") + + class Meta: + model = PosTransaction + fields = {} + + @property + def qs(self): + """Add annotations here to make sure they are filterable.""" + self.queryset = PosTransaction.objects.filter( + pos__team__camp=self.request.camp, + ).annotate( + # annotate total number of sales + products=models.Count("pos_sales"), + # annotate total price + total=models.Sum("pos_sales__sales_price"), + ) + return super().qs + + +class PosSaleFilter(FilterSet): + """FilterSet for PosSale filtering.""" + + txid = filters.CharFilter( + field_name="transaction__external_transaction_id", + label="Transaction ID", + ) + prodid = filters.CharFilter(field_name="product__external_id", label="Product ID") + brand = filters.CharFilter( + field_name="product__brand_name", + lookup_expr="icontains", + label="Brand", + ) + name = filters.CharFilter( + field_name="product__name", + lookup_expr="icontains", + label="Product Name", + ) + description = filters.CharFilter( + field_name="product__description", + lookup_expr="icontains", + label="Description", + ) + size = filters.RangeFilter(field_name="product__unit_size", label="Size") + unit = filters.ModelChoiceFilter( + field_name="product__size_unit", + queryset=get_size_unit_widget_data, + to_field_name="size_unit", + ) + tags = filters.CharFilter( + field_name="product__tags", + lookup_expr="icontains", + label="Tags", + ) + price = filters.RangeFilter(field_name="sales_price", label="Price") + pos = filters.ModelChoiceFilter( + field_name="transaction__pos__name", + queryset=get_pos_widget_data, + to_field_name="name", + ) + timestamp = filters.DateTimeFromToRangeFilter(field_name="transaction__timestamp") + cost = filters.RangeFilter(label="Cost") + profit = filters.RangeFilter(label="Profit") + + class Meta: + model = PosSale + fields = {} + + @property + def qs(self): + """Add annotations here to make sure they are filterable.""" + # define subq for getting latest product cost + latest_cost = PosProductCost.objects.filter( + product__uuid=models.OuterRef("product__uuid"), + camp=self.request.camp, + ).order_by("-timestamp") + + self.queryset = PosSale.objects.filter( + transaction__pos__team__camp=self.request.camp, + ).annotate( + cost=models.functions.Coalesce( + models.Subquery(latest_cost.values("product_cost")[:1]), + 0, + output_field=models.DecimalField(), + ), + profit=models.Sum(models.F("sales_price") - models.F("cost")), + ) + return super().qs + + +class PosProductCostFilter(FilterSet): + """FilterSet for PosProductCost filtering.""" + + brand = filters.CharFilter( + field_name="product__brand_name", + lookup_expr="icontains", + label="Brand", + ) + name = filters.CharFilter( + field_name="product__name", + lookup_expr="icontains", + label="Product Name", + ) + description = filters.CharFilter( + field_name="product__description", + lookup_expr="icontains", + label="Description", + ) + prodid = filters.CharFilter(field_name="product__external_id", label="Product ID") + size = filters.RangeFilter(field_name="product__unit_size", label="Size") + unit = filters.ModelChoiceFilter( + field_name="product__size_unit", + queryset=get_size_unit_widget_data, + to_field_name="size_unit", + ) + tags = filters.CharFilter( + field_name="product__tags", + lookup_expr="icontains", + label="Tags", + ) + timestamp = filters.DateTimeFromToRangeFilter() + cost = filters.RangeFilter(field_name="product_cost", label="Product Cost") + + class Meta: + model = PosProductCost + fields = {} + + @property + def qs(self): + """Only show costs for current camp.""" + self.queryset = PosProductCost.objects.filter(camp=self.request.camp) + return super().qs diff --git a/src/economy/migrations/0039_posproduct_pos_external_id_alter_pos_team_and_more.py b/src/economy/migrations/0039_posproduct_pos_external_id_alter_pos_team_and_more.py new file mode 100644 index 000000000..b715e36b5 --- /dev/null +++ b/src/economy/migrations/0039_posproduct_pos_external_id_alter_pos_team_and_more.py @@ -0,0 +1,285 @@ +# Generated by Django 4.2.10 on 2024-09-18 15:36 + +from django.db import migrations, models +import django.db.models.deletion +import django_prometheus.models +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("teams", "0052_team_permission_set"), + ("camps", "0036_camp_economy_team"), + ("economy", "0038_reimbursement_bank_account_alter_reimbursement_user"), + ] + + operations = [ + migrations.CreateModel( + name="PosProduct", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "external_id", + models.CharField( + help_text="The external ID of the product.", + max_length=100, + unique=True, + ), + ), + ( + "brand_name", + models.CharField( + help_text="The name of the brand.", max_length=255 + ), + ), + ( + "name", + models.CharField( + help_text="The name of the product.", max_length=255 + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="The description of the product." + ), + ), + ( + "sales_price", + models.IntegerField(help_text="The current price of this product."), + ), + ( + "unit_size", + models.DecimalField( + decimal_places=2, + help_text="The size of this product.", + max_digits=10, + ), + ), + ( + "size_unit", + models.CharField( + blank=True, + help_text="The unit the size of this product is measured in, where applicable.", + max_length=100, + ), + ), + ( + "abv", + models.DecimalField( + decimal_places=2, + default=0, + help_text="The ABV level of this product, where applicable.", + max_digits=4, + ), + ), + ( + "tags", + models.CharField( + blank=True, + help_text="The tags for this product as a comma seperated string.", + max_length=100, + ), + ), + ( + "expenses", + models.ManyToManyField( + help_text="The related expenses for this PosProduct. Only expenses related to a Pos-team are shown. For products composed of multiple ingredients all relevant expenses should be picked.", + to="economy.expense", + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + django_prometheus.models.ExportModelOperationsMixin("pos_product"), + models.Model, + ), + ), + migrations.AddField( + model_name="pos", + name="external_id", + field=models.CharField( + default="external_id_unset", + help_text="The external database ID of this pos location.", + max_length=100, + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="pos", + name="team", + field=models.ForeignKey( + help_text="The Team managing this POS", + on_delete=django.db.models.deletion.PROTECT, + related_name="points_of_sale", + to="teams.team", + ), + ), + migrations.CreateModel( + name="PosTransaction", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "external_transaction_id", + models.CharField( + help_text="The external ID of this pos transaction.", + max_length=100, + unique=True, + ), + ), + ( + "external_user_id", + models.CharField( + blank=True, + help_text="The external ID of the pos user who did this transaction.", + max_length=100, + ), + ), + ( + "timestamp", + models.DateTimeField( + help_text="The date and time of this PoS transaction." + ), + ), + ( + "pos", + models.ForeignKey( + help_text="The Pos this PosTransaction belongs to.", + on_delete=django.db.models.deletion.PROTECT, + related_name="pos_transactions", + to="economy.pos", + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + django_prometheus.models.ExportModelOperationsMixin("pos_transaction"), + models.Model, + ), + ), + migrations.CreateModel( + name="PosSale", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "sales_price", + models.IntegerField( + help_text="The price of this product (at the time of sale)." + ), + ), + ( + "product", + models.ForeignKey( + help_text="The product being sold.", + on_delete=django.db.models.deletion.PROTECT, + related_name="pos_sales", + to="economy.posproduct", + ), + ), + ( + "transaction", + models.ForeignKey( + help_text="The transaction to which this sale belongs.", + on_delete=django.db.models.deletion.PROTECT, + related_name="pos_sales", + to="economy.postransaction", + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + django_prometheus.models.ExportModelOperationsMixin("pos_sale"), + models.Model, + ), + ), + migrations.CreateModel( + name="PosProductCost", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "timestamp", + models.DateTimeField( + help_text="The timestamp from which this product_cost is correct." + ), + ), + ( + "product_cost", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="The cost/expense (in DKK, including VAT) for each product sold. For products composed of multiple ingredients this number should include the total cost per product sold.", + max_digits=8, + null=True, + ), + ), + ( + "camp", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="pos_product_costs", + to="camps.camp", + ), + ), + ( + "product", + models.ForeignKey( + help_text="The product this cost applies to.", + on_delete=django.db.models.deletion.PROTECT, + related_name="pos_product_costs", + to="economy.posproduct", + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + django_prometheus.models.ExportModelOperationsMixin("pos_product_cost"), + models.Model, + ), + ), + ] diff --git a/src/economy/models.py b/src/economy/models.py index 20c81a291..b26866b7c 100644 --- a/src/economy/models.py +++ b/src/economy/models.py @@ -474,6 +474,9 @@ def reject(self, request): # message to the browser messages.success(request, "Expense %s rejected" % self.pk) + def __str__(self): + return f"{self.responsible_team.name} Team - { self.amount } DKK - {self.creditor.name} - {self.description}" + class Reimbursement( ExportModelOperationsMixin("reimbursement"), @@ -595,6 +598,11 @@ class Meta: name = models.CharField(max_length=255, help_text="The point-of-sale name") + external_id = models.CharField( + max_length=100, + help_text="The external database ID of this pos location." "", + ) + slug = models.SlugField( max_length=255, blank=True, @@ -604,6 +612,7 @@ class Meta: team = models.ForeignKey( "teams.Team", on_delete=models.PROTECT, + related_name="points_of_sale", help_text="The Team managing this POS", ) @@ -676,6 +685,16 @@ def export_csv(self, period, workdir): ) return (self, filename, posreports.count()) + @property + def total_sales(self): + """Return the sum of all sales in all transactions for this Pos.""" + return self.sales.aggregate(models.Sum("sales_price"))["sales_price__sum"] + + @property + def sales(self): + """Return a queryset of all sales in all transactions for this Pos.""" + return PosSale.objects.filter(transaction__pos=self) + class PosReport(ExportModelOperationsMixin("pos_report"), CampRelatedModel, UUIDModel): """A PosReport contains the HAX/DKK counts and the csv report from the POS system.""" @@ -1045,6 +1064,166 @@ def hax_sold_website(self): return total +class PosProduct(ExportModelOperationsMixin("pos_product"), UUIDModel): + """A product sold in our PoS. This model does not inherit from CampRelatedModel, meaning pos products are not camp specific.""" + + external_id = models.CharField( + max_length=100, + unique=True, + help_text="The external ID of the product.", + ) + + brand_name = models.CharField(max_length=255, help_text="The name of the brand.") + + name = models.CharField(max_length=255, help_text="The name of the product.") + + description = models.TextField( + blank=True, + help_text="The description of the product.", + ) + + sales_price = models.IntegerField(help_text="The current price of this product.") + + unit_size = models.DecimalField( + max_digits=10, + decimal_places=2, + help_text="The size of this product.", + ) + + size_unit = models.CharField( + max_length=100, + blank=True, + help_text="The unit the size of this product is measured in, where applicable.", + ) + + abv = models.DecimalField( + max_digits=4, + decimal_places=2, + default=0, + help_text="The ABV level of this product, where applicable.", + ) + + tags = models.CharField( + max_length=100, + blank=True, + help_text="The tags for this product as a comma seperated string.", + ) + + expenses = models.ManyToManyField( + "economy.Expense", + help_text="The related expenses for this PosProduct. Only expenses related to a Pos-team are shown. For products composed of multiple ingredients all relevant expenses should be picked.", + ) + + def __str__(self): + return ( + f"{self.brand_name} - {self.name} ({round(self.unit_size)}{self.size_unit})" + ) + + +class PosTransaction( + ExportModelOperationsMixin("pos_transaction"), + CampRelatedModel, + UUIDModel, +): + """A transaction from the Pos system.""" + + pos = models.ForeignKey( + "economy.Pos", + on_delete=models.PROTECT, + related_name="pos_transactions", + help_text="The Pos this PosTransaction belongs to.", + ) + + external_transaction_id = models.CharField( + max_length=100, + unique=True, + help_text="The external ID of this pos transaction.", + ) + + external_user_id = models.CharField( + max_length=100, + blank=True, + help_text="The external ID of the pos user who did this transaction.", + ) + + timestamp = models.DateTimeField( + help_text="The date and time of this PoS transaction.", + ) + + @property + def camp(self): + return self.pos.team.camp + + camp_filter = "pos__team__camp" + + def __str__(self): + return f"{self.pos} sale {self.timestamp}" + + +class PosSale(ExportModelOperationsMixin("pos_sale"), CampRelatedModel, UUIDModel): + """A single product sold in a PoS transaction. + + Multiples of the same product result sold in a single tx results in multilpe PosSale objects. + """ + + transaction = models.ForeignKey( + "economy.PosTransaction", + on_delete=models.PROTECT, + related_name="pos_sales", + help_text="The transaction to which this sale belongs.", + ) + + product = models.ForeignKey( + "economy.PosProduct", + on_delete=models.PROTECT, + related_name="pos_sales", + help_text="The product being sold.", + ) + + sales_price = models.IntegerField( + help_text="The price of this product (at the time of sale).", + ) + + @property + def camp(self): + return self.transaction.pos.team.camp + + camp_filter = "transaction__pos__team__camp" + + +class PosProductCost( + ExportModelOperationsMixin("pos_product_cost"), + CampRelatedModel, + UUIDModel, +): + """Defines the cost of PosProducts.""" + + camp = models.ForeignKey( + "camps.Camp", + related_name="pos_product_costs", + on_delete=models.PROTECT, + ) + + product = models.ForeignKey( + "economy.PosProduct", + on_delete=models.PROTECT, + related_name="pos_product_costs", + help_text="The product this cost applies to.", + ) + + timestamp = models.DateTimeField( + help_text="The timestamp from which this product_cost is correct.", + ) + + product_cost = models.DecimalField( + max_digits=8, + decimal_places=2, + null=True, + blank=True, + help_text="The cost/expense (in DKK, including VAT) for each product sold. For products composed of multiple ingredients this number should include the total cost per product sold.", + ) + + ################################## # bank stuff diff --git a/src/economy/tables.py b/src/economy/tables.py new file mode 100644 index 000000000..8d0a1ef86 --- /dev/null +++ b/src/economy/tables.py @@ -0,0 +1,363 @@ +import django_tables2 as tables +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.formats import localize +from django.utils.safestring import mark_safe + +from economy.models import PosProduct +from economy.models import PosProductCost +from economy.models import PosSale +from economy.models import PosTransaction +from utils.querystring import querystring_from_request + + +def filter_button(text, request, **kwargs): + """Add a filter button before the provided text with a querystring updated with the provided kwargs.""" + querystring = querystring_from_request(request, **kwargs) + button = f'' + return mark_safe(f"{button} {text}") + + +def filter_link(text, request, **kwargs): + """Wrap the provided text in a link with a querystring updated with the provided kwargs.""" + querystring = querystring_from_request(request, **kwargs) + return mark_safe(f'{text}') + + +def intround(value): + """Remove decimals if they are all 0.""" + if value == int(value): + return int(value) + return round(value, 2) + + +# ############ COLUMNS ################# + + +class TagsColumn(tables.Column): + def render(self, value, table): + output = [] + for tag in value.split(","): + output.append( + filter_link( + f'{tag}', + table.request, + tags=tag, + ), + ) + return mark_safe(" ".join(output)) + + +class HaxColumn(tables.Column): + def render(self, value, table): + return filter_button( + f"{value} HAX", + table.request, + **{"price_min": value, "price_max": value}, + ) + + +class PosColumn(tables.Column): + def render(self, value, table): + return filter_button(value, table.request, **{"pos": value}) + + +class TimestampColumn(tables.Column): + def render(self, value, table): + return filter_button( + localize(timezone.localtime(value), use_l10n=True), + table.request, + **{"timestamp_after": value, "timestamp_before": value}, + ) + + +class SizeColumn(tables.Column): + def render(self, value, record, table): + value = intround(value) + unit = ( + record.product.size_unit if hasattr(record, "product") else record.size_unit + ) + return filter_button( + f"{value} {unit}", + table.request, + **{"size_min": value, "size_max": value, "unit": unit}, + ) + + +class BrandColumn(tables.Column): + def render(self, value, table): + return filter_button(value, table.request, **{"brand": value}) + + +class NameColumn(tables.Column): + def render(self, value, record, table): + button = filter_button(value, table.request, **{"name": value}) + url = reverse("backoffice:posproduct_list", kwargs={"camp_slug": table.request.camp.slug}) + if hasattr(record, "product"): + product = record.product + else: + product = record + link = f'(show)' + return mark_safe(f"{button} {link}") + + +class DescriptionColumn(tables.Column): + def render(self, value, table): + return filter_button(value, table.request, **{"description": value}) + + +class CostColumn(tables.Column): + def render(self, value, table): + return filter_button( + f"{value} DKK", + table.request, + **{"cost_min": value, "cost_max": value}, + ) + + +# ############ TABLES ################# + + +class PosProductTable(tables.Table): + """Table with PosProduct objects.""" + + brand_name = BrandColumn(verbose_name="Brand") + name = NameColumn(verbose_name="Name") + description = DescriptionColumn(verbose_name="Description") + sales_price = HaxColumn(verbose_name="Price") + cost = tables.Column(verbose_name="Cost", empty_values=()) + tags = TagsColumn() + unit_size = SizeColumn() + expenses = tables.Column(verbose_name="Expenses") + update = tables.TemplateColumn( + verbose_name="Update", + template_code='', + orderable=False, + ) + + def __init__(self, data, *args, **kwargs): + """Do select/prefetch_related here so it happens after django-tables2s pagination.""" + super().__init__( + data.prefetch_related( + "expenses__camp", + "pos_product_costs", + ), + *args, + **kwargs, + ) + + def render_sales_price(self, value): + return filter_button( + f"{value} HAX", + self.request, + **{"price_min": value, "price_max": value}, + ) + + def render_abv(self, value): + return filter_button( + f"{intround(value)}%", + self.request, + **{"abv_min": value, "abv_max": value}, + ) + + def render_sales_count(self, value, record): + count = filter_button( + value, + self.request, + **{"sales_count_min": value, "sales_count_max": value}, + ) + qs = querystring_from_request(self.request, prodid=record.external_id) + url = reverse( + "backoffice:possale_list", + kwargs={"camp_slug": self.request.camp.slug}, + ) + link = f'show' + return mark_safe(f"{count} ({link})") + + def render_sales_sum(self, value, record): + count = filter_button( + value, + self.request, + **{"sales_sum_min": value, "sales_sum_max": value}, + ) + return mark_safe(f"{count} HAX") + + def render_expenses(self, value, record): + if not record.expenses.exists(): + return "N/A" + output = [] + count = 0 + for expense in record.expenses.all(): + url = expense.get_backoffice_url() + output.append(f'#{count}') + count += 1 + return mark_safe(", ".join(output)) + + def render_cost(self, value, record, table): + if value == 0: + return "N/A" + cost_url = reverse( + "backoffice:posproductcost_list", + kwargs={"camp_slug": table.request.camp.slug}, + ) + link = f'(show)' + return mark_safe(f"{value} DKK
{link}") + + def order_expenses(self, queryset, is_descending): + queryset = queryset.annotate( + expense_count=models.Count("expenses"), + ).order_by(("-" if is_descending else "") + "expense_count") + return (queryset, True) + + class Meta: + model = PosProduct + fields = [ + "brand_name", + "name", + "description", + "sales_price", + "cost", + "expenses", + "unit_size", + "abv", + "tags", + "sales_count", + "sales_sum", + ] + + +class PosSaleTable(tables.Table): + """Table with PosSale objects.""" + + transaction__pos__name = PosColumn(verbose_name="Pos") + transaction__external_transaction_id = tables.Column(verbose_name="Transaction") + transaction__timestamp = TimestampColumn(verbose_name="Timestamp") + product__brand_name = BrandColumn(verbose_name="Brand") + product__name = NameColumn(verbose_name="Name") + product__unit_size = SizeColumn(verbose_name="Size") + product__description = DescriptionColumn(verbose_name="Description") + sales_price = HaxColumn(verbose_name="Price") + product__tags = TagsColumn(verbose_name="Tags") + cost = CostColumn(verbose_name="Cost") + + def __init__(self, data, *args, **kwargs): + """Do select/prefetch_related and annotations here so it happens after django-tables2s pagination.""" + super().__init__( + data + # get Pos and PosTransaction + .select_related("transaction__pos") + # get PosProduct + .select_related("product"), + *args, + **kwargs, + ) + + def render_transaction__external_transaction_id(self, value): + return filter_button(value, self.request, **{"txid": value}) + + def render_profit(self, value, record): + if record.cost == 0: + return "N/A" + return filter_button( + f"{value} DKK", + self.request, + **{"profit_min": value, "profit_max": value}, + ) + + class Meta: + """Specify the model and which fields to include in the table.""" + + model = PosSale + fields = [ + "transaction__pos__name", + "transaction__external_transaction_id", + "transaction__timestamp", + "product__brand_name", + "product__name", + "product__description", + "product__unit_size", + "product__tags", + "sales_price", + "cost", + "profit", + ] + + +class PosTransactionTable(tables.Table): + external_user_id = tables.Column(verbose_name="Seller") + pos__name = PosColumn(verbose_name="Pos") + total = HaxColumn(verbose_name="Transaction Total") + products = tables.Column(verbose_name="Items Sold") + timestamp = TimestampColumn(verbose_name="Transaction Timestamp") + + def __init__(self, data, *args, **kwargs): + """Do select/prefetch_related here so it happens after django-tables2s pagination.""" + super().__init__( + data + # get the parent Pos object + .select_related("pos") + # get PosSales for each PosTransaction + .prefetch_related("pos_sales"), + *args, + **kwargs, + ) + + class Meta: + model = PosTransaction + fields = [ + "pos__name", + "external_user_id", + "timestamp", + "products", + "total", + ] + + def render_external_user_id(self, value): + return filter_button(value, self.request, **{"seller": value}) + + def render_products(self, value, record): + count = filter_button( + value, + self.request, + **{"products_min": value, "products_max": value}, + ) + url = reverse( + "backoffice:possale_list", + kwargs={"camp_slug": self.request.camp.slug}, + ) + link = f'show' + return mark_safe(f"{count} ({link})") + + +class PosProductCostTable(tables.Table): + """Table with PosProductCost objects.""" + + product__brand_name = BrandColumn(verbose_name="Brand") + product__name = NameColumn(verbose_name="Name") + product__description = DescriptionColumn(verbose_name="Description") + product_cost = CostColumn(verbose_name="Product Cost") + timestamp = TimestampColumn(verbose_name="Start Time") + update = tables.TemplateColumn( + verbose_name="Update", + template_code='', + orderable=False, + ) + + def __init__(self, data, *args, **kwargs): + """Do select/prefetch_related here so it happens after django-tables2s pagination.""" + super().__init__( + data.prefetch_related("product"), + *args, + **kwargs, + ) + + class Meta: + model = PosProductCost + fields = [ + "product__brand_name", + "product__name", + "product__description", + "timestamp", + "product_cost", + ] diff --git a/src/economy/utils.py b/src/economy/utils.py index 1f8e01e91..819486fb7 100644 --- a/src/economy/utils.py +++ b/src/economy/utils.py @@ -1,8 +1,10 @@ import csv +import datetime import io +import logging import tempfile -from datetime import datetime from decimal import Decimal +from decimal import InvalidOperation from os.path import basename from pathlib import Path from zipfile import ZipFile @@ -14,6 +16,7 @@ from django.utils import timezone from psycopg2.extras import DateTimeTZRange +from camps.utils import get_closest_camp from economy.models import BankAccount from economy.models import ClearhausSettlement from economy.models import CoinifyBalance @@ -23,6 +26,10 @@ from economy.models import Expense from economy.models import MobilePayTransaction from economy.models import Pos +from economy.models import PosProduct +from economy.models import PosProductCost +from economy.models import PosSale +from economy.models import PosTransaction from economy.models import Reimbursement from economy.models import Revenue from economy.models import ZettleBalance @@ -34,6 +41,7 @@ # we need the Danish timezone here and there cph = pytz.timezone("Europe/Copenhagen") +logger = logging.getLogger("bornhack.%s" % __name__) def import_epay_csv(csvreader): @@ -59,14 +67,14 @@ def import_epay_csv(csvreader): auth_amount=Decimal(row[4]), currency=row[16], auth_date=timezone.make_aware( - datetime.strptime(row[6], "%d-%m-%Y %H:%M"), + datetime.datetime.strptime(row[6], "%d-%m-%Y %H:%M"), timezone=cph, ), description=row[11], card_type=row[13], captured_amount=Decimal(row[19]), captured_date=timezone.make_aware( - datetime.strptime(row[21], "%d-%m-%Y %H:%M"), + datetime.datetime.strptime(row[21], "%d-%m-%Y %H:%M"), timezone=cph, ), transaction_fee=row[24], @@ -101,8 +109,8 @@ def import_coinify_invoice_csv(csvreader): coinify_id=row[0], coinify_id_alpha=row[1], coinify_created=timezone.make_aware( - datetime.strptime(row[2], "%Y-%m-%d %H:%M:%S"), - timezone=timezone.utc, + datetime.datetime.strptime(row[2], "%Y-%m-%d %H:%M:%S"), + timezone=datetime.timezone.utc, ), payment_amount=Decimal(row[3]), payment_currency=row[4], @@ -140,8 +148,8 @@ def import_coinify_payout_csv(csvreader): ci, created = CoinifyPayout.objects.get_or_create( coinify_id=row[0], coinify_created=timezone.make_aware( - datetime.strptime(row[1], "%Y-%m-%d %H:%M:%S"), - timezone=timezone.utc, + datetime.datetime.strptime(row[1], "%Y-%m-%d %H:%M:%S"), + timezone=datetime.timezone.utc, ), amount=Decimal(row[2]), fee=Decimal(row[3]), @@ -1179,3 +1187,138 @@ def create_archive(self, workdir): # generate an absolute path fullpath = Path(settings.BASE_DIR) / "static_src" / filepath zh.write(fullpath, f"{subdir}/{basename(fullpath)}") + + +def import_pos_sales_json(transactions): + """Importer for sales details from the Pos system. + + Expects a list of dicts like so: + + {'_id': 'eKCFbcn6fvi5eaTJJ', 'userId': 'Y7NgNzTJRxPKupCv5', 'locationId': 'bTasxE2YYXZh35wtQ', 'currency': 'HAX', 'country': 'DK', 'amount': 55, 'timestamp': {'$date': '2021-08-23T18:11:05.379Z'}, 'products': [{'_id': '7oCSE2xt6szw5cZYQ', 'createdAt': {'$date': '2021-08-21T13:41:32.073Z'}, 'brandName': 'Gamma', 'name': 'Tap: Bando', 'description': 'IPA', 'salePrice': '35', 'unitSize': '40', 'sizeUnit': 'cl', 'abv': '6.5', 'tags': ['beer', 'tap'], 'shopPrices': [{'buyPrice': 17.5, 'timestamp': {'$date': '2021-08-21T13:41:32.073Z'}}], 'locationIds': ['bTasxE2YYXZh35wtQ'], 'updatedAt': {'$date': '2021-08-23T11:27:05.459Z'}, 'tap': '1'}, {'_id': 'Z4ZxsPPEDfDbTLHsz', 'createdAt': {'$date': '2021-08-21T13:27:25.47Z'}, 'brandName': 'Vestfyen', 'name': 'Tap: Pilsner', 'description': '', 'salePrice': '20', 'unitSize': '40', 'sizeUnit': 'cl', 'abv': '4.6', 'tags': ['tap', 'beer'], 'shopPrices': [{'buyPrice': 8.65, 'timestamp': {'$date': '2021-08-21T13:27:25.471Z'}}], 'tap': '2', 'updatedAt': {'$date': '2021-08-23T14:46:27.259Z'}, 'locationIds': ['bTasxE2YYXZh35wtQ']}]} + + """ + new_transactions = 0 + new_products = 0 + new_sales = 0 + new_costs = 0 + # loop over transactions + logger.info(f"Importing {len(transactions)} transactions...") + for tx in transactions: + # parse timestamp + try: + # with ms + timestamp = datetime.datetime.strptime( + tx["timestamp"]["$date"], + "%Y-%m-%dT%H:%M:%S.%fZ", + ).replace(tzinfo=datetime.timezone.utc) + except ValueError: + # without ms + timestamp = datetime.datetime.strptime( + tx["timestamp"]["$date"], + "%Y-%m-%dT%H:%M:%SZ", + ).replace(tzinfo=datetime.timezone.utc) + # find the Pos related to the camp during which the transaction happened + camp = get_closest_camp(timestamp) + pos = Pos.objects.get( + external_id=tx["locationId"], + team__camp=camp, + ) + + # create or get the transaction + transaction, created = PosTransaction.objects.get_or_create( + external_transaction_id=tx["_id"], + defaults={ + "pos": pos, + "external_user_id": tx.get("userId", ""), + "timestamp": timestamp, + }, + ) + if not created: + continue + new_transactions += 1 + logger.debug( + f"Found new transaction with txid {transaction.external_transaction_id} as PosTransaction {transaction.pk} - importing products and sales...", + ) + # loop over each sale in the transaction + for sale in tx["products"]: + if sale["salePrice"] == 0: + # skip sales where the sales_price is 0, these are typically pre-sold special + # event sales like for birthdays and weddings + continue + # get abv when possible + try: + abv = Decimal(str(sale.get("abv", 0))) + except (ValueError, InvalidOperation): + # handle stuff like 'abv': {'$numberDouble': 'NaN'} + abv = 0 + # get tags (sometimes a list and sometimes a comma seperated string, we want the latter) + tags = sale.get("tags", []) + if isinstance(tags, list): + tags = ",".join(tags) + # create or update the PosProduct + product, created = PosProduct.objects.update_or_create( + external_id=sale["_id"], + defaults={ + "brand_name": sale["brandName"], + "name": sale["name"], + "description": sale.get("description", ""), + "sales_price": int(sale["salePrice"]), + "unit_size": Decimal(sale["unitSize"]), + "size_unit": sale["sizeUnit"], + "abv": abv, + "tags": tags, + }, + ) + if created: + logger.debug( + f"Created new product {product.external_id} as PosProduct {product.pk}: {product.brand_name} - {product.name}", + ) + new_products += 1 + # create PosProductCost objects + if "shopPrices" in sale: + for cost in sale["shopPrices"]: + # parse timestamp + try: + # with ms + timestamp = datetime.datetime.strptime( + cost["timestamp"]["$date"], + "%Y-%m-%dT%H:%M:%S.%fZ", + ).replace(tzinfo=datetime.timezone.utc) + except ValueError: + # without ms + timestamp = datetime.datetime.strptime( + cost["timestamp"]["$date"], + "%Y-%m-%dT%H:%M:%SZ", + ).replace(tzinfo=datetime.timezone.utc) + camp = get_closest_camp(timestamp) + # parse price + try: + price = Decimal(str(round(cost["buyPrice"], 2))) + except (ValueError, InvalidOperation, TypeError): + # skip stuff like 'abv': {'$numberDouble': 'NaN'} + continue + # create cost as needed + cost, created = PosProductCost.objects.get_or_create( + camp=camp, + product=product, + timestamp=timestamp, + product_cost=price, + ) + if created: + logger.debug( + f"Created new PosProductCost object {cost.pk} for product {product} at price {cost.product_cost}", + ) + new_costs += 1 + + # create the PosSale object + possale = PosSale.objects.create( + transaction=transaction, + product=product, + sales_price=int(sale["salePrice"]), + ) + new_sales += 1 + logger.debug( + f"Created new PosSale {possale.pk} for PosProduct {product.brand_name} - {product.name} sold for {possale.sales_price} HAX", + ) + # all done + return new_products, new_transactions, new_sales, new_costs diff --git a/src/phonebook/views.py b/src/phonebook/views.py index ab7f500a6..1a48c1623 100644 --- a/src/phonebook/views.py +++ b/src/phonebook/views.py @@ -4,7 +4,6 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import PermissionDenied from django.core.exceptions import ValidationError from django.shortcuts import redirect from django.urls import reverse diff --git a/src/requirements/production.txt b/src/requirements/production.txt index 94a8f41e0..19ec40781 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -18,6 +18,7 @@ django-jsonview==2.0.0 django-oauth-toolkit==2.3.0 django-prometheus==2.3.1 django-reversion==5.0.12 +django-tables2==2.7.0 django-taggit==5.0.1 django-wkhtmltopdf==3.4.0 future==1.0.0 diff --git a/src/shop/models.py b/src/shop/models.py index 2f8828604..8edb01387 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -476,6 +476,8 @@ def save(self, **kwargs): class ProductStatsManager(models.Manager): + """Manager used by ShopTicketStatsDetailView showing stats for a single tickettype.""" + def with_ticket_stats(self): return ( self.filter( @@ -492,10 +494,15 @@ def with_ticket_stats(self): - F("total_units_refunded"), ) .exclude(total_units_sold=0) + # calculate the profit for this product .annotate(profit=F("price") - F("cost")) + # calculate the total income for the units sold of this product .annotate(total_income=F("price") * F("total_units_sold")) + # calculate the total cost for the units sold of this product .annotate(total_cost=F("cost") * F("total_units_sold")) + # calculate the total profit for this product .annotate(total_profit=F("profit") * F("total_units_sold")) + # calculate the total number of orders with this product .annotate(paid_order_count=Count("orderproductrelation__order")) ) diff --git a/src/utils/management/commands/bootstrap_devsite.py b/src/utils/management/commands/bootstrap_devsite.py index 2bcace3d2..3529f7d3d 100644 --- a/src/utils/management/commands/bootstrap_devsite.py +++ b/src/utils/management/commands/bootstrap_devsite.py @@ -2056,7 +2056,8 @@ def create_camp_token_finds(self, camp, tokens, users): def create_camp_expenses(self, camp): self.output(f"Creating expenses for {camp}...") - ExpenseFactory.create_batch(200, camp=camp) + for team in Team.objects.filter(camp=camp): + ExpenseFactory.create_batch(10, camp=camp, responsible_team=team) def create_camp_reimbursements(self, camp): self.output(f"Creating reimbursements for {camp}...") @@ -2265,8 +2266,10 @@ def create_camp_pos(self, teams): Pos.objects.create( name="Infodesk", team=teams["info"], + external_id="HHR9izotB6HLzgT6k", ) Pos.objects.create( name="Bar", team=teams["bar"], + external_id="bTasxE2YYXZh35wtQ", ) diff --git a/src/utils/querystring.py b/src/utils/querystring.py new file mode 100644 index 000000000..b9fbd5426 --- /dev/null +++ b/src/utils/querystring.py @@ -0,0 +1,11 @@ +"""Convenience function to use the querystring templatetag from python.""" + +from django.template import RequestContext + +from utils.templatetags import querystring + + +def querystring_from_request(request, **kwargs): + """Convenience function to use the querystring templatetag from python.""" + context = RequestContext(request) + return querystring.querystring(context, **kwargs) diff --git a/src/utils/templatetags/bornhack.py b/src/utils/templatetags/bornhack.py index 362dd3938..96abfc1b3 100644 --- a/src/utils/templatetags/bornhack.py +++ b/src/utils/templatetags/bornhack.py @@ -5,8 +5,6 @@ from django import template from django.template import Context from django.template import Engine -from django.template import Template -from django.template.loader import render_to_string from django.utils.safestring import mark_safe register = template.Library() diff --git a/src/utils/templatetags/querystring.py b/src/utils/templatetags/querystring.py new file mode 100644 index 000000000..bb1dcb9a0 --- /dev/null +++ b/src/utils/templatetags/querystring.py @@ -0,0 +1,48 @@ +"""Templatetag for working with querystrings in templates. + +Taken from https://github.com/django/django/commit/e67d3580edbee1a4b58d40875293714ac3fc6937 +Remove when django 5.1 is out +""" + +from django import template +from django.template.context import RequestContext +from django.utils.itercompat import is_iterable + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def querystring( + context: RequestContext, + query_dict=None, + **kwargs, +) -> str: + """Add, remove, and change parameters of a ``QueryDict``. + + Return the result as a query string. If the ``query_dict`` argument + is not provided, default to ``request.GET``. + + For example:: + {% query_string foo=3 %} + To remove a key:: + {% query_string foo=None %} + To use with pagination:: + {% query_string page=page_obj.next_page_number %} + A custom ``QueryDict`` can also be used:: + {% query_string my_query_dict foo=3 %} + """ + if query_dict is None: + query_dict = context.request.GET + query_dict = query_dict.copy() + for key, value in kwargs.items(): + if value is None: + if key in query_dict: + del query_dict[key] + elif is_iterable(value) and not isinstance(value, str): + query_dict.setlist(key, value) + else: + query_dict[key] = value # type: ignore[assignment] + if not query_dict: + return "" + query_string = query_dict.urlencode() + return f"?{query_string}"