feat: create/edit product views

This commit is contained in:
2026-07-23 16:19:19 +02:00
parent 2a9407e676
commit 1d11ea9b6e
16 changed files with 574 additions and 175 deletions
+57
View File
@@ -0,0 +1,57 @@
name: CI
on:
push:
branches: [master]
defaults:
run:
shell: sh
jobs:
test:
runs-on: ubuntu-latest
container:
image: python:3.13-bookworm
steps:
- name: Checkout
run: |
git clone https://git.spookydev.com/${{ gitea.repository }}.git .
git checkout ${{ gitea.sha }}
- name: Install system dependencies
run: |
apt-get update -qq
apt-get install -y --no-install-recommends libpango-1.0-0 libpangocairo-1.0-0 fonts-dejavu-core gettext
- name: Install uv
run: pip install uv --quiet
- name: Install dependencies
run: uv pip install --system -r pyproject.toml --group=dev
- name: Compile translations
run: uv run python manage.py compilemessages
- name: Run tests
run: uv run pytest --cov
build:
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
run: |
git clone https://git.spookydev.com/${{ gitea.repository }}.git .
git checkout ${{ gitea.sha }}
- name: Build and push
run: |
docker build -t registry.spookydev.com/${{ gitea.repository }}:${{ gitea.sha }} .
docker tag registry.spookydev.com/${{ gitea.repository }}:${{ gitea.sha }} \
registry.spookydev.com/${{ gitea.repository }}:latest
docker login registry.spookydev.com \
-u ${{ secrets.REGISTRY_USER }} \
-p ${{ secrets.REGISTRY_PASSWORD }}
docker push registry.spookydev.com/${{ gitea.repository }}:${{ gitea.sha }}
docker push registry.spookydev.com/${{ gitea.repository }}:latest
-48
View File
@@ -1,48 +0,0 @@
#
name: Create and publish a Docker image
# Configures this workflow to run every time a change is pushed to the branch called `release`.
on:
push:
tags: ['*']
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
packages: write
#
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-35
View File
@@ -1,35 +0,0 @@
name: CI
on: [push]
jobs:
ci:
strategy:
fail-fast: false
matrix:
python-version: ["3.13"]
os: [ubuntu-24.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Setup dependencies
run: pip install uv
- name: Lint
run: uv venv && uv run ruff check .
- name: Run tests
env:
DJANGO_SETTINGS_MODULE: config.settings
DEBUG: True
run: uv run manage.py collectstatic && uv run pytest --cov --junitxml=junit.xml -o junit_family=legacy && uv run coverage xml
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: pablo-moreno/shoppy
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
+51
View File
@@ -63,6 +63,57 @@
document.body.addEventListener('backoffice:modal-close', function () { document.body.addEventListener('backoffice:modal-close', function () {
document.getElementById('backoffice-modal').close(); document.getElementById('backoffice-modal').close();
}); });
// Combobox de búsqueda (DaisyUI + HTMX): el <input> de texto busca vía htmx
// (ver _combobox.html); seleccionar/quitar un resultado es JS puro, sin ida
// y vuelta al servidor, igual que ya se hace para cerrar el modal.
function backofficeComboboxSelect(containerId, value, label) {
const container = document.getElementById(containerId);
const multiple = container.dataset.multiple === 'true';
const fieldName = container.dataset.fieldName;
const chips = container.querySelector('.combobox-chips');
const hiddenInputs = container.querySelector('.combobox-hidden-inputs');
const search = container.querySelector('.combobox-search');
const results = container.querySelector('.combobox-results');
if (hiddenInputs.querySelector('input[value="' + value + '"]')) {
search.value = '';
results.innerHTML = '';
return;
}
if (!multiple) {
chips.innerHTML = '';
hiddenInputs.innerHTML = '';
}
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = fieldName;
hidden.value = value;
hiddenInputs.appendChild(hidden);
const chip = document.createElement('span');
chip.className = 'badge badge-primary gap-1';
chip.dataset.value = value;
chip.appendChild(document.createTextNode(label));
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'leading-none';
removeButton.textContent = '×';
removeButton.onclick = function () { backofficeComboboxRemove(containerId, value); };
chip.appendChild(removeButton);
chips.appendChild(chip);
search.value = '';
results.innerHTML = '';
}
function backofficeComboboxRemove(containerId, value) {
const container = document.getElementById(containerId);
container.querySelector('.combobox-hidden-inputs input[value="' + value + '"]').remove();
container.querySelector('.combobox-chips [data-value="' + value + '"]').remove();
}
</script> </script>
</body> </body>
</html> </html>
@@ -6,8 +6,12 @@
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-semibold">{{ title }}</h1> <h1 class="text-2xl font-semibold">{{ title }}</h1>
{% if create_url %} {% if create_url %}
{% if create_is_page %}
<a class="btn btn-primary" href="{{ create_url }}">Añadir</a>
{% else %}
<button class="btn btn-primary" hx-get="{{ create_url }}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir</button> <button class="btn btn-primary" hx-get="{{ create_url }}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir</button>
{% endif %} {% endif %}
{% endif %}
</div> </div>
{% include 'backoffice/generic/_list_fragment.html' %} {% include 'backoffice/generic/_list_fragment.html' %}
@@ -0,0 +1,28 @@
<div id="combobox-{{ field_name }}" class="combobox" data-field-name="{{ field_name }}" data-multiple="{{ multiple|yesno:'true,false' }}">
<div class="combobox-chips flex flex-wrap gap-1 mb-2">
{% for item in selected %}
<span class="badge badge-primary gap-1" data-value="{{ item.pk }}">
{{ item }}
<button type="button" class="leading-none" onclick="backofficeComboboxRemove('combobox-{{ field_name }}', '{{ item.pk }}')">×</button>
</span>
{% endfor %}
</div>
<div class="combobox-hidden-inputs">
{% for item in selected %}
<input type="hidden" name="{{ field_name }}" value="{{ item.pk }}">
{% endfor %}
</div>
<div class="dropdown w-full">
<input
type="text"
class="input input-bordered w-full combobox-search"
placeholder="Buscar..."
autocomplete="off"
hx-get="{% url 'backoffice:product_field_search' field_name %}"
hx-trigger="keyup changed delay:300ms, focus"
hx-target="next .combobox-results"
hx-swap="innerHTML"
name="q">
<ul class="combobox-results menu dropdown-content bg-base-100 rounded-box z-10 w-full p-2 shadow-sm"></ul>
</div>
</div>
@@ -0,0 +1,9 @@
{% for result in results %}
<li>
<button type="button" onclick="backofficeComboboxSelect('combobox-{{ field_name }}', '{{ result.pk }}', '{{ result|escapejs }}')">
{{ result }}
</button>
</li>
{% empty %}
<li><span class="opacity-50 px-2 py-1">Sin resultados</span></li>
{% endfor %}
@@ -0,0 +1,14 @@
<div id="product-inline-{{ field_name }}">
<form hx-post="{{ request.path }}" hx-target="#product-inline-{{ field_name }}" hx-swap="outerHTML">
{% csrf_token %}
{% if field_name == 'brand' or field_name == 'categories' or field_name == 'tags' %}
{% include 'backoffice/products/_combobox.html' %}
{% else %}
{{ form.as_p }}
{% endif %}
<div class="flex gap-2 mt-1">
<button class="btn btn-primary btn-xs">Guardar</button>
<button type="button" class="btn btn-xs" hx-get="{{ view_url }}" hx-target="#product-inline-{{ field_name }}" hx-swap="outerHTML">Cancelar</button>
</div>
</form>
</div>
@@ -0,0 +1,26 @@
<div id="product-inline-{{ field_name }}" class="flex items-start gap-2">
{% if field_name == 'name' %}
<h1 class="text-2xl font-semibold">{{ product.name }}</h1>
{% elif field_name == 'description' %}
<p class="whitespace-pre-line {% if not product.description %}opacity-50{% endif %}">{{ product.description|default:'Sin descripción.' }}</p>
{% elif field_name == 'brand' %}
<p class="{% if not product.brand %}opacity-50{% endif %}">{{ product.brand|default:'Sin marca' }}</p>
{% elif field_name == 'categories' %}
<div class="flex flex-wrap gap-1">
{% for category in product.categories.all %}
<span class="badge badge-ghost">{{ category }}</span>
{% empty %}
<span class="opacity-50">Sin categorías</span>
{% endfor %}
</div>
{% elif field_name == 'tags' %}
<div class="flex flex-wrap gap-1">
{% for tag in product.tags.all %}
<span class="badge badge-ghost">{{ tag }}</span>
{% empty %}
<span class="opacity-50">Sin etiquetas</span>
{% endfor %}
</div>
{% endif %}
<button class="btn btn-ghost btn-xs" hx-get="{{ edit_url }}" hx-target="#product-inline-{{ field_name }}" hx-swap="outerHTML" title="Editar"></button>
</div>
@@ -5,15 +5,39 @@
hx-target="#backoffice-detail" hx-target="#backoffice-detail"
hx-swap="outerHTML"> hx-swap="outerHTML">
{% url 'backoffice:product_inline_view' product.pk 'name' as name_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'name' as name_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'description' as description_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'description' as description_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'brand' as brand_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'brand' as brand_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'categories' as categories_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'categories' as categories_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'tags' as tags_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'tags' as tags_edit_url %}
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-semibold">{{ product.name }}</h1> {% include 'backoffice/products/_inline_view.html' with field_name='name' view_url=name_view_url edit_url=name_edit_url %}
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-sm" hx-get="{% url 'backoffice:product_update' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Editar</button>
<button class="btn btn-sm btn-error" hx-get="{% url 'backoffice:product_delete' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Borrar</button> <button class="btn btn-sm btn-error" hx-get="{% url 'backoffice:product_delete' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Borrar</button>
</div> </div>
</div> </div>
<div class="stats shadow mb-6 bg-base-100"> <div class="flex gap-6 mb-6">
{% with images.first as main_image %}
<div class="shrink-0 w-64">
{% if main_image %}
<img class="w-64 h-64 object-cover rounded-box border border-base-300 shadow" src="{{ main_image.l.url }}" alt="{{ product.name }}">
{% else %}
<div class="w-64 h-64 rounded-box border border-base-300 border-dashed flex items-center justify-center text-sm opacity-50">
Sin imagen
</div>
{% endif %}
</div>
{% endwith %}
<section class="w-full">
<div class="stats shadow bg-base-100 self-start">
<div class="stat"> <div class="stat">
<div class="stat-title">SKU</div> <div class="stat-title">SKU</div>
<div class="stat-value text-lg">{{ product.sku }}</div> <div class="stat-value text-lg">{{ product.sku }}</div>
@@ -30,6 +54,32 @@
</div> </div>
</div> </div>
<div class="m-4">
{% include 'backoffice/products/_inline_view.html' with field_name='description' view_url=description_view_url edit_url=description_edit_url %}
</div>
<div class="grid grid-cols-3 gap-4 m-4">
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Marca</h3>
{% include 'backoffice/products/_inline_view.html' with field_name='brand' view_url=brand_view_url edit_url=brand_edit_url %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Categorías</h3>
{% include 'backoffice/products/_inline_view.html' with field_name='categories' view_url=categories_view_url edit_url=categories_edit_url %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Etiquetas</h3>
{% include 'backoffice/products/_inline_view.html' with field_name='tags' view_url=tags_view_url edit_url=tags_edit_url %}
</div>
</div>
</section>
</div>
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<h2 class="text-xl font-semibold">Imágenes</h2> <h2 class="text-xl font-semibold">Imágenes</h2>
<button class="btn btn-sm btn-primary" hx-get="{% url 'backoffice:product_image_create' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir imagen</button> <button class="btn btn-sm btn-primary" hx-get="{% url 'backoffice:product_image_create' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir imagen</button>
@@ -37,7 +87,10 @@
<div class="flex flex-wrap gap-4 mb-6"> <div class="flex flex-wrap gap-4 mb-6">
{% for image in images %} {% for image in images %}
<div class="relative"> <div class="relative">
<img class="w-24 h-24 object-cover rounded-box border border-base-300" src="{{ image.s.url }}" alt=""> <img class="w-24 h-24 object-cover rounded-box border {% if forloop.first %}border-primary border-2{% else %}border-base-300{% endif %}" src="{{ image.s.url }}" alt="">
{% if forloop.first %}
<span class="badge badge-primary badge-xs absolute -top-2 -left-2">Principal</span>
{% endif %}
<button class="btn btn-xs btn-error absolute -top-2 -right-2" hx-get="{% url 'backoffice:product_image_delete' image.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML"></button> <button class="btn btn-xs btn-error absolute -top-2 -right-2" hx-get="{% url 'backoffice:product_image_delete' image.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML"></button>
</div> </div>
{% empty %} {% empty %}
@@ -0,0 +1,63 @@
{% extends 'backoffice/base.html' %}
{% block title %}Nuevo producto{% endblock %}
{% block main %}
<form method="post" enctype="multipart/form-data" class="max-w-3xl">
{% csrf_token %}
<div class="mb-4">
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.name.id_for_label }}">Nombre</label>
{{ form.name }}
{{ form.name.errors }}
</div>
<div class="mb-6">
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.description.id_for_label }}">Descripción</label>
{{ form.description }}
{{ form.description.errors }}
</div>
<div class="grid grid-cols-3 gap-4 mb-6">
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Marca</h3>
{% include 'backoffice/products/_combobox.html' with field_name='brand' multiple=False %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Categorías</h3>
{% include 'backoffice/products/_combobox.html' with field_name='categories' multiple=True %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Etiquetas</h3>
{% include 'backoffice/products/_combobox.html' with field_name='tags' multiple=True %}
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4 max-w-md">
<div>
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.sku.id_for_label }}">Código de referencia</label>
{{ form.sku }}
{{ form.sku.errors }}
</div>
<div>
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.stock.id_for_label }}">Stock</label>
{{ form.stock }}
{{ form.stock.errors }}
</div>
</div>
<div class="flex gap-6 mb-6">
<label class="flex items-center gap-2">
{{ form.hidden }} Oculto
</label>
<label class="flex items-center gap-2">
{{ form.is_shipping_method }} Es forma de envío
</label>
</div>
<div class="flex gap-2">
<button class="btn btn-primary">Crear producto</button>
<a class="btn" href="{% url 'backoffice:product_list' %}">Cancelar</a>
</div>
</form>
{% endblock %}
+9 -38
View File
@@ -17,53 +17,24 @@ class TestBackofficeHtmxContract(TestCase, CreateProductsMixin):
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
self.product = self.create_product() self.product = self.create_product()
def test_htmx_get_create_form_returns_fragment_not_full_page(self): def test_product_create_is_a_full_page_not_a_modal_fragment(self):
# ProductCreateView ya no es modal: navegar a la URL de creación siempre
# devuelve la página completa, incluso si la petición lleva HX-Request.
response = self.client.get(reverse('backoffice:product_create'), HTTP_HX_REQUEST='true') response = self.client.get(reverse('backoffice:product_create'), HTTP_HX_REQUEST='true')
assert response.status_code == 200 assert response.status_code == 200
body = response.content.decode() body = response.content.decode()
assert '<html' not in body assert '<html' in body
assert 'Añadir producto' in body assert 'Nuevo producto' in body
def test_htmx_create_success_returns_204_and_triggers_modal_close_and_refresh(self): def test_product_create_redirects_to_detail_even_with_hx_request(self):
response = self.client.post( response = self.client.post(
reverse('backoffice:product_create'), reverse('backoffice:product_create'),
{ {'sku': 'HTMX-1', 'name': 'Producto htmx', 'description': '', 'stock': '0'},
'sku': 'HTMX-1',
'name': 'Producto htmx',
'description': '',
'stock': '0',
'categories': [],
'tags': [],
},
HTTP_HX_REQUEST='true', HTTP_HX_REQUEST='true',
) )
assert response.status_code == 204 product = Product.objects.get(sku='HTMX-1')
trigger = json.loads(response['HX-Trigger'])
assert trigger == {'backoffice:modal-close': True, 'backoffice:list-changed': True}
assert Product.objects.filter(sku='HTMX-1').exists()
def test_htmx_create_invalid_returns_200_with_errors_not_204(self):
response = self.client.post(
reverse('backoffice:product_create'),
{'sku': '', 'name': '', 'description': '', 'stock': '0', 'categories': [], 'tags': []},
HTTP_HX_REQUEST='true',
)
assert response.status_code == 200
assert 'HX-Trigger' not in response
def test_non_htmx_create_still_redirects(self):
response = self.client.post(
reverse('backoffice:product_create'),
{
'sku': 'PLAIN-1',
'name': 'Producto normal',
'description': '',
'stock': '0',
'categories': [],
'tags': [],
},
)
assert response.status_code == 302 assert response.status_code == 302
assert response.url == reverse('backoffice:product_detail', args=[product.pk])
assert 'HX-Trigger' not in response assert 'HX-Trigger' not in response
def test_htmx_variant_create_triggers_refresh(self): def test_htmx_variant_create_triggers_refresh(self):
+118 -6
View File
@@ -7,7 +7,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from PIL import Image from PIL import Image
from shop.models import Product, ProductImage, ProductPrice, ProductVariant, Tax from shop.models import Brand, Product, ProductCategory, ProductImage, ProductPrice, ProductVariant, Tag, Tax
from shop.tests.mixins import CreateProductsMixin from shop.tests.mixins import CreateProductsMixin
@@ -23,19 +23,40 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
assert self.product.name in response.content.decode() assert self.product.name in response.content.decode()
def test_create_product(self): def test_create_product(self):
response = self.client.get(reverse('backoffice:product_create'))
assert response.status_code == 200
response = self.client.post(
reverse('backoffice:product_create'),
{'sku': 'NEW-1', 'name': 'Producto nuevo', 'description': '', 'stock': '0'},
)
product = Product.objects.get(sku='NEW-1')
assert response.status_code == 302
assert response.url == reverse('backoffice:product_detail', args=[product.pk])
def test_create_product_with_brand_categories_and_tags_in_one_submit(self):
brand = Brand.objects.create(name='Marca X')
cat1 = ProductCategory.objects.create(name='Cat 1')
cat2 = ProductCategory.objects.create(name='Cat 2')
tag1 = Tag.objects.create(name='Tag 1')
response = self.client.post( response = self.client.post(
reverse('backoffice:product_create'), reverse('backoffice:product_create'),
{ {
'sku': 'NEW-1', 'sku': 'NEW-2',
'name': 'Producto nuevo', 'name': 'Producto completo',
'description': '', 'description': '',
'stock': '0', 'stock': '0',
'categories': [], 'brand': brand.pk,
'tags': [], 'categories': [cat1.pk, cat2.pk],
'tags': [tag1.pk],
}, },
) )
assert response.status_code == 302 assert response.status_code == 302
assert Product.objects.filter(sku='NEW-1').exists() product = Product.objects.get(sku='NEW-2')
assert product.brand == brand
assert set(product.categories.all()) == {cat1, cat2}
assert set(product.tags.all()) == {tag1}
def test_create_variant_for_product(self): def test_create_variant_for_product(self):
size_m = self.create_attribute_value('Talla', 'M') size_m = self.create_attribute_value('Talla', 'M')
@@ -104,6 +125,97 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
assert response.status_code == 302 assert response.status_code == 302
assert not ProductImage.objects.filter(pk=image.pk).exists() assert not ProductImage.objects.filter(pk=image.pk).exists()
def test_inline_view_name(self):
response = self.client.get(reverse('backoffice:product_inline_view', args=[self.product.pk, 'name']))
assert response.status_code == 200
assert self.product.name in response.content.decode()
def test_inline_edit_name_form(self):
response = self.client.get(reverse('backoffice:product_inline_edit', args=[self.product.pk, 'name']))
assert response.status_code == 200
assert 'name="name"' in response.content.decode()
assert 'description' not in response.content.decode()
def test_inline_update_name(self):
response = self.client.post(
reverse('backoffice:product_inline_edit', args=[self.product.pk, 'name']), {'name': 'Nuevo nombre'}
)
assert response.status_code == 200
self.product.refresh_from_db()
assert self.product.name == 'Nuevo nombre'
assert 'Nuevo nombre' in response.content.decode()
def test_inline_update_description(self):
response = self.client.post(
reverse('backoffice:product_inline_edit', args=[self.product.pk, 'description']),
{'description': 'Descripción actualizada'},
)
assert response.status_code == 200
self.product.refresh_from_db()
assert self.product.description == 'Descripción actualizada'
def test_inline_update_brand(self):
brand = Brand.objects.create(name='Marca X')
response = self.client.post(
reverse('backoffice:product_inline_edit', args=[self.product.pk, 'brand']), {'brand': brand.pk}
)
assert response.status_code == 200
self.product.refresh_from_db()
assert self.product.brand == brand
assert 'Marca X' in response.content.decode()
def test_inline_update_categories_accepts_multiple(self):
cat1 = ProductCategory.objects.create(name='Cat 1')
cat2 = ProductCategory.objects.create(name='Cat 2')
response = self.client.post(
reverse('backoffice:product_inline_edit', args=[self.product.pk, 'categories']),
{'categories': [cat1.pk, cat2.pk]},
)
assert response.status_code == 200
self.product.refresh_from_db()
assert set(self.product.categories.all()) == {cat1, cat2}
def test_inline_update_tags_accepts_multiple(self):
tag1 = Tag.objects.create(name='Tag 1')
tag2 = Tag.objects.create(name='Tag 2')
response = self.client.post(
reverse('backoffice:product_inline_edit', args=[self.product.pk, 'tags']), {'tags': [tag1.pk, tag2.pk]}
)
assert response.status_code == 200
self.product.refresh_from_db()
assert set(self.product.tags.all()) == {tag1, tag2}
def test_inline_edit_rejects_arbitrary_field(self):
response = self.client.get(reverse('backoffice:product_inline_edit', args=[self.product.pk, 'sku']))
assert response.status_code == 404
def test_field_search_filters_by_query(self):
Brand.objects.create(name='Acme')
Brand.objects.create(name='Globex')
response = self.client.get(reverse('backoffice:product_field_search', args=['brand']), {'q': 'Acm'})
assert response.status_code == 200
body = response.content.decode()
assert 'Acme' in body
assert 'Globex' not in body
def test_field_search_without_query_returns_all(self):
Brand.objects.create(name='Acme')
Brand.objects.create(name='Globex')
response = self.client.get(reverse('backoffice:product_field_search', args=['brand']))
assert response.status_code == 200
body = response.content.decode()
assert 'Acme' in body
assert 'Globex' in body
def test_field_search_rejects_arbitrary_field(self):
response = self.client.get(reverse('backoffice:product_field_search', args=['sku']))
assert response.status_code == 404
def test_delete_batch_reduces_stock(self): def test_delete_batch_reduces_stock(self):
self.product.stock = Decimal('10') self.product.stock = Decimal('10')
self.product.save() self.product.save()
+15 -1
View File
@@ -6,8 +6,22 @@ urlpatterns = [
path('', products.ProductListView.as_view(), name='product_list'), path('', products.ProductListView.as_view(), name='product_list'),
path('create/', products.ProductCreateView.as_view(), name='product_create'), path('create/', products.ProductCreateView.as_view(), name='product_create'),
path('<int:pk>/', products.ProductDetailView.as_view(), name='product_detail'), path('<int:pk>/', products.ProductDetailView.as_view(), name='product_detail'),
path('<int:pk>/edit/', products.ProductUpdateView.as_view(), name='product_update'),
path('<int:pk>/delete/', products.ProductDeleteView.as_view(), name='product_delete'), path('<int:pk>/delete/', products.ProductDeleteView.as_view(), name='product_delete'),
path(
'<int:pk>/inline/<str:field_name>/',
products.ProductInlineViewFragment.as_view(),
name='product_inline_view',
),
path(
'<int:pk>/inline/<str:field_name>/edit/',
products.ProductInlineEditView.as_view(),
name='product_inline_edit',
),
path(
'inline-search/<str:field_name>/',
products.ProductFieldSearchView.as_view(),
name='product_field_search',
),
path('<int:product_pk>/prices/create/', products.ProductPriceCreateView.as_view(), name='product_price_create'), path('<int:product_pk>/prices/create/', products.ProductPriceCreateView.as_view(), name='product_price_create'),
path( path(
'<int:product_pk>/variants/create/', '<int:product_pk>/variants/create/',
+106 -29
View File
@@ -1,7 +1,8 @@
from django.http import HttpResponse, HttpResponseRedirect from django.forms import modelform_factory
from django.shortcuts import get_object_or_404 from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.urls import reverse_lazy from django.shortcuts import get_object_or_404, render
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView from django.urls import reverse, reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView, View
from backoffice.forms import ProductVariantForm from backoffice.forms import ProductVariantForm
from backoffice.mixins import ( from backoffice.mixins import (
@@ -9,6 +10,7 @@ from backoffice.mixins import (
BackofficeHtmxMixin, BackofficeHtmxMixin,
BackofficeModalDeleteMixin, BackofficeModalDeleteMixin,
BackofficeModalFormMixin, BackofficeModalFormMixin,
BackofficeStyledFormMixin,
) )
from shop.models import ( from shop.models import (
Brand, Brand,
@@ -49,6 +51,7 @@ class ProductListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
'title': 'Productos', 'title': 'Productos',
'columns': [('SKU', 'sku'), ('Nombre', 'name'), ('Stock', 'stock'), ('Oculto', 'hidden')], 'columns': [('SKU', 'sku'), ('Nombre', 'name'), ('Stock', 'stock'), ('Oculto', 'hidden')],
'create_url': reverse_lazy('backoffice:product_create'), 'create_url': reverse_lazy('backoffice:product_create'),
'create_is_page': True,
'detail_url_name': 'backoffice:product_detail', 'detail_url_name': 'backoffice:product_detail',
'delete_url_name': 'backoffice:product_delete', 'delete_url_name': 'backoffice:product_delete',
} }
@@ -56,35 +59,18 @@ class ProductListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
return context return context
class ProductCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): class ProductCreateView(BackofficeCRUDMixin, BackofficeStyledFormMixin, CreateView):
"""Página completa (no modal), con el mismo aspecto/widgets que la página de
detalle (incluido el combobox de marca/categorías/etiquetas), pero todo en
un único formulario: nada se envía al servidor hasta pulsar "Crear
producto" (variantes, precios, imágenes y remesas sí requieren que el
producto exista, así que se añaden después desde el detalle)."""
model = Product model = Product
permission_required = 'shop.add_product' permission_required = 'shop.add_product'
section = SECTION section = SECTION
fields = ('sku', 'name', 'description', 'stock', 'brand', 'categories', 'tags', 'hidden', 'is_shipping_method') fields = ('sku', 'name', 'description', 'stock', 'brand', 'categories', 'tags', 'hidden', 'is_shipping_method')
template_name = 'backoffice/generic/form.html' template_name = 'backoffice/products/product_create.html'
fragment_template_name = FORM_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({'title': 'Añadir producto', 'cancel_url': reverse_lazy('backoffice:product_list')})
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.pk])
class ProductUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView):
model = Product
permission_required = 'shop.change_product'
section = SECTION
fields = ('sku', 'name', 'description', 'stock', 'brand', 'categories', 'tags', 'hidden', 'is_shipping_method')
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({'title': f'Editar {self.object.name}', 'cancel_url': reverse_lazy('backoffice:product_list')})
return context
def get_success_url(self): def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.pk]) return reverse_lazy('backoffice:product_detail', args=[self.object.pk])
@@ -138,6 +124,97 @@ class ProductDetailView(BackofficeCRUDMixin, DetailView):
return context return context
# --- Edición inline de Product.name / Product.description / Product.brand /
# Product.categories / Product.tags ---
INLINE_EDITABLE_PRODUCT_FIELDS = ('name', 'description', 'brand', 'categories', 'tags')
MULTI_VALUE_INLINE_FIELDS = ('categories', 'tags')
COMBOBOX_INLINE_FIELDS = {
'brand': Brand,
'categories': ProductCategory,
'tags': Tag,
}
class ProductInlineFieldMixin:
def get_field_name(self):
field_name = self.kwargs['field_name']
if field_name not in INLINE_EDITABLE_PRODUCT_FIELDS:
raise Http404
return field_name
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
'field_name': self.get_field_name(),
'view_url': reverse('backoffice:product_inline_view', args=[self.object.pk, self.get_field_name()]),
'edit_url': reverse('backoffice:product_inline_edit', args=[self.object.pk, self.get_field_name()]),
}
)
return context
class ProductInlineViewFragment(BackofficeCRUDMixin, ProductInlineFieldMixin, DetailView):
model = Product
permission_required = 'shop.view_product'
section = SECTION
template_name = 'backoffice/products/_inline_view.html'
class ProductInlineEditView(BackofficeCRUDMixin, BackofficeStyledFormMixin, ProductInlineFieldMixin, UpdateView):
model = Product
permission_required = 'shop.change_product'
section = SECTION
template_name = 'backoffice/products/_inline_edit.html'
def get_form_class(self):
return modelform_factory(Product, fields=[self.get_field_name()])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
field_name = self.get_field_name()
if field_name in COMBOBOX_INLINE_FIELDS:
multiple = field_name in MULTI_VALUE_INLINE_FIELDS
value = getattr(self.object, field_name)
context.update(
{'multiple': multiple, 'selected': list(value.all()) if multiple else list(filter(None, [value]))}
)
return context
def form_valid(self, form):
self.object = form.save()
return render(self.request, 'backoffice/products/_inline_view.html', self.get_context_data())
class ProductFieldSearchView(BackofficeCRUDMixin, View):
permission_required = 'shop.change_product'
section = SECTION
def get(self, request, *args, **kwargs):
field_name = kwargs['field_name']
model = COMBOBOX_INLINE_FIELDS.get(field_name)
if model is None:
raise Http404
query = request.GET.get('q', '').strip()
queryset = model.objects.all()
if query:
queryset = queryset.filter(name__icontains=query)
return render(
request,
'backoffice/products/_combobox_results.html',
{'results': queryset[:10], 'field_name': field_name},
)
# --- ProductImage (nested under a product) --- # --- ProductImage (nested under a product) ---
+3
View File
@@ -353,6 +353,9 @@ class Provider(TimestampedModel):
class Brand(TimestampedModel): class Brand(TimestampedModel):
name = models.CharField(max_length=100, unique=True, blank=False, null=False, verbose_name=_('nombre')) name = models.CharField(max_length=100, unique=True, blank=False, null=False, verbose_name=_('nombre'))
def __str__(self):
return self.name
class Meta: class Meta:
verbose_name = _('marca') verbose_name = _('marca')
verbose_name_plural = _('marcas') verbose_name_plural = _('marcas')