diff --git a/intbot/core/analysis/products.py b/intbot/core/analysis/products.py new file mode 100644 index 0000000..e7acaa4 --- /dev/null +++ b/intbot/core/analysis/products.py @@ -0,0 +1,119 @@ +""" +Parse Products from PretixData for further joins and analysis in other places. +""" + +from decimal import Decimal +from typing import ClassVar, Iterable + +import polars as pl +from core.models import PretixData +from pydantic import BaseModel, model_validator + + +class LocalisedFieldsMixin: + # Marking as ClassVar here is important. It doens't work without it :) + _localised_fields: ClassVar[Iterable[str]] = () + + @model_validator(mode="before") + @classmethod + def extract(cls, values): + for field in cls._localised_fields: + if isinstance(values[field], dict) and "en" in values[field]: + values[field] = values[field]["en"] + continue + + return values + + +class ProductVariation(LocalisedFieldsMixin, BaseModel): + id: int + value: str + description: str | dict + price: Decimal + + _localised_fields = ["value", "description"] + + +class Product(LocalisedFieldsMixin, BaseModel): + id: int + name: str + description: str | dict + variations: list[ProductVariation] + default_price: Decimal | None = None + + _localised_fields = ["name", "description"] + + +class FlatProductDescription(BaseModel): + product_id: int + variation_id: int | None + product_name: str + type: str + variant: str + price: Decimal | None + + +def get_latest_products_data() -> PretixData: + qs = PretixData.objects.filter(resource=PretixData.PretixResources.products) + return qs.latest("created_at") + + +def parse_latest_products_to_objects(pretix_data: PretixData) -> list[Product]: + data = pretix_data.content + products = [Product.model_validate(entry) for entry in data] + return products + + +def flat_product_data(products: list[Product]) -> pl.DataFrame: + """ + Returns a polars data frame with flat description of available Products. + Products hold nested `ProductVariation`s; flatten them so every variation + (or base product) becomes one row in a DataFrame. + """ + rows = [] + for p in products: + if p.variations: + for v in p.variations: + if "Late" in v.value: + type_ = "Late" + name = v.value.replace("Late", "").strip() + else: + type_ = "Regular" + name = v.value + + if "Combined" in name: + name = "Combined" + + rows.append( + FlatProductDescription( + product_id=p.id, + variation_id=v.id, + product_name=p.name, + type=type_, + variant=name, + price=v.price, + ) + ) + else: + rows.append( + FlatProductDescription( + product_id=p.id, + variation_id=None, + product_name=p.name, + variant=p.name, + type="Late" if "Late" in p.name else "Regular", + price=p.default_price, + ) + ) + + return pl.DataFrame(rows) + + +def latest_flat_product_data() -> pl.DataFrame: + """ + Thin wrapper on getting latest information from the database, and + converting into a polars data frame + """ + pretix_data = get_latest_products_data() + products = parse_latest_products_to_objects(pretix_data) + return flat_product_data(products) diff --git a/intbot/core/bot/main.py b/intbot/core/bot/main.py index 3bc7e7f..fb64c08 100644 --- a/intbot/core/bot/main.py +++ b/intbot/core/bot/main.py @@ -1,4 +1,6 @@ import discord +from asgiref.sync import sync_to_async +from core.analysis.products import latest_flat_product_data from core.models import DiscordMessage, InboxItem from discord.ext import commands, tasks from django.conf import settings @@ -199,6 +201,25 @@ async def until(ctx): await ctx.send(f"{delta.days} days left until the conference") +@bot.command() +@commands.has_role("EP2025") +async def products(ctx): + """ + Returns pretix Products but only if called by a EP2025 volunteer on a test channel. + + This is an example of implementation of a command that has some basic + access control (in this case works only on certain channels). + """ + + if ctx.channel.id != settings.DISCORD_TEST_CHANNEL_ID: + await ctx.send("This command only works on certain channels") + return + + data = await sync_to_async(latest_flat_product_data)() + + await ctx.send(f"```{str(data)}```") + + def run_bot(): bot_token = settings.DISCORD_BOT_TOKEN bot.run(bot_token) diff --git a/intbot/core/views.py b/intbot/core/views.py index 0dbfea5..9a30308 100644 --- a/intbot/core/views.py +++ b/intbot/core/views.py @@ -1,4 +1,6 @@ +from core.analysis.products import latest_flat_product_data from django.conf import settings +from django.contrib.auth.decorators import login_required from django.template.response import TemplateResponse from django.utils import timezone @@ -6,4 +8,32 @@ def days_until(request): delta = settings.CONFERENCE_START - timezone.now() - return TemplateResponse(request, "days_until.html", {"days_until": delta.days}) + return TemplateResponse( + request, + "days_until.html", + { + "days_until": delta.days, + }, + ) + + +@login_required +def products(request): + """ + For now this is just an example of the implementation. + + Table with products is not that useful, but it's the easiest one to + implement first as proof of concept + """ + df = latest_flat_product_data() + columns = df.columns + rows = df.rows() + + return TemplateResponse( + request, + "table.html", + { + "columns": columns, + "rows": rows, + }, + ) diff --git a/intbot/intbot/urls.py b/intbot/intbot/urls.py index 19237fe..ec71fc9 100644 --- a/intbot/intbot/urls.py +++ b/intbot/intbot/urls.py @@ -4,7 +4,7 @@ internal_webhook_endpoint, zammad_webhook_endpoint, ) -from core.views import days_until +from core.views import days_until, products from django.contrib import admin from django.urls import path @@ -17,4 +17,5 @@ path("webhook/zammad/", zammad_webhook_endpoint), # Public Pages path("days-until/", days_until), + path("products/", products), ] diff --git a/intbot/templates/table.html b/intbot/templates/table.html new file mode 100644 index 0000000..9245e51 --- /dev/null +++ b/intbot/templates/table.html @@ -0,0 +1,36 @@ + + + + +
+ +

Flat Products Table

+

This is not particularly useful now, but it's here as a proof of concept :)

+ + + + {% for column in columns %} + + {% endfor %} + + + {% for row in rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} +
{{ column }}
{{ cell }}
+ +
+ + diff --git a/intbot/tests/test_analysis/test_products.py b/intbot/tests/test_analysis/test_products.py new file mode 100644 index 0000000..95f0f2c --- /dev/null +++ b/intbot/tests/test_analysis/test_products.py @@ -0,0 +1,275 @@ +from decimal import Decimal + +import polars as pl +import pytest +from core.analysis.products import ( + FlatProductDescription, + Product, + ProductVariation, + flat_product_data, + latest_flat_product_data, +) +from core.models import PretixData +from polars.testing import assert_frame_equal + + +def test_flat_product_data(): + products = [ + Product( + id=100, + name="Business", + description="Business ticket", + variations=[ + ProductVariation(id=1, value="Combined", description="", price=800), + ProductVariation(id=2, value="Conference", description="", price=500), + ProductVariation(id=3, value="Tutorial", description="", price=400), + ], + ), + Product( + id=200, + name="Personal", + description="Personal ticket", + variations=[ + ProductVariation(id=4, value="Combined", description="", price=400), + ProductVariation(id=5, value="Conference", description="", price=300), + ProductVariation(id=6, value="Tutorial", description="", price=200), + ], + ), + ] + + df = flat_product_data(products) + + assert df.shape == (6, 6) + assert df.schema == pl.Schema( + { + "product_id": pl.Int64, + "variation_id": pl.Int64, + "product_name": pl.String, + "type": pl.String, + "variant": pl.String, + "price": pl.Decimal(precision=None, scale=0), + } + ) + + +@pytest.mark.django_db +def test_latest_flat_product_data(): + """ + Bigger integrated tests going through everything from getting data from the + database to returning a polars dataframe + """ + # NOTE: Real data from pretix contains more details, this is abbreviated + # just for the necessary data we need for parsing. + PretixData.objects.create( + resource=PretixData.PretixResources.products, + content=[ + { + "id": 100, + "category": 2000, + "name": {"en": "Business"}, + "description": { + "en": "If your company pays for you to attend, or if you use Python professionally. When you purchase a Business Ticket, you help us keep the conference affordable for everyone. \r\nThank you!" + }, + "default_price": "500.00", + "admission": True, + "variations": [ + { + "id": 1, + "value": {"en": "Conference"}, + "active": True, + "description": { + "en": "Access to Conference Days & Sprint Weekend (16-20 July). Tutorials (14-15 July) are **NOT** included. To access Tutorial days please buy a Tutorial or Combined ticket.\r\n\r\n**Net price \u20ac500.00 + 21% Czech VAT**. \r\n\r\nAvailable until sold out or 27 June." + }, + "default_price": "605.00", + "price": "605.00", + }, + { + "id": 2, + "value": {"en": "Tutorials"}, + "active": True, + "description": { + "en": "Access to Workshop/Tutorial Days (14-15 July) and the Sprint Weekend (19-20 July), but **NOT** the main conference (16-18 July). \r\n**Net price \u20ac400.00+ 21% Czech VAT.**\r\n\r\nTutorial tickets are only available until 27 June" + }, + "default_price": "484.00", + "price": "484.00", + }, + { + "id": 3, + "value": {"en": "Combined (Conference + Tutorials)"}, + "active": True, + "description": { + "en": "Access to everything during the whole seven-day event (14-20 July).\r\n**Net price \u20ac800.00 + 21% Czech VAT.**\r\n\r\nAvailable until sold out or 27 June." + }, + "default_price": "968.00", + "price": "968.00", + }, + { + "id": 4, + "value": {"en": "Late Conference"}, + "active": True, + "description": { + "en": "Access to Conference Days & Sprint Weekend (16-20 July) & limited access to specific sponsored/special workshops during the Workshop/Tutorial Days (14-15 July).\r\n**Net price \u20ac750.00 + 21% Czech VAT**\r\n\r\nAvailable from 27 June or after regular Conference tickets are sold out." + }, + "default_price": "907.50", + "price": "907.50", + }, + { + "id": 5, + "value": {"en": "Late Combined"}, + "active": True, + "description": { + "en": "Access to everything during the whole seven-day event (14-20 July).\r\n**Net price \u20ac1,200.00 + 21% Czech VAT.**\r\n\r\nAvailable from 27 June or after regular Combined tickets are sold out." + }, + "default_price": "1452.00", + "price": "1452.00", + }, + ], + }, + { + "id": 200, + "category": 2000, + "name": {"en": "Personal"}, + "active": True, + "description": { + "en": "If you enjoy Python as a hobbyist or use it as a freelancer." + }, + "default_price": "300.00", + "variations": [ + { + "id": 6, + "value": {"en": "Conference"}, + "description": { + "en": "Access to Conference Days & Sprint Weekend (16-20 July). Tutorials (14-15 July) are **NOT** included. \r\nTo access Tutorial days please buy a Tutorial or Combined ticket.\r\nAvailable until sold out or 27 June." + }, + "default_price": "300.00", + "price": "300.00", + }, + { + "id": 7, + "value": {"en": "Tutorials"}, + "description": { + "en": "Access to Workshop/Tutorial Days (14-15 July) and the Sprint Weekend (19-20 July), but **NOT** the main conference (16-18 July).\r\n\r\nAvailable until sold out or 27 June." + }, + "default_price": "200.00", + "price": "200.00", + }, + { + "id": 8, + "value": {"en": "Combined (Conference + Tutorials)"}, + "description": { + "en": "Access to everything during the whole seven-day event (14-20 July).\r\n\r\nAvailable until sold out or 27 June." + }, + "position": 2, + "default_price": "450.00", + "price": "450.00", + }, + { + "id": 9, + "value": {"en": "Late Conference"}, + "description": { + "en": "Access to Conference Days & Sprint Weekend (16-20 July). Tutorials (14-15 July) are NOT included. To access Tutorial days please buy a Tutorial or Combined ticket.\r\n\r\nAvailable from 27 June or after regular Conference tickets are sold out." + }, + "default_price": "450.00", + "price": "450.00", + }, + { + "id": 10, + "value": {"en": "Late Combined"}, + "description": { + "en": "Access to everything during the whole seven-day event (14-20 July).\r\n\r\nAvailable from 27 June or after regular Combined tickets are sold out." + }, + "default_price": "675.00", + "price": "675.00", + }, + ], + }, + ], + ) + expected = pl.DataFrame( + [ + FlatProductDescription( + product_id=100, + variation_id=1, + product_name="Business", + variant="Conference", + type="Regular", + price=Decimal("605.00"), + ), + FlatProductDescription( + product_id=100, + variation_id=2, + product_name="Business", + variant="Tutorials", + type="Regular", + price=Decimal("484.00"), + ), + FlatProductDescription( + product_id=100, + variation_id=3, + product_name="Business", + variant="Combined", + type="Regular", + price=Decimal("968.00"), + ), + FlatProductDescription( + product_id=100, + variation_id=4, + product_name="Business", + variant="Conference", + type="Late", + price=Decimal("907.50"), + ), + FlatProductDescription( + product_id=100, + variation_id=5, + product_name="Business", + variant="Combined", + type="Late", + price=Decimal("1452.00"), + ), + FlatProductDescription( + product_id=200, + variation_id=6, + product_name="Personal", + variant="Conference", + type="Regular", + price=Decimal("300.00"), + ), + FlatProductDescription( + product_id=200, + variation_id=7, + product_name="Personal", + variant="Tutorials", + type="Regular", + price=Decimal("200.00"), + ), + FlatProductDescription( + product_id=200, + variation_id=8, + product_name="Personal", + variant="Combined", + type="Regular", + price=Decimal("450.00"), + ), + FlatProductDescription( + product_id=200, + variation_id=9, + product_name="Personal", + variant="Conference", + type="Late", + price=Decimal("450.00"), + ), + FlatProductDescription( + product_id=200, + variation_id=10, + product_name="Personal", + variant="Combined", + type="Late", + price=Decimal("675.00"), + ), + ] + ) + + df = latest_flat_product_data() + + assert_frame_equal(df, expected) diff --git a/pyproject.toml b/pyproject.toml index 0e8e151..03b0b74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "pydantic>=2.10.6", "freezegun>=1.5.1", "ipython>=9.1.0", + "polars>=1.27.1", ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index 9c8553d..4176551 100644 --- a/uv.lock +++ b/uv.lock @@ -371,6 +371,7 @@ dependencies = [ { name = "ipython" }, { name = "mypy" }, { name = "pdbpp" }, + { name = "polars" }, { name = "psycopg" }, { name = "pydantic" }, { name = "pytest" }, @@ -396,6 +397,7 @@ requires-dist = [ { name = "ipython", specifier = ">=9.1.0" }, { name = "mypy", specifier = ">=1.14.1" }, { name = "pdbpp", specifier = ">=0.10.3" }, + { name = "polars", specifier = ">=1.27.1" }, { name = "psycopg", specifier = ">=3.2.3" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pytest", specifier = ">=8.3.4" }, @@ -570,6 +572,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "polars" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/96/56ab877d3d690bd8e67f5c6aabfd3aa8bc7c33ee901767905f564a6ade36/polars-1.27.1.tar.gz", hash = "sha256:94fcb0216b56cd0594aa777db1760a41ad0dfffed90d2ca446cf9294d2e97f02", size = 4555382 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/be965ca4e1372805d0d2313bb4ed8eae88804fc3bfeb6cb0a07c53191bdb/polars-1.27.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ba7ad4f8046d00dd97c1369e46a4b7e00ffcff5d38c0f847ee4b9b1bb182fb18", size = 34756840 }, + { url = "https://files.pythonhosted.org/packages/c0/1a/ae019d323e83c6e8a9b4323f3fea94e047715847dfa4c4cbaf20a6f8444e/polars-1.27.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:339e3948748ad6fa7a42e613c3fb165b497ed797e93fce1aa2cddf00fbc16cac", size = 31616000 }, + { url = "https://files.pythonhosted.org/packages/20/c1/c65924c0ca186f481c02b531f1ec66c34f9bbecc11d70246562bb4949876/polars-1.27.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f801e0d9da198eb97cfb4e8af4242b8396878ff67b655c71570b7e333102b72b", size = 35388976 }, + { url = "https://files.pythonhosted.org/packages/88/c2/37720b8794935f1e77bde439564fa421a41f5fed8111aeb7b9ca0ebafb2d/polars-1.27.1-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:4d18a29c65222451818b63cd397b2e95c20412ea0065d735a20a4a79a7b26e8a", size = 32586083 }, + { url = "https://files.pythonhosted.org/packages/41/3d/1bb108eb278c1eafb303f78c515fb71c9828944eba3fb5c0ac432b9fad28/polars-1.27.1-cp39-abi3-win_amd64.whl", hash = "sha256:a4f832cf478b282d97f8bf86eeae2df66fa1384de1c49bc61f7224a10cc6a5df", size = 35602500 }, + { url = "https://files.pythonhosted.org/packages/0f/5c/cc23daf0a228d6fadbbfc8a8c5165be33157abe5b9d72af3e127e0542857/polars-1.27.1-cp39-abi3-win_arm64.whl", hash = "sha256:4f238ee2e3c5660345cb62c0f731bbd6768362db96c058098359ecffa42c3c6c", size = 31891470 }, +] + [[package]] name = "prompt-toolkit" version = "3.0.51"