Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
},
"devDependencies": {
"concurrently": "^8.2.2",
"esbuild": "^0.28.0"
"esbuild": "^0.28.2"
}
}
419 changes: 201 additions & 218 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: false
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ authors = [{ name = "Adam Zahradník", email = "adam@zahradnik.xyz" }]
requires-python = ">=3.12,<4"
readme = "README.md"
dependencies = [
"django>=5.0.6,<6",
"gunicorn>=23.0.0,<24",
"django>=5.0.6,<7",
"gunicorn>=23.0.0,<27",
"django-feather>=0.3.0,<0.4",
"django-debug-toolbar>=4.4.6,<5",
"django-debug-toolbar>=4.4.6,<9",
"django-htmx>=1.17.3,<2",
"django-ipware>=7.0.1,<8",
"django-ipware>=7.0.1,<9",
"django-widget-tweaks>=1.5.0,<2",
"sentry-sdk>=2.5.1,<3",
"django-stubs>=5.0.2,<6",
"django-stubs>=5.0.2,<7",
"psycopg[binary]>=3.1.17,<4",
"django-probes>=1.8.0",
]
Expand Down
12 changes: 11 additions & 1 deletion sortiment/store/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@
from sortiment.store.models import Reset


class ProductEANInline(admin.TabularInline):
model = models.ProductEAN
extra = 1


class ProductAdmin(ModelAdmin):
list_display = (
"name",
"barcode",
"ean_list",
"price",
"is_unlimited",
"is_dummy",
Expand All @@ -17,6 +22,11 @@ class ProductAdmin(ModelAdmin):
"is_unlimited",
"is_dummy",
)
inlines = [ProductEANInline]

@admin.display(description="EAN kódy")
def ean_list(self, obj):
return ", ".join(obj.ean_codes)


class WarehouseAdmin(ModelAdmin):
Expand Down
61 changes: 59 additions & 2 deletions sortiment/store/forms.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,75 @@
import re

from django import forms
from django.core.exceptions import ValidationError
from django.db import transaction
from django.forms import DecimalField, Form, IntegerField, ModelForm, NumberInput

from .models import Product, Warehouse, WarehouseState
from .models import Product, ProductEAN, Warehouse, WarehouseState

EAN_SPLIT_RE = re.compile(r"[\s,]+")


class ProductForm(ModelForm):
eans = forms.CharField(
label="EAN kódy",
help_text="Zadaj jeden alebo viac EAN kódov, každý na samostatný riadok.",
widget=forms.Textarea(attrs={"rows": 3, "placeholder": "Jeden EAN na riadok"}),
)

class Meta:
model = Product
fields = ["name", "barcode", "image", "is_unlimited", "tags", "price"]
fields = ["name", "image", "is_unlimited", "tags", "price"]
widgets = {
"price": NumberInput(attrs={"min": 0, "step": 0.01}),
}

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.fields["eans"].initial = "\n".join(self.instance.ean_codes)

def clean_eans(self):
raw = self.cleaned_data["eans"]
codes = [code for code in EAN_SPLIT_RE.split(raw) if code]
if not codes:
raise ValidationError("Zadaj aspoň jeden EAN kód.")

seen = set()
for code in codes:
if len(code) > 32:
raise ValidationError(
f"EAN kód {code} je príliš dlhý (max. 32 znakov)."
)
if code in seen:
raise ValidationError(f"EAN kód {code} je zadaný viackrát.")
seen.add(code)

existing = ProductEAN.objects.filter(code__in=codes)
if self.instance.pk:
existing = existing.exclude(product=self.instance)
if existing.exists():
code = existing.first().code
raise ValidationError(f"EAN kód {code} už patrí inému produktu.")

return codes

@transaction.atomic
def save(self, commit=True):
product = super().save(commit=commit)
if commit:
codes = self.cleaned_data["eans"]
product.eans.exclude(code__in=codes).delete()
existing = set(product.eans.values_list("code", flat=True))
ProductEAN.objects.bulk_create(
[
ProductEAN(product=product, code=code)
for code in codes
if code not in existing
]
)
return product


class DiscardForm(Form):
quantity = IntegerField(min_value=0, label="Počet kusov")
Expand Down
72 changes: 72 additions & 0 deletions sortiment/store/migrations/0016_product_eans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Generated by Django 5.2.14 on 2026-10-22 16:45

import django.db.models.deletion
from django.db import migrations, models


def copy_barcodes(apps, schema_editor):
Product = apps.get_model("store", "Product")
ProductEAN = apps.get_model("store", "ProductEAN")

ProductEAN.objects.bulk_create(
[
ProductEAN(product_id=product.id, code=product.barcode)
for product in Product.objects.exclude(barcode="")
]
)


def restore_barcodes(apps, schema_editor):
Product = apps.get_model("store", "Product")
ProductEAN = apps.get_model("store", "ProductEAN")

seen_products = set()
for ean in ProductEAN.objects.order_by("id"):
if ean.product_id in seen_products:
continue
seen_products.add(ean.product_id)
Product.objects.filter(id=ean.product_id).update(barcode=ean.code)


class Migration(migrations.Migration):
dependencies = [
("store", "0015_auto_20240703_2338"),
]

operations = [
migrations.CreateModel(
name="ProductEAN",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"code",
models.CharField(max_length=32, unique=True, verbose_name="EAN"),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="eans",
to="store.product",
verbose_name="produkt",
),
),
],
options={
"ordering": ["id"],
},
),
migrations.RunPython(copy_barcodes, restore_barcodes),
migrations.RemoveField(
model_name="product",
name="barcode",
),
]
26 changes: 23 additions & 3 deletions sortiment/store/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ def __str__(self):
class Product(models.Model):
id: int
name = models.CharField(max_length=128, verbose_name="názov")
barcode = models.CharField(max_length=32, unique=True, verbose_name="čiarový kód")
image = models.FileField(null=True, verbose_name="obrázok")
price = models.DecimalField(
max_digits=6, decimal_places=2, verbose_name="predajná cena"
Expand All @@ -64,18 +63,39 @@ class Product(models.Model):
def __str__(self):
return f"{self.name} ({self.price} €)"

@property
def ean_codes(self):
return [ean.code for ean in self.eans.all()]

@staticmethod
def generate_one_time_product(price, barcode):
product, created = Product.objects.get_or_create(
ean = ProductEAN.objects.filter(code=barcode).first()
if ean:
return ean.product

product = Product.objects.create(
price=price,
name="Jednorazová položka",
is_unlimited=True,
barcode=barcode,
is_dummy=True,
)
ProductEAN.objects.create(product=product, code=barcode)
return product


class ProductEAN(models.Model):
product = models.ForeignKey(
Product, on_delete=models.CASCADE, related_name="eans", verbose_name="produkt"
)
code = models.CharField(max_length=32, unique=True, verbose_name="EAN")

class Meta:
ordering = ["id"]

def __str__(self):
return self.code


class WarehouseState(models.Model):
warehouse = models.ForeignKey(
Warehouse, on_delete=models.CASCADE, verbose_name="sklad"
Expand Down
2 changes: 1 addition & 1 deletion sortiment/store/templates/products/_search.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{% load icon %}
{% if not product %}
<form action="{% url 'store:product_search' %}" method="get" autocomplete="off" data-turbo-frame="product-search">
<input type="search" class="input dark:bg-gray-900 dark:border-gray-700" name="q" placeholder="Zadaj čiarový kód / názov produktu..." autofocus />
<input type="search" class="input dark:bg-gray-900 dark:border-gray-700" name="q" placeholder="Zadaj EAN / názov produktu..." autofocus />

<turbo-frame id="product-search" target="_top">
<div class="mt-4 bg-gray-50 dark:bg-gray-950 p-2 rounded-md space-y-2">
Expand Down
2 changes: 1 addition & 1 deletion sortiment/store/templates/store/_products_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 p-6">
{% for p in products %}
<a class="hover:bg-gray-50 dark:hover:bg-gray-800 p-2 rounded-lg cursor-pointer {% if not p.product.is_unlimited and p.local_quantity <= 0 %} opacity-30 grayscale hover:opacity-100 {% endif %}" role="button" href="{% url 'store:cart_add' p.product.id %}" data-turbo-method="post" data-turbo-frame="turbo-cart" {% if forloop.first %}data-barcode-target="firstProduct"{% endif %}
{% if request.GET.query == p.product.barcode %}data-exact-match="1"{% endif %}>
{% if request.GET.query in p.product.ean_codes %}data-exact-match="1"{% endif %}>
{% include "store/_item_image.html" with product=p.product %}
<h2 class="text-lg font-bold mt-1">{{ p.product.name }}</h2>

Expand Down
4 changes: 2 additions & 2 deletions sortiment/store/templates/store/inventory.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ <h1 class="font-bold text-2xl">Inventúra</h1>
<thead class="border-b-2 border-gray-500 dark:border-gray-600">
<tr class="bg-gray-200 dark:bg-gray-800 text-left">
<th class="p-2">Produkt</th>
<th class="p-2">Čiarový kód</th>
<th class="p-2">EAN kódy</th>
{% for wh in warehouses %}
<th class="p-2 text-center">{{ wh.name }}</th>
{% endfor %}
Expand All @@ -35,7 +35,7 @@ <h1 class="font-bold text-2xl">Inventúra</h1>
</div>
</div>
</td>
<td class="p-2 tabular-nums">{{ product.barcode }}</td>
<td class="p-2 tabular-nums">{{ product.eans|join:", " }}</td>
{% for whStock in product.stock %}
<td class="p-2 text-center">{{ whStock }}</td>
{% endfor %}
Expand Down
2 changes: 1 addition & 1 deletion sortiment/store/templates/store/products.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<form action="{% url "store:product_list" %}" autocomplete="off"
data-barcode-target="form" data-turbo-stream data-turbo-frame="turbo-products">
<input type="search" class="input dark:bg-gray-700 dark:border-0 dark:text-gray-100" name="query"
placeholder="Načítaj čiarový kód alebo vyhladávaj podľa názvu (pridaj prvé do košíka s ENTERom)"
placeholder="Načítaj EAN alebo vyhľadávaj podľa názvu (pridaj prvé do košíka s ENTERom)"
value="{{ request.GET.query }}"
data-barcode-target="field"
data-action="input->barcode#search keydown.enter->barcode#select" autofocus>
Expand Down
12 changes: 7 additions & 5 deletions sortiment/store/views/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,10 @@ class InventoryView(StaffRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
warehouses = Warehouse.objects.all()
products = Product.objects.filter(is_dummy=False, is_unlimited=False).order_by(
"name"
products = (
Product.objects.filter(is_dummy=False, is_unlimited=False)
.order_by("name")
.prefetch_related("eans")
)
states = WarehouseState.objects.filter(product__in=products).all()
state_map = defaultdict(lambda: 0)
Expand All @@ -226,7 +228,7 @@ def get_context_data(self, **kwargs):
rows.append(
{
"name": p.name,
"barcode": p.barcode,
"eans": p.ean_codes,
"price": p.price,
"stock": stock,
"total": total,
Expand Down Expand Up @@ -254,8 +256,8 @@ def get_context_data(self, **kwargs):
query = self.request.GET.get("q")
if query:
ctx["products"] = Product.objects.filter(
Q(barcode=query) | Q(name__unaccent__icontains=query)
)[0:10]
Q(eans__code=query) | Q(name__unaccent__icontains=query)
).distinct()[0:10]
ctx["query"] = query
return ctx

Expand Down
10 changes: 5 additions & 5 deletions sortiment/store/views/storefront.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,22 @@ class ProductListView(LoginRequiredMixin, CartContext, TemplateView):
template_name = "store/products.html"

def get_queryset(self):
products = Product.objects.filter(is_dummy=False)
products = Product.objects.filter(is_dummy=False).prefetch_related("eans")

query = self.request.GET.get("query")
if query:
exact = Product.objects.filter(barcode=query)
exact = Product.objects.filter(eans__code=query).prefetch_related("eans")
if exact.exists():
return exact

dummy_price = get_dummy_barcode_data(query)
if dummy_price:
dummy_obj = Product.generate_one_time_product(dummy_price, query)
return Product.objects.filter(id=dummy_obj.id)
return Product.objects.filter(id=dummy_obj.id).prefetch_related("eans")

products = products.filter(
Q(name__unaccent__icontains=query) | Q(barcode__startswith=query)
)
Q(name__unaccent__icontains=query) | Q(eans__code__startswith=query)
).distinct()

# only show products that have active tag
tag = self.request.GET.get("tag")
Expand Down
Loading
Loading