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.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,7 +6,11 @@
|
|||||||
<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 %}
|
||||||
<button class="btn btn-primary" hx-get="{{ create_url }}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir</button>
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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,31 +5,81 @@
|
|||||||
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">
|
||||||
<div class="stat">
|
{% with images.first as main_image %}
|
||||||
<div class="stat-title">SKU</div>
|
<div class="shrink-0 w-64">
|
||||||
<div class="stat-value text-lg">{{ product.sku }}</div>
|
{% if main_image %}
|
||||||
</div>
|
<img class="w-64 h-64 object-cover rounded-box border border-base-300 shadow" src="{{ main_image.l.url }}" alt="{{ product.name }}">
|
||||||
<div class="stat">
|
{% else %}
|
||||||
<div class="stat-title">Stock</div>
|
<div class="w-64 h-64 rounded-box border border-base-300 border-dashed flex items-center justify-center text-sm opacity-50">
|
||||||
<div class="stat-value text-lg">{{ product.stock }}</div>
|
Sin imagen
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
{% endif %}
|
||||||
<div class="stat-title">Precio actual</div>
|
|
||||||
<div class="stat-value text-lg">
|
|
||||||
{% if product.price %}{{ product.price.price_with_tax }} €{% else %}—{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Stock</div>
|
||||||
|
<div class="stat-value text-lg">{{ product.stock }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Precio actual</div>
|
||||||
|
<div class="stat-value text-lg">
|
||||||
|
{% if product.price %}{{ product.price.price_with_tax }} €{% else %}—{% endif %}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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 %}
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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) ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user