feat: backoffice setup
CI / ci (ubuntu-24.04, 3.13) (push) Has been cancelled

This commit is contained in:
2026-07-23 13:31:53 +02:00
parent 87405f31fa
commit 2a9407e676
45 changed files with 2148 additions and 1 deletions
View File
+31
View File
@@ -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
+134
View File
@@ -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 '<html' not in body
assert 'Añadir producto' in body
def test_htmx_create_success_returns_204_and_triggers_modal_close_and_refresh(self):
response = self.client.post(
reverse('backoffice:product_create'),
{
'sku': 'HTMX-1',
'name': 'Producto htmx',
'description': '',
'stock': '0',
'categories': [],
'tags': [],
},
HTTP_HX_REQUEST='true',
)
assert response.status_code == 204
trigger = json.loads(response['HX-Trigger'])
assert trigger == {'backoffice:modal-close': True, 'backoffice:list-changed': True}
assert Product.objects.filter(sku='HTMX-1').exists()
def test_htmx_create_invalid_returns_200_with_errors_not_204(self):
response = self.client.post(
reverse('backoffice:product_create'),
{'sku': '', 'name': '', 'description': '', 'stock': '0', 'categories': [], 'tags': []},
HTTP_HX_REQUEST='true',
)
assert response.status_code == 200
assert 'HX-Trigger' not in response
def test_non_htmx_create_still_redirects(self):
response = self.client.post(
reverse('backoffice:product_create'),
{
'sku': 'PLAIN-1',
'name': 'Producto normal',
'description': '',
'stock': '0',
'categories': [],
'tags': [],
},
)
assert response.status_code == 302
assert 'HX-Trigger' not in response
def test_htmx_variant_create_triggers_refresh(self):
size_m = self.create_attribute_value('Talla', 'M')
response = self.client.post(
reverse('backoffice:product_variant_create', args=[self.product.pk]),
{'sku': 'HTMX-V1', 'stock': '5', 'attribute_values': [size_m.pk]},
HTTP_HX_REQUEST='true',
)
assert response.status_code == 204
trigger = json.loads(response['HX-Trigger'])
assert trigger['backoffice:modal-close'] is True
assert trigger['backoffice:list-changed'] is True
assert ProductVariant.objects.filter(sku='HTMX-V1').exists()
def test_htmx_variant_delete_stays_and_refreshes(self):
size_m = self.create_attribute_value('Talla', 'M')
variant = self.create_product_variant(self.product, sku='HTMX-V2', attribute_values=[size_m])
response = self.client.post(
reverse('backoffice:product_variant_delete', args=[variant.pk]), HTTP_HX_REQUEST='true'
)
assert response.status_code == 204
assert 'HX-Redirect' not in response
trigger = json.loads(response['HX-Trigger'])
assert trigger['backoffice:list-changed'] is True
assert not ProductVariant.objects.filter(pk=variant.pk).exists()
def test_htmx_image_upload_triggers_refresh(self):
image = Image.new('RGB', (64, 64), '#ACACAC')
buffer = BytesIO()
image.save(fp=buffer, format='WEBP')
file = ContentFile(buffer.getvalue(), name='test.webp')
response = self.client.post(
reverse('backoffice:product_image_create', args=[self.product.pk]),
{'original': file},
format='multipart',
HTTP_HX_REQUEST='true',
)
assert response.status_code == 204
trigger = json.loads(response['HX-Trigger'])
assert trigger == {'backoffice:modal-close': True, 'backoffice:list-changed': True}
assert ProductImage.objects.filter(product=self.product).exists()
def test_htmx_product_delete_uses_hx_redirect_instead_of_trigger(self):
product = self.create_product(sku='HTMX-DEL')
response = self.client.post(reverse('backoffice:product_delete', args=[product.pk]), HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert response['HX-Redirect'] == reverse('backoffice:product_list')
assert 'HX-Trigger' not in response
assert not Product.objects.filter(pk=product.pk).exists()
def test_htmx_list_self_refresh_returns_fragment(self):
response = self.client.get(reverse('backoffice:product_list'), HTTP_HX_REQUEST='true')
assert response.status_code == 200
body = response.content.decode()
assert '<html' not in body
assert 'id="backoffice-list"' in body
def test_htmx_product_detail_self_refresh_returns_fragment(self):
response = self.client.get(reverse('backoffice:product_detail', args=[self.product.pk]), HTTP_HX_REQUEST='true')
assert response.status_code == 200
body = response.content.decode()
assert '<html' not in body
assert 'id="backoffice-detail"' in body
+50
View File
@@ -0,0 +1,50 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from shop.models import Order
from shop.tests.mixins import CreateProductsMixin
class TestBackofficeOrders(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()
self.order = Order.objects.create(
email='client@example.com',
billing_address='Tatooine',
billing_city='Mos Eisley',
billing_state='Tatooine',
billing_country='Tatooine',
billing_zip='00001',
shipping_address='Tatooine',
shipping_city='Mos Eisley',
shipping_state='Tatooine',
shipping_country='Tatooine',
shipping_zip='00001',
)
def test_order_list(self):
response = self.client.get(reverse('backoffice:order_list'))
assert response.status_code == 200
assert self.order.code in response.content.decode()
def test_order_list_filtered_by_status(self):
response = self.client.get(reverse('backoffice:order_list'), {'status': Order.Statuses.STATUS_PAID})
assert response.status_code == 200
assert self.order.code not in response.content.decode()
def test_order_detail(self):
response = self.client.get(reverse('backoffice:order_detail', args=[self.order.pk]))
assert response.status_code == 200
def test_update_shipping_status(self):
response = self.client.post(
reverse('backoffice:order_update_shipping_status', args=[self.order.pk]),
{'shipping_status': Order.ShippingStatuses.STATUS_SENT},
)
assert response.status_code == 302
self.order.refresh_from_db()
assert self.order.shipping_status == Order.ShippingStatuses.STATUS_SENT
+116
View File
@@ -0,0 +1,116 @@
from decimal import Decimal
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, ProductPrice, ProductVariant, Tax
from shop.tests.mixins import CreateProductsMixin
class TestBackofficeProducts(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_product_list(self):
response = self.client.get(reverse('backoffice:product_list'))
assert response.status_code == 200
assert self.product.name in response.content.decode()
def test_create_product(self):
response = self.client.post(
reverse('backoffice:product_create'),
{
'sku': 'NEW-1',
'name': 'Producto nuevo',
'description': '',
'stock': '0',
'categories': [],
'tags': [],
},
)
assert response.status_code == 302
assert Product.objects.filter(sku='NEW-1').exists()
def test_create_variant_for_product(self):
size_m = self.create_attribute_value('Talla', 'M')
response = self.client.post(
reverse('backoffice:product_variant_create', args=[self.product.pk]),
{'sku': 'V-M', 'stock': '5', 'attribute_values': [size_m.pk]},
)
assert response.status_code == 302
variant = ProductVariant.objects.get(sku='V-M')
assert variant.product == self.product
assert size_m in variant.attribute_values.all()
def test_create_duplicated_variant_combination_fails(self):
size_m = self.create_attribute_value('Talla', 'M')
self.create_product_variant(self.product, sku='V-M1', attribute_values=[size_m])
response = self.client.post(
reverse('backoffice:product_variant_create', args=[self.product.pk]),
{'sku': 'V-M2', 'stock': '5', 'attribute_values': [size_m.pk]},
)
assert response.status_code == 200
assert not ProductVariant.objects.filter(sku='V-M2').exists()
def test_create_price_for_product(self):
tax, created = Tax.objects.get_or_create(code='IVA', value=21)
response = self.client.post(
reverse('backoffice:product_price_create', args=[self.product.pk]),
{'price': '9.99', 'tax': tax.pk, 'current': 'on'},
)
assert response.status_code == 302
assert ProductPrice.objects.filter(product=self.product, price=Decimal('9.99')).exists()
def test_create_price_for_variant(self):
tax, created = Tax.objects.get_or_create(code='IVA', value=21)
size_m = self.create_attribute_value('Talla', 'M')
variant = self.create_product_variant(self.product, sku='V-M', attribute_values=[size_m])
response = self.client.post(
reverse('backoffice:product_variant_price_create', args=[variant.pk]),
{'price': '19.99', 'tax': tax.pk, 'current': 'on'},
)
assert response.status_code == 302
assert ProductPrice.objects.filter(variant=variant, price=Decimal('19.99')).exists()
def create_image_file(self, name='test.webp'):
image = Image.new('RGB', (256, 256), '#ACACAC')
buffer = BytesIO()
image.save(fp=buffer, format='WEBP')
return ContentFile(buffer.getvalue(), name=name)
def test_create_image_for_product(self):
response = self.client.post(
reverse('backoffice:product_image_create', args=[self.product.pk]),
{'original': self.create_image_file()},
format='multipart',
)
assert response.status_code == 302
assert ProductImage.objects.filter(product=self.product).exists()
def test_delete_image(self):
image = ProductImage.objects.create(product=self.product, original=self.create_image_file())
response = self.client.post(reverse('backoffice:product_image_delete', args=[image.pk]))
assert response.status_code == 302
assert not ProductImage.objects.filter(pk=image.pk).exists()
def test_delete_batch_reduces_stock(self):
self.product.stock = Decimal('10')
self.product.save()
batch = self.product.productbatch_set.create(code='B1', quantity=Decimal('3'))
response = self.client.post(reverse('backoffice:product_batch_delete', args=[batch.pk]))
assert response.status_code == 302
self.product.refresh_from_db()
assert self.product.stock == Decimal('7')
+40
View File
@@ -0,0 +1,40 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from shop.models import ShopSettings
from web.models import WebSettings
class TestBackofficeSettings(TestCase):
def setUp(self):
self.superuser = User.objects.create_superuser('vader', 'vader@empire.com', 'ihatesand')
self.client.force_login(self.superuser)
def test_get_brand_settings(self):
response = self.client.get(reverse('backoffice:brand_settings'))
assert response.status_code == 200
def test_update_brand_settings(self):
response = self.client.post(
reverse('backoffice:brand_settings'),
{'web_title': 'Mi tienda', 'web_description': '', 'bg_color': '', 'theme_color': ''},
)
assert response.status_code == 302
settings = WebSettings.objects.get(pk=1)
assert settings.web_title == 'Mi tienda'
def test_get_shop_settings(self):
response = self.client.get(reverse('backoffice:shop_settings'))
assert response.status_code == 200
def test_update_shop_settings(self):
response = self.client.post(
reverse('backoffice:shop_settings'),
{'merchant_code': '999008881', 'currency_code': '978', 'terminal': '001', 'shared_secret': 'secret'},
)
assert response.status_code == 302
settings = ShopSettings.objects.get(pk=1)
assert settings.merchant_code == '999008881'