diff --git a/backoffice/__init__.py b/backoffice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backoffice/apps.py b/backoffice/apps.py new file mode 100644 index 0000000..16ce96a --- /dev/null +++ b/backoffice/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BackofficeConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'backoffice' diff --git a/backoffice/forms.py b/backoffice/forms.py new file mode 100644 index 0000000..998445d --- /dev/null +++ b/backoffice/forms.py @@ -0,0 +1,35 @@ +from django import forms +from django.core.exceptions import ValidationError + +from shop.models import ProductVariant + + +class ProductVariantForm(forms.ModelForm): + class Meta: + model = ProductVariant + fields = ('sku', 'stock', 'attribute_values') + widgets = {'attribute_values': forms.CheckboxSelectMultiple} + + def __init__(self, *args, product=None, **kwargs): + super().__init__(*args, **kwargs) + self.product = product or self.instance.product + + def clean(self): + cleaned_data = super().clean() + attribute_values = cleaned_data.get('attribute_values') + + if attribute_values is not None: + combination = set(attribute_values.values_list('pk', flat=True)) + siblings = ProductVariant.objects.filter(product=self.product).exclude(pk=self.instance.pk) + + for sibling in siblings: + if set(sibling.attribute_values.values_list('pk', flat=True)) == combination: + raise ValidationError( + 'Ya existe una variante de este producto con la misma combinación de atributos.' + ) + + return cleaned_data + + def save(self, commit=True): + self.instance.product = self.product + return super().save(commit=commit) diff --git a/backoffice/mixins.py b/backoffice/mixins.py new file mode 100644 index 0000000..dd17f55 --- /dev/null +++ b/backoffice/mixins.py @@ -0,0 +1,107 @@ +import json + +from django import forms +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin +from django.http import HttpResponse, HttpResponseRedirect + +DAISYUI_WIDGET_CLASSES = ( + (forms.CheckboxInput, 'checkbox'), + (forms.CheckboxSelectMultiple, 'checkbox'), + (forms.ClearableFileInput, 'file-input file-input-bordered w-full'), + (forms.Textarea, 'textarea textarea-bordered w-full'), + (forms.Select, 'select select-bordered w-full'), +) +DAISYUI_DEFAULT_WIDGET_CLASS = 'input input-bordered w-full' + + +class BackofficeSectionMixin: + section = None + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['section'] = self.section + return context + + +class BackofficeAccessMixin(BackofficeSectionMixin, LoginRequiredMixin, UserPassesTestMixin): + login_url = 'users:login' + + def test_func(self): + return self.request.user.is_staff + + +class BackofficeCRUDMixin(BackofficeAccessMixin, PermissionRequiredMixin): + raise_exception = True + + +class BackofficeStyledFormMixin: + """Aplica clases de DaisyUI a los widgets del formulario según su tipo, + sin necesitar declarar un ModelForm explícito por modelo (a diferencia de + web.mixins.StylingMixin, que requiere listar `styled_fields` a mano).""" + + def get_form(self, form_class=None): + form = super().get_form(form_class) + + for field in form.fields.values(): + css_class = DAISYUI_DEFAULT_WIDGET_CLASS + + for widget_type, widget_css_class in DAISYUI_WIDGET_CLASSES: + if isinstance(field.widget, widget_type): + css_class = widget_css_class + break + + existing = field.widget.attrs.get('class', '') + field.widget.attrs['class'] = f'{existing} {css_class}'.strip() + + return form + + +class BackofficeHtmxMixin: + """Toda vista de backoffice puede responder con la página completa (navegación + directa) o con un fragmento (carga en el modal, o autorefresco de una lista/detalle).""" + + fragment_template_name = None + + def is_htmx(self): + return self.request.headers.get('HX-Request') == 'true' + + def get_template_names(self): + if self.is_htmx() and self.fragment_template_name: + return [self.fragment_template_name] + return super().get_template_names() + + +class BackofficeModalFormMixin(BackofficeStyledFormMixin, BackofficeHtmxMixin): + """Create/Update pensadas para abrirse en modal. Al guardar con éxito vía htmx + no redirige (un no navega): responde 204 + HX-Trigger para cerrar el + modal y avisar a quien lo abrió (lista o detalle) de que se refresque.""" + + trigger_event = 'backoffice:list-changed' + + def form_valid(self, form): + self.object = form.save() + + if self.is_htmx(): + response = HttpResponse(status=204) + response['HX-Trigger'] = json.dumps({'backoffice:modal-close': True, self.trigger_event: True}) + return response + + return HttpResponseRedirect(self.get_success_url()) + + +class BackofficeModalDeleteMixin(BackofficeHtmxMixin): + trigger_event = 'backoffice:list-changed' + + def perform_delete(self): + self.object.delete() + + def form_valid(self, form): + success_url = self.get_success_url() + self.perform_delete() + + if self.is_htmx(): + response = HttpResponse(status=204) + response['HX-Trigger'] = json.dumps({'backoffice:modal-close': True, self.trigger_event: True}) + return response + + return HttpResponseRedirect(success_url) diff --git a/backoffice/templates/backoffice/base.html b/backoffice/templates/backoffice/base.html new file mode 100644 index 0000000..efea8a9 --- /dev/null +++ b/backoffice/templates/backoffice/base.html @@ -0,0 +1,68 @@ +{% load static %} + + + + + + {% block title %}Backoffice{% endblock %} + + + + + + {% block extra_js %} + {% endblock %} + + +
+ +
+ {% block main %} + {% endblock %} +
+
+ + + + + + + + + diff --git a/backoffice/templates/backoffice/customers/customer_detail.html b/backoffice/templates/backoffice/customers/customer_detail.html new file mode 100644 index 0000000..f46dfc4 --- /dev/null +++ b/backoffice/templates/backoffice/customers/customer_detail.html @@ -0,0 +1,44 @@ +{% extends 'backoffice/base.html' %} + +{% block title %}{{ object.email }}{% endblock %} + +{% block main %} +

{{ object.email }}

+

{{ object.first_name }} {{ object.last_name }}

+ +

Direcciones

+
+ + + + {% for address in addresses %} + + + + + + {% empty %} + + {% endfor %} + +
TipoDirecciónLocalidad
{{ address.get_address_type_display }}{{ address.address }}{{ address.address_town }}
Sin direcciones registradas.
+
+ +

Pedidos

+
+ + + + {% for order in orders %} + + + + + + {% empty %} + + {% endfor %} + +
CódigoEstadoTotal
{{ order.code }}{{ order.get_status_display }}{{ order.total }} €
Sin pedidos.
+
+{% endblock %} diff --git a/backoffice/templates/backoffice/dashboard.html b/backoffice/templates/backoffice/dashboard.html new file mode 100644 index 0000000..7bd6015 --- /dev/null +++ b/backoffice/templates/backoffice/dashboard.html @@ -0,0 +1,17 @@ +{% extends 'backoffice/base.html' %} + +{% block title %}Backoffice{% endblock %} + +{% block main %} +

Backoffice

+ +
+ {% for section in sections %} + +
+

{{ section.label }}

+
+
+ {% endfor %} +
+{% endblock %} diff --git a/backoffice/templates/backoffice/generic/_confirm_delete_fragment.html b/backoffice/templates/backoffice/generic/_confirm_delete_fragment.html new file mode 100644 index 0000000..112e480 --- /dev/null +++ b/backoffice/templates/backoffice/generic/_confirm_delete_fragment.html @@ -0,0 +1,10 @@ +

Confirmar borrado

+

¿Seguro que quieres borrar «{{ object }}»?

+ +
+ {% csrf_token %} + +
diff --git a/backoffice/templates/backoffice/generic/_form_fragment.html b/backoffice/templates/backoffice/generic/_form_fragment.html new file mode 100644 index 0000000..98a8e05 --- /dev/null +++ b/backoffice/templates/backoffice/generic/_form_fragment.html @@ -0,0 +1,10 @@ +

{{ title }}

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
diff --git a/backoffice/templates/backoffice/generic/_list_fragment.html b/backoffice/templates/backoffice/generic/_list_fragment.html new file mode 100644 index 0000000..d802031 --- /dev/null +++ b/backoffice/templates/backoffice/generic/_list_fragment.html @@ -0,0 +1,52 @@ +{% load backoffice_extras %} + +
+
+ + + + {% for label, attr in columns %}{% endfor %} + {% if update_url_name or delete_url_name or detail_url_name %}{% endif %} + + + + {% for object in object_list %} + + {% for label, attr in columns %}{% endfor %} + {% if update_url_name or delete_url_name or detail_url_name %} + + {% endif %} + + {% empty %} + + {% endfor %} + +
{{ label }}
{{ object|getattribute:attr }} + {% if detail_url_name %} + Ver + {% endif %} + {% if update_url_name %} + + {% endif %} + {% if delete_url_name %} + + {% endif %} +
No hay resultados.
+
+ + {% if is_paginated %} +
+ {% if page_obj.has_previous %} + « + {% endif %} + {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + {% if page_obj.has_next %} + » + {% endif %} +
+ {% endif %} +
diff --git a/backoffice/templates/backoffice/generic/confirm_delete.html b/backoffice/templates/backoffice/generic/confirm_delete.html new file mode 100644 index 0000000..0db9a63 --- /dev/null +++ b/backoffice/templates/backoffice/generic/confirm_delete.html @@ -0,0 +1,16 @@ +{% extends 'backoffice/base.html' %} + +{% block title %}Confirmar borrado{% endblock %} + +{% block main %} +

Confirmar borrado

+

¿Seguro que quieres borrar «{{ object }}»?

+ +
+ {% csrf_token %} +
+ + {% if cancel_url %}Cancelar{% endif %} +
+
+{% endblock %} diff --git a/backoffice/templates/backoffice/generic/form.html b/backoffice/templates/backoffice/generic/form.html new file mode 100644 index 0000000..64eb672 --- /dev/null +++ b/backoffice/templates/backoffice/generic/form.html @@ -0,0 +1,16 @@ +{% extends 'backoffice/base.html' %} + +{% block title %}{{ title }}{% endblock %} + +{% block main %} +

{{ title }}

+ +
+ {% csrf_token %} + {{ form.as_p }} +
+ + {% if cancel_url %}Cancelar{% endif %} +
+
+{% endblock %} diff --git a/backoffice/templates/backoffice/generic/list.html b/backoffice/templates/backoffice/generic/list.html new file mode 100644 index 0000000..271e498 --- /dev/null +++ b/backoffice/templates/backoffice/generic/list.html @@ -0,0 +1,14 @@ +{% extends 'backoffice/base.html' %} + +{% block title %}{{ title }}{% endblock %} + +{% block main %} +
+

{{ title }}

+ {% if create_url %} + + {% endif %} +
+ + {% include 'backoffice/generic/_list_fragment.html' %} +{% endblock %} diff --git a/backoffice/templates/backoffice/orders/order_detail.html b/backoffice/templates/backoffice/orders/order_detail.html new file mode 100644 index 0000000..bf0344f --- /dev/null +++ b/backoffice/templates/backoffice/orders/order_detail.html @@ -0,0 +1,74 @@ +{% extends 'backoffice/base.html' %} + +{% block title %}Pedido {{ object.code }}{% endblock %} + +{% block main %} +

Pedido {{ object.code }}

+ +
+
+
Cliente
+
{{ object.email }}
+
+
+
Estado
+
{{ object.get_status_display }}
+
+
+
Total
+
{{ object.total }} €
+
+
+ +

+ Dirección de envío: + {{ object.shipping_address }}, {{ object.shipping_city }}, {{ object.shipping_zip }} +

+ +

Estado de envío

+
+ {% csrf_token %} + {{ shipping_status_form.as_p }} + +
+ +

Líneas

+
+ + + + {% for line in lines %} + + + + + + + {% endfor %} + +
ProductoCantidadPrecioTotal
+ {{ line.product.name }} + {% if line.variant %} + {% for attribute_value in line.variant.attribute_values.all %}{{ attribute_value }}{% endfor %} + {% endif %} + {{ line.quantity }}{{ line.price }} €{{ line.total }} €
+
+ +

Pagos

+
+ + + + {% for payment in payments %} + + + + + + {% empty %} + + {% endfor %} + +
FechaImporteMétodo
{{ payment.creation_date }}{{ payment.amount }} €{{ payment.get_method_display }}
Sin pagos registrados.
+
+{% endblock %} diff --git a/backoffice/templates/backoffice/products/_attribute_detail_fragment.html b/backoffice/templates/backoffice/products/_attribute_detail_fragment.html new file mode 100644 index 0000000..dcb7339 --- /dev/null +++ b/backoffice/templates/backoffice/products/_attribute_detail_fragment.html @@ -0,0 +1,33 @@ +
+ +
+

{{ object.name }}

+ +
+ +
+

Valores

+ +
+
+ + + + {% for value in values %} + + + + + {% empty %} + + {% endfor %} + +
Valor
{{ value.value }} + +
Este atributo no tiene valores todavía.
+
+
diff --git a/backoffice/templates/backoffice/products/_product_detail_fragment.html b/backoffice/templates/backoffice/products/_product_detail_fragment.html new file mode 100644 index 0000000..b415cf5 --- /dev/null +++ b/backoffice/templates/backoffice/products/_product_detail_fragment.html @@ -0,0 +1,105 @@ +{% load static %} +
+ +
+

{{ product.name }}

+
+ + +
+
+ +
+
+
SKU
+
{{ product.sku }}
+
+
+
Stock
+
{{ product.stock }}
+
+
+
Precio actual
+
+ {% if product.price %}{{ product.price.price_with_tax }} €{% else %}—{% endif %} +
+
+
+ +
+

Imágenes

+ +
+
+ {% for image in images %} +
+ + +
+ {% empty %} +

Este producto no tiene imágenes.

+ {% endfor %} +
+ +
+

Variantes

+ +
+
+ + + + {% for variant in variants %} + + + + + + + + {% empty %} + + {% endfor %} + +
SKUAtributosStockPrecio
{{ variant.sku }}{% for attribute_value in variant.attribute_values.all %}{{ attribute_value }}{% endfor %}{{ variant.stock }} + {% if variant.price %}{{ variant.price.price_with_tax }} €{% else %}—{% endif %} + + + + +
Este producto no tiene variantes.
+
+ +
+ +
+ +
+

Remesas

+ +
+
+ + + + {% for batch in batches %} + + + + + + + + {% empty %} + + {% endfor %} + +
CódigoCantidadCaducidadProveedor
{{ batch.code }}{{ batch.quantity }}{{ batch.expiration_date|default:'—' }}{{ batch.provider|default:'—' }} + +
Este producto no tiene remesas.
+
+
diff --git a/backoffice/templates/backoffice/products/attribute_detail.html b/backoffice/templates/backoffice/products/attribute_detail.html new file mode 100644 index 0000000..9de486d --- /dev/null +++ b/backoffice/templates/backoffice/products/attribute_detail.html @@ -0,0 +1,7 @@ +{% extends 'backoffice/base.html' %} + +{% block title %}{{ object.name }}{% endblock %} + +{% block main %} + {% include 'backoffice/products/_attribute_detail_fragment.html' %} +{% endblock %} diff --git a/backoffice/templates/backoffice/products/product_detail.html b/backoffice/templates/backoffice/products/product_detail.html new file mode 100644 index 0000000..930ef46 --- /dev/null +++ b/backoffice/templates/backoffice/products/product_detail.html @@ -0,0 +1,7 @@ +{% extends 'backoffice/base.html' %} + +{% block title %}{{ product.name }}{% endblock %} + +{% block main %} + {% include 'backoffice/products/_product_detail_fragment.html' %} +{% endblock %} diff --git a/backoffice/templatetags/__init__.py b/backoffice/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backoffice/templatetags/backoffice_extras.py b/backoffice/templatetags/backoffice_extras.py new file mode 100644 index 0000000..cae2d70 --- /dev/null +++ b/backoffice/templatetags/backoffice_extras.py @@ -0,0 +1,15 @@ +from django import template + +register = template.Library() + + +@register.filter +def getattribute(obj, attr_path): + value = obj + for attr in attr_path.split('.'): + if value is None: + return '' + value = getattr(value, attr, '') + if callable(value): + value = value() + return value diff --git a/backoffice/tests/__init__.py b/backoffice/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backoffice/tests/test_access.py b/backoffice/tests/test_access.py new file mode 100644 index 0000000..4bef313 --- /dev/null +++ b/backoffice/tests/test_access.py @@ -0,0 +1,31 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse + + +class TestBackofficeAccess(TestCase): + def test_anonymous_user_is_redirected_to_login(self): + response = self.client.get(reverse('backoffice:dashboard')) + assert response.status_code == 302 + assert reverse('users:login') in response.url + + def test_authenticated_non_staff_user_gets_forbidden(self): + user = User.objects.create_user('luke', 'luke@rebels.com', 'ihatesand', is_staff=False) + self.client.force_login(user) + + response = self.client.get(reverse('backoffice:dashboard')) + assert response.status_code == 403 + + def test_superuser_can_access_dashboard(self): + user = User.objects.create_superuser('vader', 'vader@empire.com', 'ihatesand') + self.client.force_login(user) + + response = self.client.get(reverse('backoffice:dashboard')) + assert response.status_code == 200 + + def test_staff_without_specific_permission_gets_forbidden_on_product_list(self): + user = User.objects.create_user('han', 'han@falcon.com', 'ihatesand', is_staff=True) + self.client.force_login(user) + + response = self.client.get(reverse('backoffice:product_list')) + assert response.status_code == 403 diff --git a/backoffice/tests/test_htmx.py b/backoffice/tests/test_htmx.py new file mode 100644 index 0000000..bc0a976 --- /dev/null +++ b/backoffice/tests/test_htmx.py @@ -0,0 +1,134 @@ +import json +from io import BytesIO + +from django.contrib.auth.models import User +from django.core.files.base import ContentFile +from django.test import TestCase +from django.urls import reverse +from PIL import Image + +from shop.models import Product, ProductImage, ProductVariant +from shop.tests.mixins import CreateProductsMixin + + +class TestBackofficeHtmxContract(TestCase, CreateProductsMixin): + def setUp(self): + self.superuser = User.objects.create_superuser('vader', 'vader@empire.com', 'ihatesand') + self.client.force_login(self.superuser) + self.product = self.create_product() + + def test_htmx_get_create_form_returns_fragment_not_full_page(self): + response = self.client.get(reverse('backoffice:product_create'), HTTP_HX_REQUEST='true') + assert response.status_code == 200 + body = response.content.decode() + assert '/', customers.CustomerDetailView.as_view(), name='customer_detail'), +] diff --git a/backoffice/urls/orders.py b/backoffice/urls/orders.py new file mode 100644 index 0000000..72a8b19 --- /dev/null +++ b/backoffice/urls/orders.py @@ -0,0 +1,13 @@ +from django.urls import path + +from backoffice.views import orders + +urlpatterns = [ + path('', orders.OrderListView.as_view(), name='order_list'), + path('/', orders.OrderDetailView.as_view(), name='order_detail'), + path( + '/shipping-status/', + orders.OrderUpdateShippingStatusView.as_view(), + name='order_update_shipping_status', + ), +] diff --git a/backoffice/urls/payments.py b/backoffice/urls/payments.py new file mode 100644 index 0000000..8c24338 --- /dev/null +++ b/backoffice/urls/payments.py @@ -0,0 +1,7 @@ +from django.urls import path + +from backoffice.views import payments + +urlpatterns = [ + path('', payments.PaymentListView.as_view(), name='payment_list'), +] diff --git a/backoffice/urls/products.py b/backoffice/urls/products.py new file mode 100644 index 0000000..fc4d22b --- /dev/null +++ b/backoffice/urls/products.py @@ -0,0 +1,65 @@ +from django.urls import path + +from backoffice.views import products + +urlpatterns = [ + path('', products.ProductListView.as_view(), name='product_list'), + path('create/', products.ProductCreateView.as_view(), name='product_create'), + path('/', 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('/prices/create/', products.ProductPriceCreateView.as_view(), name='product_price_create'), + path( + '/variants/create/', + products.ProductVariantCreateView.as_view(), + name='product_variant_create', + ), + path('variants//edit/', products.ProductVariantUpdateView.as_view(), name='product_variant_update'), + path('variants//delete/', products.ProductVariantDeleteView.as_view(), name='product_variant_delete'), + path( + 'variants//prices/create/', + products.ProductVariantPriceCreateView.as_view(), + name='product_variant_price_create', + ), + path('/batches/create/', products.ProductBatchCreateView.as_view(), name='product_batch_create'), + path('batches//delete/', products.ProductBatchDeleteView.as_view(), name='product_batch_delete'), + path('/images/create/', products.ProductImageCreateView.as_view(), name='product_image_create'), + path('images//delete/', products.ProductImageDeleteView.as_view(), name='product_image_delete'), + path('categories/', products.ProductCategoryListView.as_view(), name='product_category_list'), + path('categories/create/', products.ProductCategoryCreateView.as_view(), name='product_category_create'), + path( + 'categories//edit/', products.ProductCategoryUpdateView.as_view(), name='product_category_update' + ), + path( + 'categories//delete/', products.ProductCategoryDeleteView.as_view(), name='product_category_delete' + ), + path('attributes/', products.ProductAttributeListView.as_view(), name='product_attribute_list'), + path('attributes/create/', products.ProductAttributeCreateView.as_view(), name='product_attribute_create'), + path('attributes//', products.ProductAttributeDetailView.as_view(), name='product_attribute_detail'), + path( + 'attributes//edit/', products.ProductAttributeUpdateView.as_view(), name='product_attribute_update' + ), + path( + 'attributes//delete/', + products.ProductAttributeDeleteView.as_view(), + name='product_attribute_delete', + ), + path( + 'attributes//values/create/', + products.ProductAttributeValueCreateView.as_view(), + name='product_attribute_value_create', + ), + path( + 'attributes/values//delete/', + products.ProductAttributeValueDeleteView.as_view(), + name='product_attribute_value_delete', + ), + path('brands/', products.BrandListView.as_view(), name='brand_list'), + path('brands/create/', products.BrandCreateView.as_view(), name='brand_create'), + path('brands//edit/', products.BrandUpdateView.as_view(), name='brand_update'), + path('brands//delete/', products.BrandDeleteView.as_view(), name='brand_delete'), + path('tags/', products.TagListView.as_view(), name='tag_list'), + path('tags/create/', products.TagCreateView.as_view(), name='tag_create'), + path('tags//edit/', products.TagUpdateView.as_view(), name='tag_update'), + path('tags//delete/', products.TagDeleteView.as_view(), name='tag_delete'), +] diff --git a/backoffice/urls/providers.py b/backoffice/urls/providers.py new file mode 100644 index 0000000..49c91c8 --- /dev/null +++ b/backoffice/urls/providers.py @@ -0,0 +1,7 @@ +from django.urls import path + +from backoffice.views import providers + +urlpatterns = [ + path('', providers.ProviderListView.as_view(), name='provider_list'), +] diff --git a/backoffice/urls/settings.py b/backoffice/urls/settings.py new file mode 100644 index 0000000..13bf67d --- /dev/null +++ b/backoffice/urls/settings.py @@ -0,0 +1,8 @@ +from django.urls import path + +from backoffice.views import settings + +urlpatterns = [ + path('brand/', settings.BrandSettingsView.as_view(), name='brand_settings'), + path('shop/', settings.ShopSettingsView.as_view(), name='shop_settings'), +] diff --git a/backoffice/urls/taxes.py b/backoffice/urls/taxes.py new file mode 100644 index 0000000..1d55205 --- /dev/null +++ b/backoffice/urls/taxes.py @@ -0,0 +1,7 @@ +from django.urls import path + +from backoffice.views import taxes + +urlpatterns = [ + path('', taxes.TaxListView.as_view(), name='tax_list'), +] diff --git a/backoffice/views/__init__.py b/backoffice/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backoffice/views/customers.py b/backoffice/views/customers.py new file mode 100644 index 0000000..1892ab8 --- /dev/null +++ b/backoffice/views/customers.py @@ -0,0 +1,52 @@ +from django.contrib.auth import get_user_model +from django.views.generic import DetailView, ListView + +from backoffice.mixins import BackofficeCRUDMixin, BackofficeHtmxMixin +from shop.models import CustomerAddress, Order + +User = get_user_model() + +SECTION = 'customers' + + +class CustomerListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView): + model = User + permission_required = 'auth.view_user' + section = SECTION + paginate_by = 20 + template_name = 'backoffice/generic/list.html' + fragment_template_name = 'backoffice/generic/_list_fragment.html' + + def get_queryset(self): + return super().get_queryset().filter(is_staff=False).order_by('email') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'title': 'Clientes', + 'columns': [('E-mail', 'email'), ('Nombre', 'first_name'), ('Apellidos', 'last_name')], + 'detail_url_name': 'backoffice:customer_detail', + } + ) + return context + + +class CustomerDetailView(BackofficeCRUDMixin, DetailView): + model = User + permission_required = 'auth.view_user' + section = SECTION + template_name = 'backoffice/customers/customer_detail.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'addresses': CustomerAddress.objects.filter(user=self.object), + 'orders': Order.objects.filter(user=self.object), + } + ) + return context + +# CRUD pendiente: la edición/borrado de clientes ya se gestiona a través del +# admin de Django (auth.User) — aquí solo se expone consulta. diff --git a/backoffice/views/dashboard.py b/backoffice/views/dashboard.py new file mode 100644 index 0000000..4ef3770 --- /dev/null +++ b/backoffice/views/dashboard.py @@ -0,0 +1,23 @@ +from django.views.generic import TemplateView + +from backoffice.mixins import BackofficeAccessMixin + +SECTIONS = [ + {'label': 'Productos', 'url_name': 'backoffice:product_list'}, + {'label': 'Pedidos', 'url_name': 'backoffice:order_list'}, + {'label': 'Pagos', 'url_name': 'backoffice:payment_list'}, + {'label': 'Clientes', 'url_name': 'backoffice:customer_list'}, + {'label': 'Proveedores', 'url_name': 'backoffice:provider_list'}, + {'label': 'Impuestos', 'url_name': 'backoffice:tax_list'}, + {'label': 'Marca y ajustes', 'url_name': 'backoffice:brand_settings'}, +] + + +class DashboardView(BackofficeAccessMixin, TemplateView): + template_name = 'backoffice/dashboard.html' + section = 'dashboard' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['sections'] = SECTIONS + return context diff --git a/backoffice/views/orders.py b/backoffice/views/orders.py new file mode 100644 index 0000000..75511cb --- /dev/null +++ b/backoffice/views/orders.py @@ -0,0 +1,84 @@ +from django import forms +from django.urls import reverse_lazy +from django.views.generic import DetailView, ListView, UpdateView + +from backoffice.mixins import BackofficeCRUDMixin, BackofficeHtmxMixin +from shop.models import Order + +SECTION = 'orders' + + +class ShippingStatusForm(forms.ModelForm): + class Meta: + model = Order + fields = ('shipping_status',) + widgets = {'shipping_status': forms.Select(attrs={'class': 'select select-bordered w-full'})} + + +class OrderListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView): + model = Order + permission_required = 'shop.view_order' + section = SECTION + paginate_by = 20 + template_name = 'backoffice/generic/list.html' + fragment_template_name = 'backoffice/generic/_list_fragment.html' + + def get_queryset(self): + queryset = super().get_queryset() + status = self.request.GET.get('status') + + if status: + queryset = queryset.filter(status=status) + + return queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'title': 'Pedidos', + 'columns': [('Código', 'code'), ('Cliente', 'email'), ('Estado', 'get_status_display'), ('Total', 'total')], + 'detail_url_name': 'backoffice:order_detail', + } + ) + return context + + +class OrderDetailView(BackofficeCRUDMixin, DetailView): + model = Order + permission_required = 'shop.view_order' + section = SECTION + template_name = 'backoffice/orders/order_detail.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'lines': self.object.lines.all(), + 'payments': self.object.payments.all(), + 'shipping_status_form': ShippingStatusForm(instance=self.object), + } + ) + return context + + +class OrderUpdateShippingStatusView(BackofficeCRUDMixin, UpdateView): + model = Order + form_class = ShippingStatusForm + permission_required = 'shop.change_order' + section = SECTION + + def get_success_url(self): + return reverse_lazy('backoffice:order_detail', args=[self.object.pk]) + + def form_invalid(self, form): + return self.render_to_response(self.get_context_data(shipping_status_form=form)) + + def get_template_names(self): + return ['backoffice/orders/order_detail.html'] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({'lines': self.object.lines.all(), 'payments': self.object.payments.all()}) + context.setdefault('shipping_status_form', ShippingStatusForm(instance=self.object)) + return context diff --git a/backoffice/views/payments.py b/backoffice/views/payments.py new file mode 100644 index 0000000..0b56249 --- /dev/null +++ b/backoffice/views/payments.py @@ -0,0 +1,31 @@ +from django.views.generic import ListView + +from backoffice.mixins import BackofficeCRUDMixin, BackofficeHtmxMixin +from shop.models import Payment + +SECTION = 'payments' + + +class PaymentListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView): + model = Payment + permission_required = 'shop.view_payment' + section = SECTION + paginate_by = 20 + template_name = 'backoffice/generic/list.html' + fragment_template_name = 'backoffice/generic/_list_fragment.html' + ordering = '-creation_date' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'title': 'Pagos', + 'columns': [ + ('Fecha', 'creation_date'), + ('Pedido', 'order.code'), + ('Importe', 'amount'), + ('Método', 'get_method_display'), + ], + } + ) + return context diff --git a/backoffice/views/products.py b/backoffice/views/products.py new file mode 100644 index 0000000..bb96a55 --- /dev/null +++ b/backoffice/views/products.py @@ -0,0 +1,708 @@ +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 backoffice.forms import ProductVariantForm +from backoffice.mixins import ( + BackofficeCRUDMixin, + BackofficeHtmxMixin, + BackofficeModalDeleteMixin, + BackofficeModalFormMixin, +) +from shop.models import ( + Brand, + Product, + ProductAttribute, + ProductAttributeValue, + ProductBatch, + ProductCategory, + ProductImage, + ProductPrice, + ProductVariant, + Tag, +) +from shop.utils import delete_product_batch + +SECTION = 'products' + +LIST_FRAGMENT = 'backoffice/generic/_list_fragment.html' +FORM_FRAGMENT = 'backoffice/generic/_form_fragment.html' +DELETE_FRAGMENT = 'backoffice/generic/_confirm_delete_fragment.html' + + +# --- Product --- + + +class ProductListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView): + model = Product + permission_required = 'shop.view_product' + section = SECTION + paginate_by = 20 + template_name = 'backoffice/generic/list.html' + fragment_template_name = LIST_FRAGMENT + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'title': 'Productos', + 'columns': [('SKU', 'sku'), ('Nombre', 'name'), ('Stock', 'stock'), ('Oculto', 'hidden')], + 'create_url': reverse_lazy('backoffice:product_create'), + 'detail_url_name': 'backoffice:product_detail', + 'delete_url_name': 'backoffice:product_delete', + } + ) + return context + + +class ProductCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + 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 + + def get_success_url(self): + return reverse_lazy('backoffice:product_detail', args=[self.object.pk]) + + +class ProductDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView): + model = Product + permission_required = 'shop.delete_product' + section = SECTION + template_name = 'backoffice/generic/confirm_delete.html' + fragment_template_name = DELETE_FRAGMENT + success_url = reverse_lazy('backoffice:product_list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['cancel_url'] = reverse_lazy('backoffice:product_list') + return context + + def form_valid(self, form): + # Borrar el producto invalida la página en la que estuvieras (lista o su + # propio detalle), así que aquí sí navegamos de verdad en vez de solo + # cerrar el modal y refrescar en sitio. + success_url = self.get_success_url() + self.perform_delete() + + if self.is_htmx(): + response = HttpResponse(status=200) + response['HX-Redirect'] = success_url + return response + + return HttpResponseRedirect(success_url) + + +class ProductDetailView(BackofficeCRUDMixin, DetailView): + model = Product + permission_required = 'shop.view_product' + section = SECTION + template_name = 'backoffice/products/product_detail.html' + fragment_template_name = 'backoffice/products/_product_detail_fragment.html' + + def get_template_names(self): + if self.request.headers.get('HX-Request') == 'true': + return [self.fragment_template_name] + return super().get_template_names() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['variants'] = self.object.variants.prefetch_related('attribute_values__attribute') + context['batches'] = ProductBatch.objects.filter(product=self.object) + context['images'] = self.object.images.all() + return context + + +# --- ProductImage (nested under a product) --- + + +class ProductImageCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + model = ProductImage + permission_required = 'shop.add_productimage' + section = SECTION + fields = ('original',) + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + + def get_product(self): + return get_object_or_404(Product, pk=self.kwargs['product_pk']) + + def get_form(self, form_class=None): + form = super().get_form(form_class) + form.instance.product = self.get_product() + return form + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + product = self.get_product() + context.update( + {'title': f'Añadir imagen a {product.name}', 'cancel_url': reverse_lazy('backoffice:product_detail', args=[product.pk])} + ) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + + +class ProductImageDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView): + model = ProductImage + permission_required = 'shop.delete_productimage' + section = SECTION + template_name = 'backoffice/generic/confirm_delete.html' + fragment_template_name = DELETE_FRAGMENT + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['cancel_url'] = reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + + +# --- ProductVariant (nested under a product) --- + + +class ProductVariantCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + model = ProductVariant + form_class = ProductVariantForm + permission_required = 'shop.add_productvariant' + section = SECTION + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + + def get_product(self): + return get_object_or_404(Product, pk=self.kwargs['product_pk']) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['product'] = self.get_product() + return kwargs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + product = self.get_product() + context.update( + {'title': f'Añadir variante a {product.name}', 'cancel_url': reverse_lazy('backoffice:product_detail', args=[product.pk])} + ) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + + +class ProductVariantUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView): + model = ProductVariant + form_class = ProductVariantForm + permission_required = 'shop.change_productvariant' + section = SECTION + 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 variante {self.object.sku}', + 'cancel_url': reverse_lazy('backoffice:product_detail', args=[self.object.product_id]), + } + ) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + + +class ProductVariantDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView): + model = ProductVariant + permission_required = 'shop.delete_productvariant' + section = SECTION + template_name = 'backoffice/generic/confirm_delete.html' + fragment_template_name = DELETE_FRAGMENT + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['cancel_url'] = reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + + +# --- ProductPrice (create only — la historia es de solo alta, ver shop/models.py:ProductPrice) --- + + +class ProductPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + model = ProductPrice + permission_required = 'shop.add_productprice' + section = SECTION + fields = ('price', 'tax', 'current') + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + + def get_product(self): + return get_object_or_404(Product, pk=self.kwargs['product_pk']) + + def get_form(self, form_class=None): + # El FK debe fijarse antes de is_valid(), porque ProductPrice.clean() + # exige exactamente uno de product/variant en el momento de validar. + form = super().get_form(form_class) + form.instance.product = self.get_product() + return form + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + product = self.get_product() + context.update( + {'title': f'Añadir precio a {product.name}', 'cancel_url': reverse_lazy('backoffice:product_detail', args=[product.pk])} + ) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + + +class ProductVariantPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + model = ProductPrice + permission_required = 'shop.add_productprice' + section = SECTION + fields = ('price', 'tax', 'current') + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + + def get_variant(self): + return get_object_or_404(ProductVariant, pk=self.kwargs['variant_pk']) + + def get_form(self, form_class=None): + form = super().get_form(form_class) + form.instance.variant = self.get_variant() + return form + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + variant = self.get_variant() + context.update( + { + 'title': f'Añadir precio a la variante {variant.sku}', + 'cancel_url': reverse_lazy('backoffice:product_detail', args=[variant.product_id]), + } + ) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_detail', args=[self.object.variant.product_id]) + + +# --- ProductBatch (nested under a product) --- + + +class ProductBatchCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + model = ProductBatch + permission_required = 'shop.add_productbatch' + section = SECTION + fields = ('code', 'quantity', 'expiration_date', 'provider') + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + + def get_product(self): + return get_object_or_404(Product, pk=self.kwargs['product_pk']) + + def get_form(self, form_class=None): + form = super().get_form(form_class) + form.instance.product = self.get_product() + return form + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + product = self.get_product() + context.update( + {'title': f'Añadir remesa a {product.name}', 'cancel_url': reverse_lazy('backoffice:product_detail', args=[product.pk])} + ) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + + +class ProductBatchDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView): + model = ProductBatch + permission_required = 'shop.delete_productbatch' + section = SECTION + template_name = 'backoffice/generic/confirm_delete.html' + fragment_template_name = DELETE_FRAGMENT + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['cancel_url'] = reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_detail', args=[self.object.product_id]) + + def perform_delete(self): + # Reutiliza la lógica ya existente de descuento de stock al borrar una remesa. + delete_product_batch(self.object) + + +# --- ProductCategory --- + + +class ProductCategoryListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView): + model = ProductCategory + permission_required = 'shop.view_productcategory' + section = SECTION + template_name = 'backoffice/generic/list.html' + fragment_template_name = LIST_FRAGMENT + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'title': 'Categorías de producto', + 'columns': [('Nombre', 'name'), ('Padre', 'parent'), ('Oculta', 'hidden')], + 'create_url': reverse_lazy('backoffice:product_category_create'), + 'update_url_name': 'backoffice:product_category_update', + 'delete_url_name': 'backoffice:product_category_delete', + } + ) + return context + + +class ProductCategoryCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + model = ProductCategory + permission_required = 'shop.add_productcategory' + section = SECTION + fields = ('name', 'parent', 'promoted', 'hidden', 'show_in_navbar') + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + success_url = reverse_lazy('backoffice:product_category_list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({'title': 'Añadir categoría', 'cancel_url': reverse_lazy('backoffice:product_category_list')}) + return context + + +class ProductCategoryUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView): + model = ProductCategory + permission_required = 'shop.change_productcategory' + section = SECTION + fields = ('name', 'parent', 'promoted', 'hidden', 'show_in_navbar') + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + success_url = reverse_lazy('backoffice:product_category_list') + + 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_category_list')}) + return context + + +class ProductCategoryDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView): + model = ProductCategory + permission_required = 'shop.delete_productcategory' + section = SECTION + template_name = 'backoffice/generic/confirm_delete.html' + fragment_template_name = DELETE_FRAGMENT + success_url = reverse_lazy('backoffice:product_category_list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['cancel_url'] = reverse_lazy('backoffice:product_category_list') + return context + + +# --- ProductAttribute / ProductAttributeValue --- + + +class ProductAttributeListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView): + model = ProductAttribute + permission_required = 'shop.view_productattribute' + section = SECTION + template_name = 'backoffice/generic/list.html' + fragment_template_name = LIST_FRAGMENT + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'title': 'Atributos de producto', + 'columns': [('Nombre', 'name')], + 'create_url': reverse_lazy('backoffice:product_attribute_create'), + 'detail_url_name': 'backoffice:product_attribute_detail', + 'update_url_name': 'backoffice:product_attribute_update', + 'delete_url_name': 'backoffice:product_attribute_delete', + } + ) + return context + + +class ProductAttributeCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + model = ProductAttribute + permission_required = 'shop.add_productattribute' + section = SECTION + fields = ('name',) + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + success_url = reverse_lazy('backoffice:product_attribute_list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({'title': 'Añadir atributo', 'cancel_url': reverse_lazy('backoffice:product_attribute_list')}) + return context + + +class ProductAttributeUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView): + """Solo edita el nombre del atributo. Los valores se gestionan en ProductAttributeDetailView.""" + + model = ProductAttribute + permission_required = 'shop.change_productattribute' + section = SECTION + fields = ('name',) + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + success_url = reverse_lazy('backoffice:product_attribute_list') + + 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_attribute_list')}) + return context + + +class ProductAttributeDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView): + model = ProductAttribute + permission_required = 'shop.delete_productattribute' + section = SECTION + template_name = 'backoffice/generic/confirm_delete.html' + fragment_template_name = DELETE_FRAGMENT + success_url = reverse_lazy('backoffice:product_attribute_list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['cancel_url'] = reverse_lazy('backoffice:product_attribute_list') + return context + + +class ProductAttributeDetailView(BackofficeCRUDMixin, DetailView): + """Gestión de los valores (ProductAttributeValue) de un atributo.""" + + model = ProductAttribute + permission_required = 'shop.view_productattribute' + section = SECTION + template_name = 'backoffice/products/attribute_detail.html' + fragment_template_name = 'backoffice/products/_attribute_detail_fragment.html' + + def get_template_names(self): + if self.request.headers.get('HX-Request') == 'true': + return [self.fragment_template_name] + return super().get_template_names() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['values'] = self.object.values.all() + return context + + +class ProductAttributeValueCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + model = ProductAttributeValue + permission_required = 'shop.add_productattributevalue' + section = SECTION + fields = ('value',) + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + + def get_attribute(self): + return get_object_or_404(ProductAttribute, pk=self.kwargs['attribute_pk']) + + def get_form(self, form_class=None): + form = super().get_form(form_class) + form.instance.attribute = self.get_attribute() + return form + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + attribute = self.get_attribute() + context.update( + { + 'title': f'Añadir valor a {attribute.name}', + 'cancel_url': reverse_lazy('backoffice:product_attribute_detail', args=[attribute.pk]), + } + ) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_attribute_detail', args=[self.object.attribute_id]) + + +class ProductAttributeValueDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView): + model = ProductAttributeValue + permission_required = 'shop.delete_productattributevalue' + section = SECTION + template_name = 'backoffice/generic/confirm_delete.html' + fragment_template_name = DELETE_FRAGMENT + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['cancel_url'] = reverse_lazy('backoffice:product_attribute_detail', args=[self.object.attribute_id]) + return context + + def get_success_url(self): + return reverse_lazy('backoffice:product_attribute_detail', args=[self.object.attribute_id]) + + +# --- Brand / Tag --- + + +class BrandListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView): + model = Brand + permission_required = 'shop.view_brand' + section = SECTION + template_name = 'backoffice/generic/list.html' + fragment_template_name = LIST_FRAGMENT + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'title': 'Marcas', + 'columns': [('Nombre', 'name')], + 'create_url': reverse_lazy('backoffice:brand_create'), + 'update_url_name': 'backoffice:brand_update', + 'delete_url_name': 'backoffice:brand_delete', + } + ) + return context + + +class BrandCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + model = Brand + permission_required = 'shop.add_brand' + section = SECTION + fields = ('name',) + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + success_url = reverse_lazy('backoffice:brand_list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({'title': 'Añadir marca', 'cancel_url': reverse_lazy('backoffice:brand_list')}) + return context + + +class BrandUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView): + model = Brand + permission_required = 'shop.change_brand' + section = SECTION + fields = ('name',) + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + success_url = reverse_lazy('backoffice:brand_list') + + 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:brand_list')}) + return context + + +class BrandDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView): + model = Brand + permission_required = 'shop.delete_brand' + section = SECTION + template_name = 'backoffice/generic/confirm_delete.html' + fragment_template_name = DELETE_FRAGMENT + success_url = reverse_lazy('backoffice:brand_list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['cancel_url'] = reverse_lazy('backoffice:brand_list') + return context + + +class TagListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView): + model = Tag + permission_required = 'shop.view_tag' + section = SECTION + template_name = 'backoffice/generic/list.html' + fragment_template_name = LIST_FRAGMENT + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'title': 'Etiquetas', + 'columns': [('Nombre', 'name')], + 'create_url': reverse_lazy('backoffice:tag_create'), + 'update_url_name': 'backoffice:tag_update', + 'delete_url_name': 'backoffice:tag_delete', + } + ) + return context + + +class TagCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): + model = Tag + permission_required = 'shop.add_tag' + section = SECTION + fields = ('name',) + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + success_url = reverse_lazy('backoffice:tag_list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({'title': 'Añadir etiqueta', 'cancel_url': reverse_lazy('backoffice:tag_list')}) + return context + + +class TagUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView): + model = Tag + permission_required = 'shop.change_tag' + section = SECTION + fields = ('name',) + template_name = 'backoffice/generic/form.html' + fragment_template_name = FORM_FRAGMENT + success_url = reverse_lazy('backoffice:tag_list') + + 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:tag_list')}) + return context + + +class TagDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView): + model = Tag + permission_required = 'shop.delete_tag' + section = SECTION + template_name = 'backoffice/generic/confirm_delete.html' + fragment_template_name = DELETE_FRAGMENT + success_url = reverse_lazy('backoffice:tag_list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['cancel_url'] = reverse_lazy('backoffice:tag_list') + return context diff --git a/backoffice/views/providers.py b/backoffice/views/providers.py new file mode 100644 index 0000000..6d89858 --- /dev/null +++ b/backoffice/views/providers.py @@ -0,0 +1,29 @@ +from django.views.generic import ListView + +from backoffice.mixins import BackofficeCRUDMixin, BackofficeHtmxMixin +from shop.models import Provider + +SECTION = 'providers' + + +class ProviderListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView): + model = Provider + permission_required = 'shop.view_provider' + section = SECTION + paginate_by = 20 + template_name = 'backoffice/generic/list.html' + fragment_template_name = 'backoffice/generic/_list_fragment.html' + ordering = 'name' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + 'title': 'Proveedores', + 'columns': [('Nombre', 'name'), ('NIF/CIF', 'vat_id'), ('E-mail', 'email'), ('Teléfono', 'phone')], + } + ) + return context + +# CRUD pendiente: replicar ProductCategoryCreateView/UpdateView/DeleteView de +# backoffice/views/products.py sobre el modelo Provider. diff --git a/backoffice/views/settings.py b/backoffice/views/settings.py new file mode 100644 index 0000000..74b13bd --- /dev/null +++ b/backoffice/views/settings.py @@ -0,0 +1,59 @@ +from django.urls import reverse_lazy +from django.views.generic import UpdateView + +from backoffice.mixins import BackofficeCRUDMixin, BackofficeStyledFormMixin +from shop.models import ShopSettings +from web.models import WebSettings + +SECTION = 'settings' + + +class BrandSettingsView(BackofficeCRUDMixin, BackofficeStyledFormMixin, UpdateView): + model = WebSettings + permission_required = 'web.change_websettings' + section = SECTION + fields = ( + 'web_title', + 'web_description', + 'logo', + 'business_name', + 'business_vat_id', + 'business_brand', + 'business_address', + 'business_state', + 'business_zip', + 'business_phone', + 'business_email', + 'business_email_2', + 'bg_color', + 'theme_color', + ) + template_name = 'backoffice/generic/form.html' + success_url = reverse_lazy('backoffice:brand_settings') + + def get_object(self, queryset=None): + obj, created = WebSettings.objects.get_or_create(pk=1) + return obj + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({'title': 'Marca y ajustes de la web', 'cancel_url': reverse_lazy('backoffice:dashboard')}) + return context + + +class ShopSettingsView(BackofficeCRUDMixin, BackofficeStyledFormMixin, UpdateView): + model = ShopSettings + permission_required = 'shop.change_shopsettings' + section = SECTION + fields = ('merchant_code', 'currency_code', 'terminal', 'shared_secret', 'tpv_domain') + template_name = 'backoffice/generic/form.html' + success_url = reverse_lazy('backoffice:shop_settings') + + def get_object(self, queryset=None): + obj, created = ShopSettings.objects.get_or_create(pk=1) + return obj + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({'title': 'Ajustes de la tienda (TPV)', 'cancel_url': reverse_lazy('backoffice:dashboard')}) + return context diff --git a/backoffice/views/taxes.py b/backoffice/views/taxes.py new file mode 100644 index 0000000..1010892 --- /dev/null +++ b/backoffice/views/taxes.py @@ -0,0 +1,22 @@ +from django.views.generic import ListView + +from backoffice.mixins import BackofficeCRUDMixin, BackofficeHtmxMixin +from shop.models import Tax + +SECTION = 'taxes' + + +class TaxListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView): + model = Tax + permission_required = 'shop.view_tax' + section = SECTION + template_name = 'backoffice/generic/list.html' + fragment_template_name = 'backoffice/generic/_list_fragment.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({'title': 'Impuestos', 'columns': [('Código', 'code'), ('Valor (%)', 'value')]}) + return context + +# CRUD pendiente: replicar ProductCategoryCreateView/UpdateView/DeleteView de +# backoffice/views/products.py sobre el modelo Tax. diff --git a/config/settings/base.py b/config/settings/base.py index b9c8318..bb3f4bb 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -40,7 +40,7 @@ THIRD_PARTY_APPS = [ 'gonk', ] -PROJECT_APPS = ['shop', 'users', 'web'] +PROJECT_APPS = ['shop', 'users', 'web', 'backoffice'] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + PROJECT_APPS diff --git a/config/urls.py b/config/urls.py index 85447c5..f55231f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('watchman/', include('watchman.urls')), path('tpv/', include('shop.urls', namespace='shop')), + path('backoffice/', include('backoffice.urls', namespace='backoffice')), ]