feat: create/edit product views
This commit is contained in:
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -63,6 +63,57 @@
|
||||
document.body.addEventListener('backoffice:modal-close', function () {
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h1 class="text-2xl font-semibold">{{ title }}</h1>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% 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-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">
|
||||
<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">
|
||||
<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>
|
||||
</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-title">SKU</div>
|
||||
<div class="stat-value text-lg">{{ product.sku }}</div>
|
||||
@@ -30,6 +54,32 @@
|
||||
</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">
|
||||
<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>
|
||||
@@ -37,7 +87,10 @@
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
{% for image in images %}
|
||||
<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>
|
||||
</div>
|
||||
{% 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 %}
|
||||
@@ -17,53 +17,24 @@ class TestBackofficeHtmxContract(TestCase, CreateProductsMixin):
|
||||
self.client.force_login(self.superuser)
|
||||
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')
|
||||
assert response.status_code == 200
|
||||
body = response.content.decode()
|
||||
assert '<html' not in body
|
||||
assert 'Añadir producto' in body
|
||||
assert '<html' 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(
|
||||
reverse('backoffice:product_create'),
|
||||
{
|
||||
'sku': 'HTMX-1',
|
||||
'name': 'Producto htmx',
|
||||
'description': '',
|
||||
'stock': '0',
|
||||
'categories': [],
|
||||
'tags': [],
|
||||
},
|
||||
{'sku': 'HTMX-1', 'name': 'Producto htmx', 'description': '', 'stock': '0'},
|
||||
HTTP_HX_REQUEST='true',
|
||||
)
|
||||
assert response.status_code == 204
|
||||
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': [],
|
||||
},
|
||||
)
|
||||
product = Product.objects.get(sku='HTMX-1')
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse('backoffice:product_detail', args=[product.pk])
|
||||
assert 'HX-Trigger' not in response
|
||||
|
||||
def test_htmx_variant_create_triggers_refresh(self):
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
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
|
||||
|
||||
|
||||
@@ -23,19 +23,40 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
|
||||
assert self.product.name in response.content.decode()
|
||||
|
||||
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(
|
||||
reverse('backoffice:product_create'),
|
||||
{
|
||||
'sku': 'NEW-1',
|
||||
'name': 'Producto nuevo',
|
||||
'sku': 'NEW-2',
|
||||
'name': 'Producto completo',
|
||||
'description': '',
|
||||
'stock': '0',
|
||||
'categories': [],
|
||||
'tags': [],
|
||||
'brand': brand.pk,
|
||||
'categories': [cat1.pk, cat2.pk],
|
||||
'tags': [tag1.pk],
|
||||
},
|
||||
)
|
||||
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):
|
||||
size_m = self.create_attribute_value('Talla', 'M')
|
||||
@@ -104,6 +125,97 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
|
||||
assert response.status_code == 302
|
||||
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):
|
||||
self.product.stock = Decimal('10')
|
||||
self.product.save()
|
||||
|
||||
@@ -6,8 +6,22 @@ urlpatterns = [
|
||||
path('', products.ProductListView.as_view(), name='product_list'),
|
||||
path('create/', products.ProductCreateView.as_view(), name='product_create'),
|
||||
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>/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>/variants/create/',
|
||||
|
||||
+106
-29
@@ -1,7 +1,8 @@
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView
|
||||
from django.forms import modelform_factory
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
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.mixins import (
|
||||
@@ -9,6 +10,7 @@ from backoffice.mixins import (
|
||||
BackofficeHtmxMixin,
|
||||
BackofficeModalDeleteMixin,
|
||||
BackofficeModalFormMixin,
|
||||
BackofficeStyledFormMixin,
|
||||
)
|
||||
from shop.models import (
|
||||
Brand,
|
||||
@@ -49,6 +51,7 @@ class ProductListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
|
||||
'title': 'Productos',
|
||||
'columns': [('SKU', 'sku'), ('Nombre', 'name'), ('Stock', 'stock'), ('Oculto', 'hidden')],
|
||||
'create_url': reverse_lazy('backoffice:product_create'),
|
||||
'create_is_page': True,
|
||||
'detail_url_name': 'backoffice:product_detail',
|
||||
'delete_url_name': 'backoffice:product_delete',
|
||||
}
|
||||
@@ -56,35 +59,18 @@ class ProductListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
|
||||
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
|
||||
permission_required = 'shop.add_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': '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
|
||||
template_name = 'backoffice/products/product_create.html'
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('backoffice:product_detail', args=[self.object.pk])
|
||||
@@ -138,6 +124,97 @@ class ProductDetailView(BackofficeCRUDMixin, DetailView):
|
||||
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) ---
|
||||
|
||||
|
||||
|
||||
@@ -353,6 +353,9 @@ class Provider(TimestampedModel):
|
||||
class Brand(TimestampedModel):
|
||||
name = models.CharField(max_length=100, unique=True, blank=False, null=False, verbose_name=_('nombre'))
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('marca')
|
||||
verbose_name_plural = _('marcas')
|
||||
|
||||
Reference in New Issue
Block a user