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 %}
+ {{ column }} |
+ {% endfor %}
+
+
+ {% for row in rows %}
+
+ {% for cell in row %}
+ {{ cell }} |
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+
+
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"