diff --git a/.gitea/workflows/test-build.yaml b/.gitea/workflows/test-build.yaml new file mode 100644 index 0000000..cb81495 --- /dev/null +++ b/.gitea/workflows/test-build.yaml @@ -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 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index 10e110f..0000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/runtests.yml b/.github/workflows/runtests.yml deleted file mode 100644 index eff893a..0000000 --- a/.github/workflows/runtests.yml +++ /dev/null @@ -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 }} diff --git a/backoffice/templates/backoffice/base.html b/backoffice/templates/backoffice/base.html index efea8a9..3944685 100644 --- a/backoffice/templates/backoffice/base.html +++ b/backoffice/templates/backoffice/base.html @@ -63,6 +63,57 @@ document.body.addEventListener('backoffice:modal-close', function () { document.getElementById('backoffice-modal').close(); }); + + // Combobox de búsqueda (DaisyUI + HTMX): el 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(); + } diff --git a/backoffice/templates/backoffice/generic/list.html b/backoffice/templates/backoffice/generic/list.html index 271e498..f783512 100644 --- a/backoffice/templates/backoffice/generic/list.html +++ b/backoffice/templates/backoffice/generic/list.html @@ -6,7 +6,11 @@

{{ title }}

{% if create_url %} - + {% if create_is_page %} + Añadir + {% else %} + + {% endif %} {% endif %}
diff --git a/backoffice/templates/backoffice/products/_combobox.html b/backoffice/templates/backoffice/products/_combobox.html new file mode 100644 index 0000000..75d8d3d --- /dev/null +++ b/backoffice/templates/backoffice/products/_combobox.html @@ -0,0 +1,28 @@ +
+
+ {% for item in selected %} + + {{ item }} + + + {% endfor %} +
+
+ {% for item in selected %} + + {% endfor %} +
+ +
diff --git a/backoffice/templates/backoffice/products/_combobox_results.html b/backoffice/templates/backoffice/products/_combobox_results.html new file mode 100644 index 0000000..99ab8ef --- /dev/null +++ b/backoffice/templates/backoffice/products/_combobox_results.html @@ -0,0 +1,9 @@ +{% for result in results %} +
  • + +
  • +{% empty %} +
  • Sin resultados
  • +{% endfor %} diff --git a/backoffice/templates/backoffice/products/_inline_edit.html b/backoffice/templates/backoffice/products/_inline_edit.html new file mode 100644 index 0000000..4faa769 --- /dev/null +++ b/backoffice/templates/backoffice/products/_inline_edit.html @@ -0,0 +1,14 @@ +
    +
    + {% csrf_token %} + {% if field_name == 'brand' or field_name == 'categories' or field_name == 'tags' %} + {% include 'backoffice/products/_combobox.html' %} + {% else %} + {{ form.as_p }} + {% endif %} +
    + + +
    +
    +
    diff --git a/backoffice/templates/backoffice/products/_inline_view.html b/backoffice/templates/backoffice/products/_inline_view.html new file mode 100644 index 0000000..a58885f --- /dev/null +++ b/backoffice/templates/backoffice/products/_inline_view.html @@ -0,0 +1,26 @@ +
    + {% if field_name == 'name' %} +

    {{ product.name }}

    + {% elif field_name == 'description' %} +

    {{ product.description|default:'Sin descripción.' }}

    + {% elif field_name == 'brand' %} +

    {{ product.brand|default:'Sin marca' }}

    + {% elif field_name == 'categories' %} +
    + {% for category in product.categories.all %} + {{ category }} + {% empty %} + Sin categorías + {% endfor %} +
    + {% elif field_name == 'tags' %} +
    + {% for tag in product.tags.all %} + {{ tag }} + {% empty %} + Sin etiquetas + {% endfor %} +
    + {% endif %} + +
    diff --git a/backoffice/templates/backoffice/products/_product_detail_fragment.html b/backoffice/templates/backoffice/products/_product_detail_fragment.html index b415cf5..c4159fc 100644 --- a/backoffice/templates/backoffice/products/_product_detail_fragment.html +++ b/backoffice/templates/backoffice/products/_product_detail_fragment.html @@ -5,31 +5,81 @@ 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 %} +
    -

    {{ product.name }}

    + {% include 'backoffice/products/_inline_view.html' with field_name='name' view_url=name_view_url edit_url=name_edit_url %}
    -
    -
    -
    -
    SKU
    -
    {{ product.sku }}
    -
    -
    -
    Stock
    -
    {{ product.stock }}
    -
    -
    -
    Precio actual
    -
    - {% if product.price %}{{ product.price.price_with_tax }} €{% else %}—{% endif %} +
    + {% with images.first as main_image %} +
    + {% if main_image %} + {{ product.name }} + {% else %} +
    + Sin imagen +
    + {% endif %}
    -
    + {% endwith %} + +
    +
    +
    +
    SKU
    +
    {{ product.sku }}
    +
    +
    +
    Stock
    +
    {{ product.stock }}
    +
    +
    +
    Precio actual
    +
    + {% if product.price %}{{ product.price.price_with_tax }} €{% else %}—{% endif %} +
    +
    +
    + +
    + {% include 'backoffice/products/_inline_view.html' with field_name='description' view_url=description_view_url edit_url=description_edit_url %} +
    + +
    +
    +

    Marca

    + {% include 'backoffice/products/_inline_view.html' with field_name='brand' view_url=brand_view_url edit_url=brand_edit_url %} +
    +
    +

    Categorías

    + {% include 'backoffice/products/_inline_view.html' with field_name='categories' view_url=categories_view_url edit_url=categories_edit_url %} +
    +
    +

    Etiquetas

    + {% include 'backoffice/products/_inline_view.html' with field_name='tags' view_url=tags_view_url edit_url=tags_edit_url %} +
    +
    +
    + + + + +

    Imágenes

    @@ -37,7 +87,10 @@
    {% for image in images %}
    - + + {% if forloop.first %} + Principal + {% endif %}
    {% empty %} diff --git a/backoffice/templates/backoffice/products/product_create.html b/backoffice/templates/backoffice/products/product_create.html new file mode 100644 index 0000000..585a7b3 --- /dev/null +++ b/backoffice/templates/backoffice/products/product_create.html @@ -0,0 +1,63 @@ +{% extends 'backoffice/base.html' %} + +{% block title %}Nuevo producto{% endblock %} + +{% block main %} +
    + {% csrf_token %} + +
    + + {{ form.name }} + {{ form.name.errors }} +
    + +
    + + {{ form.description }} + {{ form.description.errors }} +
    + +
    +
    +

    Marca

    + {% include 'backoffice/products/_combobox.html' with field_name='brand' multiple=False %} +
    +
    +

    Categorías

    + {% include 'backoffice/products/_combobox.html' with field_name='categories' multiple=True %} +
    +
    +

    Etiquetas

    + {% include 'backoffice/products/_combobox.html' with field_name='tags' multiple=True %} +
    +
    + +
    +
    + + {{ form.sku }} + {{ form.sku.errors }} +
    +
    + + {{ form.stock }} + {{ form.stock.errors }} +
    +
    + +
    + + +
    + +
    + + Cancelar +
    +
    +{% endblock %} diff --git a/backoffice/tests/test_htmx.py b/backoffice/tests/test_htmx.py index bc0a976..308c112 100644 --- a/backoffice/tests/test_htmx.py +++ b/backoffice/tests/test_htmx.py @@ -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 '/', products.ProductDetailView.as_view(), name='product_detail'), - path('/edit/', products.ProductUpdateView.as_view(), name='product_update'), path('/delete/', products.ProductDeleteView.as_view(), name='product_delete'), + path( + '/inline//', + products.ProductInlineViewFragment.as_view(), + name='product_inline_view', + ), + path( + '/inline//edit/', + products.ProductInlineEditView.as_view(), + name='product_inline_edit', + ), + path( + 'inline-search//', + products.ProductFieldSearchView.as_view(), + name='product_field_search', + ), path('/prices/create/', products.ProductPriceCreateView.as_view(), name='product_price_create'), path( '/variants/create/', diff --git a/backoffice/views/products.py b/backoffice/views/products.py index bb96a55..1c7f1aa 100644 --- a/backoffice/views/products.py +++ b/backoffice/views/products.py @@ -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) --- diff --git a/shop/models.py b/shop/models.py index 8d46fa5..ef64983 100644 --- a/shop/models.py +++ b/shop/models.py @@ -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')