diff --git a/.env.example b/.env.example index 9a50945..7cdfeed 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ SECRET_KEY=django-insecure-change-me DEBUG=True ALLOWED_HOSTS=localhost,127.0.0.1 +S3_ENABLE=False # PostgreSQL (por defecto habilitado; si POSTGRES_ENABLED=False se usa SQLite) POSTGRES_ENABLED=True @@ -14,6 +15,17 @@ POSTGRES_PORT=5432 # Redis REDIS_URL=redis://127.0.0.1:6379/1 +# S3 (activar con S3_ENABLE=True) +AWS_STORAGE_BUCKET_NAME= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_S3_REGION_NAME= +AWS_S3_ENDPOINT_URL= +AWS_S3_CUSTOM_DOMAIN= +AWS_S3_USE_SSL=True +AWS_QUERYSTRING_AUTH=False +AWS_DEFAULT_ACL=public-read + # Stripe STRIPE_PUBLISHABLE_KEY= STRIPE_SECRET_KEY= diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b3470d7..b9cdc65 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -35,6 +35,7 @@ Templates use Django's inheritance pattern: - **Image uploads**: Organized in `tienda/static/media/images/` via `upload_to='images/'` in ImageField - **Access**: Media files served automatically in development via Django's static file handler - **Image model**: Located in [tienda/models.py](tienda/models.py) with `ImageField(upload_to='images/')` +- **S3 mode**: if `S3_ENABLE=True` (case-insensitive), static and media switch to S3 storages instead of the local filesystem; Nginx should proxy the app only and the browser should load asset URLs from the bucket or CDN ## Shipping Restrictions - **Zona de envío**: Solo se vende/envía dentro de la provincia de Almería diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..515af89 --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,33 @@ +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run opencode + uses: anomalyco/opencode/github@latest + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + with: + model: openai/gpt-5.3-codex \ No newline at end of file diff --git a/.gitignore b/.gitignore index afb4ca3..1fe2ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ __pycache__/ *.pyc tienda/__pycache__/ proyecto/__pycache__/ +media \ No newline at end of file diff --git a/nginx.conf b/nginx.conf index a2a3e58..7e100bf 100644 --- a/nginx.conf +++ b/nginx.conf @@ -34,7 +34,9 @@ http { listen 80; server_name _; - # Archivos estáticos generados por collectstatic. + # Modo local: sirve static/media desde volúmenes montados. + # Si S3_ENABLE=True, estos bloques no se usan y el navegador debe + # cargar los assets directamente desde el bucket o CDN. location /static/ { alias /static/; expires 30d; @@ -42,7 +44,7 @@ http { access_log off; } - # Archivos subidos por usuarios. + # Archivos subidos por usuarios en modo local. location /media/ { alias /media/; expires 7d; diff --git a/proyecto/settings.py b/proyecto/settings.py index 2732df4..6e66468 100644 --- a/proyecto/settings.py +++ b/proyecto/settings.py @@ -53,6 +53,21 @@ def env_int(name: str, default: int) -> int: return default return int(value) + +def env_str(name: str, default: str = '') -> str: + value = os.getenv(name) + if value is None: + return default + return value.strip() + + +def env_optional_str(name: str) -> str | None: + value = os.getenv(name) + if value is None: + return None + value = value.strip() + return value or None + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent load_dotenv(BASE_DIR / '.env') @@ -66,6 +81,8 @@ SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-#g((q@lvnkt(j6)2(gvtn0px)r # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env_bool('DEBUG', True) +S3_ENABLE = env_bool('S3_ENABLE', False) +S3_USE_LOCAL_URLS = env_bool('S3_USE_LOCAL_URLS', False) ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [ '192.168.1.142', @@ -87,9 +104,11 @@ INSTALLED_APPS = [ 'compressor', ] +if S3_ENABLE: + INSTALLED_APPS.append('storages') + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -98,6 +117,9 @@ MIDDLEWARE = [ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +if not S3_ENABLE: + MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware') + ROOT_URLCONF = 'proyecto.urls' TEMPLATES = [ @@ -211,6 +233,27 @@ STORAGES = { }, } +if S3_ENABLE: + AWS_STORAGE_BUCKET_NAME = env_str('AWS_STORAGE_BUCKET_NAME') or None + AWS_ACCESS_KEY_ID = env_optional_str('AWS_ACCESS_KEY_ID') + AWS_SECRET_ACCESS_KEY = env_optional_str('AWS_SECRET_ACCESS_KEY') + AWS_S3_REGION_NAME = env_optional_str('AWS_S3_REGION_NAME') + AWS_S3_ENDPOINT_URL = env_optional_str('AWS_S3_ENDPOINT_URL') + AWS_S3_CUSTOM_DOMAIN = env_optional_str('AWS_S3_CUSTOM_DOMAIN') + AWS_S3_USE_SSL = env_bool('AWS_S3_USE_SSL', True) + AWS_QUERYSTRING_AUTH = env_bool('AWS_QUERYSTRING_AUTH', False) + AWS_DEFAULT_ACL = env_str('AWS_DEFAULT_ACL', 'public-read') or None + AWS_S3_OBJECT_PARAMETERS = {} + + STORAGES = { + 'default': { + 'BACKEND': 'tienda.storage_backends.MediaStorage', + }, + 'staticfiles': { + 'BACKEND': 'tienda.storage_backends.StaticStorage', + }, + } + STATICFILES_FINDERS = [ 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', @@ -384,6 +427,4 @@ CELERY_RESULT_SERIALIZER = 'json' SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") USE_X_FORWARDED_HOST = True -SECURE_REFERER_POLICY = "strict-origin-when-cross-origin" - -print(f"DEBUG: ALLOWED_HOSTS is {ALLOWED_HOSTS}") \ No newline at end of file +SECURE_REFERER_POLICY = "strict-origin-when-cross-origin" \ No newline at end of file diff --git a/proyecto/urls.py b/proyecto/urls.py index ca32905..d7813f5 100644 --- a/proyecto/urls.py +++ b/proyecto/urls.py @@ -26,5 +26,7 @@ urlpatterns = [ path('tienda/', include('tienda.urls')) ] -if settings.DEBUG: +if settings.DEBUG and ( + not settings.S3_ENABLE or getattr(settings, 'S3_USE_LOCAL_URLS', False) +): urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/requirements.txt b/requirements.txt index 4b6da18..b1b1161 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ Django==6.0.4 django-appconf==1.2.0 django-redis==5.4.0 django_compressor==4.6.0 +django-storages[boto3]==1.14.6 gunicorn==25.1.0 idna==3.11 Jinja2==3.1.6 @@ -22,6 +23,7 @@ MarkupSafe==3.0.3 packaging==26.0 paypalrestsdk==1.13.3 pillow==12.2.0 +boto3==1.42.97 prompt_toolkit==3.0.52 pycparser==3.0 pyOpenSSL==26.0.0 diff --git a/tienda/models.py b/tienda/models.py index 56d986e..f15a28f 100644 --- a/tienda/models.py +++ b/tienda/models.py @@ -25,6 +25,11 @@ class User(AbstractUser): choices = RegisterStatus.choices, default = RegisterStatus.CONFIRMATION_REQUIRED ) + def to_dict(self): + return { + "username": self.username, + "fullname": self.get_full_name() + } class VerificationCode(models.Model): class VerificationModes(models.TextChoices): @@ -41,7 +46,7 @@ class VerificationCode(models.Model): def generate(user: User, code_mode: str) -> VerificationCode: while True: - code = "".join(random.choices(string.ascii_letters+string.digits+string.punctuation)) + code = "".join(random.choices(string.ascii_letters+string.digits, k=64)) if not VerificationCode.objects.filter(code=code).exists(): return VerificationCode.objects.create( code = code, @@ -55,6 +60,11 @@ class Category(models.Model): def __str__(self): return self.name + + def to_dict(self): + return { + "name": self.name + } class Image(models.Model): name = models.CharField(max_length=200, default="") @@ -63,6 +73,13 @@ class Image(models.Model): def __str__(self): return self.name + + def to_dict(self): + return { + "name": self.name, + "image": self.image.url, + "alt": self.alt + } class Product(models.Model): name = models.CharField(max_length=200, default="") @@ -85,6 +102,19 @@ class Product(models.Model): def get_vat_amount(self): """Retorna la cantidad de IVA""" return round(self.price * VAT_RATE, 2) + + def to_dict(self): + return { + "name": self.name, + "description": self.description, + "briefdesc": self.briefdesc, + "price": self.price, + "stock": self.stock, + "category": self.category.to_dict(), + "primary_image": self.primary_image.to_dict() if self.primary_image else None, + "secondary_images": [secondary_image.to_dict() for secondary_image in self.secondary_images.all()], + "creator": self.creator.to_dict() if self.creator else None + } class StockReservation(models.Model): diff --git a/tienda/static/css/custom.css b/tienda/static/css/custom.css index 5a77645..56a7bce 100644 --- a/tienda/static/css/custom.css +++ b/tienda/static/css/custom.css @@ -1,3 +1,28 @@ +.skip-link { + position: fixed; + top: -100%; + left: 50%; + transform: translateX(-50%); + background: #fff; + color: #513CB0; + padding: 8px 24px; + font-weight: 700; + font-size: 0.9rem; + z-index: 10001; + text-decoration: none; + border-radius: 0 0 8px 8px; + border: 2px solid #513CB0; + border-top: none; + box-shadow: 0 4px 12px rgba(81, 60, 176, 0.25); + transition: top 0.2s ease; + outline: none; + white-space: nowrap; +} +.skip-link:focus, +.skip-link:focus-visible { + top: 0; +} + @media (min-width: 1250px) { .grid { display: grid; @@ -63,8 +88,9 @@ p.price { .navbar.header .site-title-mobile { color: #FFF; position: absolute; + top: calc(var(--bs-navbar-padding-y) + 20px); left: 50%; - transform: translateX(-50%); + transform: translate(-50%, -50%); margin: 0; max-width: calc(100% - 9rem); overflow: hidden; diff --git a/tienda/storage_backends.py b/tienda/storage_backends.py new file mode 100644 index 0000000..bad7897 --- /dev/null +++ b/tienda/storage_backends.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import os + +from django.utils.encoding import iri_to_uri +from storages.backends.s3 import S3Storage + + +def _use_local_asset_urls() -> bool: + return os.getenv('S3_USE_LOCAL_URLS', '').strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _local_asset_url(prefix: str, name: str) -> str: + return iri_to_uri(f'/{prefix}/{name.lstrip("/")}') + + +class StaticStorage(S3Storage): + location = 'static' + default_acl = 'public-read' + querystring_auth = False + file_overwrite = True + object_parameters = { + 'CacheControl': 'public, max-age=31536000, immutable', + } + + def url(self, name: str) -> str: + if _use_local_asset_urls(): + return _local_asset_url('static', name) + return super().url(name) + + +class MediaStorage(S3Storage): + location = 'media' + default_acl = 'public-read' + querystring_auth = False + file_overwrite = False + object_parameters = { + 'CacheControl': 'public, max-age=604800', + } + + def url(self, name: str) -> str: + if _use_local_asset_urls(): + return _local_asset_url('media', name) + return super().url(name) \ No newline at end of file diff --git a/tienda/templates/tienda/base.html b/tienda/templates/tienda/base.html index f4d6708..8cc1491 100644 --- a/tienda/templates/tienda/base.html +++ b/tienda/templates/tienda/base.html @@ -50,8 +50,11 @@ transition: background-color 0.2s; } - .search-suggestion-item:hover { + .search-suggestion-item:hover, + .search-suggestion-item.active { background-color: #f8f9fa; + outline: 2px solid #0d6efd; + outline-offset: -2px; } .search-suggestion-item:last-child { @@ -78,6 +81,7 @@ {% block head %}{% endblock %} + {% cache 500 sidebar request.user.username %} {% endcache %} -
+
{% if messages %}
@@ -201,6 +205,35 @@ const searchSuggestions = document.getElementById('searchSuggestions'); const searchForm = document.getElementById('searchForm'); let searchTimeout; + let currentFocusIndex = -1; + + // Helpers para gestionar el estado ARIA del combobox + function openSuggestions() { + searchSuggestions.classList.add('show'); + searchInput.setAttribute('aria-expanded', 'true'); + } + + function closeSuggestions() { + searchSuggestions.classList.remove('show'); + searchInput.setAttribute('aria-expanded', 'false'); + searchInput.setAttribute('aria-activedescendant', ''); + currentFocusIndex = -1; + } + + function updateFocus(options) { + options.forEach((option, index) => { + const active = index === currentFocusIndex; + option.classList.toggle('active', active); + option.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + if (currentFocusIndex >= 0) { + const activeOption = options[currentFocusIndex]; + searchInput.setAttribute('aria-activedescendant', activeOption.id); + activeOption.scrollIntoView({ block: 'nearest' }); + } else { + searchInput.setAttribute('aria-activedescendant', ''); + } + } // Escuchar cambios en el input searchInput.addEventListener('input', function() { @@ -208,7 +241,7 @@ const query = this.value.trim(); if (query.length < 2) { - searchSuggestions.classList.remove('show'); + closeSuggestions(); return; } @@ -218,6 +251,31 @@ }, 300); }); + // Navegación por teclado (ArrowDown/ArrowUp/Enter/Escape) + searchInput.addEventListener('keydown', function(event) { + const options = searchSuggestions.querySelectorAll('[role="option"]'); + if (!options.length || !searchSuggestions.classList.contains('show')) { + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + currentFocusIndex = Math.min(currentFocusIndex + 1, options.length - 1); + updateFocus(options); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + currentFocusIndex = Math.max(currentFocusIndex - 1, -1); + updateFocus(options); + } else if (event.key === 'Enter' && currentFocusIndex >= 0) { + event.preventDefault(); + const selected = options[currentFocusIndex]; + window.location.href = selected.dataset.href; + } else if (event.key === 'Escape') { + closeSuggestions(); + searchInput.focus(); + } + }); + // Función para obtener sugerencias del servidor function fetchSuggestions(query) { fetch(`{% url 'search_suggestions' %}?q=${encodeURIComponent(query)}`) @@ -227,33 +285,37 @@ }) .catch(error => { console.error('Error fetching suggestions:', error); - searchSuggestions.classList.remove('show'); + closeSuggestions(); }); } // Función para mostrar las sugerencias function displaySuggestions(suggestions, query) { + currentFocusIndex = -1; if (suggestions.length === 0) { searchSuggestions.innerHTML = '
No se encontraron productos
'; - searchSuggestions.classList.add('show'); + openSuggestions(); return; } let html = ''; - suggestions.forEach(suggestion => { + suggestions.forEach((suggestion, index) => { // Resaltar la coincidencia en el nombre const highlightedName = highlightMatch(suggestion.name, query); const priceWithVAT = (suggestion.price * 1.21).toFixed(2); html += ` - + `; }); searchSuggestions.innerHTML = html; - searchSuggestions.classList.add('show'); + openSuggestions(); } // Función para resaltar el texto que coincide @@ -265,19 +327,19 @@ // Cerrar sugerencias cuando se hace clic fuera document.addEventListener('click', function(event) { if (!searchForm.contains(event.target)) { - searchSuggestions.classList.remove('show'); + closeSuggestions(); } }); // Cerrar sugerencias al enviar el formulario searchForm.addEventListener('submit', function() { - searchSuggestions.classList.remove('show'); + closeSuggestions(); }); // Mostrar sugerencias al hacer clic en el input (si hay texto) searchInput.addEventListener('focus', function() { if (this.value.trim().length >= 2 && searchSuggestions.innerHTML) { - searchSuggestions.classList.add('show'); + openSuggestions(); } }); diff --git a/tienda/templates/tienda/cart.html b/tienda/templates/tienda/cart.html index bcde235..5f2bd7b 100644 --- a/tienda/templates/tienda/cart.html +++ b/tienda/templates/tienda/cart.html @@ -51,7 +51,7 @@
{% csrf_token %} - +
@@ -59,7 +59,7 @@ {% if item.product.stock > 0 %} {{ item.product.stock }} {% else %} - 0 + Sin stock {% endif %} {{ item.get_subtotal_with_vat|format_price }} € @@ -89,7 +89,7 @@
IVA (21%) - {{ cart.get_vat_amount|format_price }} € + {{ cart.get_vat_amount|format_price }} €
Envío diff --git a/tienda/templates/tienda/checkout.html b/tienda/templates/tienda/checkout.html index 1b88ee9..b1135e5 100644 --- a/tienda/templates/tienda/checkout.html +++ b/tienda/templates/tienda/checkout.html @@ -127,22 +127,25 @@ -
+
{% if saved_cards %} -
-

Tarjetas guardadas:

+
+ Selección de tarjeta {% for card in saved_cards %}
@@ -156,7 +159,7 @@
-
+ {% endif %}
@@ -183,7 +186,8 @@
-
+
{% if saved_paypal %}
Cuenta PayPal guardada: @@ -196,6 +200,7 @@ Guardar esta cuenta de PayPal para futuras compras
+
{% if not addresses or stock_issues %}
Selecciona una dirección de envío válida para activar el pago.
@@ -221,12 +226,42 @@ const HAS_STOCK_ISSUES = {{ stock_issues|yesno:"true,false" }}; const HAS_ADDRESS = {{ addresses|yesno:"true,false" }}; // ---- Tab switching ---- -document.querySelectorAll('#paymentTabs .nav-link').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('#paymentTabs .nav-link').forEach(b => b.classList.remove('active')); - document.querySelectorAll('.payment-tab-content').forEach(p => p.classList.remove('active')); - btn.classList.add('active'); - document.getElementById(btn.dataset.tab).classList.add('active'); +const paymentTabs = Array.from(document.querySelectorAll('#paymentTabs .nav-link[role="tab"]')); + +function activateTab(tab) { + paymentTabs.forEach(b => { + const isSelected = b === tab; + b.classList.toggle('active', isSelected); + b.setAttribute('aria-selected', isSelected ? 'true' : 'false'); + b.setAttribute('tabindex', isSelected ? '0' : '-1'); + }); + document.querySelectorAll('.payment-tab-content').forEach(p => p.classList.remove('active')); + document.getElementById(tab.dataset.tab).classList.add('active'); +} + +paymentTabs.forEach(btn => { + btn.addEventListener('click', () => activateTab(btn)); + btn.addEventListener('keydown', e => { + const idx = paymentTabs.indexOf(e.currentTarget); + if (e.key === 'ArrowRight') { + e.preventDefault(); + const next = paymentTabs[(idx + 1) % paymentTabs.length]; + activateTab(next); + next.focus(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + const prev = paymentTabs[(idx - 1 + paymentTabs.length) % paymentTabs.length]; + activateTab(prev); + prev.focus(); + } else if (e.key === 'Home') { + e.preventDefault(); + activateTab(paymentTabs[0]); + paymentTabs[0].focus(); + } else if (e.key === 'End') { + e.preventDefault(); + activateTab(paymentTabs[paymentTabs.length - 1]); + paymentTabs[paymentTabs.length - 1].focus(); + } }); }); @@ -254,10 +289,13 @@ document.getElementById('pay-card-btn').addEventListener('click', async () => { const addressId = document.getElementById('shipping-address').value; if (!addressId) { - alert('Selecciona una dirección de envío para continuar.'); + const cardErrors = document.getElementById('card-errors'); + if (cardErrors) cardErrors.textContent = 'Selecciona una dirección de envío para continuar.'; return; } + const cardErrorsEl = document.getElementById('card-errors'); + if (cardErrorsEl) cardErrorsEl.textContent = ''; const btn = document.getElementById('pay-card-btn'); const spinner = document.getElementById('card-spinner'); btn.disabled = true; @@ -335,9 +373,15 @@ paypal.Buttons({ createOrder: async () => { const addressId = document.getElementById('shipping-address').value; if (!addressId) { - alert('Selecciona una dirección de envío para continuar.'); + const paypalErrors = document.getElementById('paypal-errors'); + if (paypalErrors) { + paypalErrors.textContent = 'Selecciona una dirección de envío para continuar.'; + paypalErrors.classList.remove('d-none'); + } return Promise.reject(new Error('Sin dirección')); } + const paypalErrorsEl = document.getElementById('paypal-errors'); + if (paypalErrorsEl) paypalErrorsEl.classList.add('d-none'); const resp = await fetch('{% url "crear_orden_paypal" %}', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN }, @@ -359,7 +403,11 @@ paypal.Buttons({ showSuccess(result.transaction_code); }, onError: (err) => { - alert('Error en el pago con PayPal: ' + err); + const paypalErrors = document.getElementById('paypal-errors'); + if (paypalErrors) { + paypalErrors.textContent = 'Error en el pago con PayPal: ' + err; + paypalErrors.classList.remove('d-none'); + } }, }).render('#paypal-button-container'); {% endif %} diff --git a/tienda/templates/tienda/home.html b/tienda/templates/tienda/home.html index 4465721..3e28fb3 100644 --- a/tienda/templates/tienda/home.html +++ b/tienda/templates/tienda/home.html @@ -196,9 +196,11 @@ Ver detalles - - 🛒 - +
+ {% csrf_token %} + + +
diff --git a/tienda/templates/tienda/producto.html b/tienda/templates/tienda/producto.html index fca8dd8..117aa9f 100644 --- a/tienda/templates/tienda/producto.html +++ b/tienda/templates/tienda/producto.html @@ -34,16 +34,16 @@
Precio total (IVA incluido):
€{{ product.get_price_with_vat|format_price }} -
IVA: €{{ product.get_vat_amount|format_price }}
+
IVA incluido: €{{ product.get_vat_amount|format_price }}
{{ product.briefdesc }}
{% if product.stock > 0 %} - Stock disponible: {{ product.stock }} + Stock disponible: {{ product.stock }} {% else %} - Sin stock + Sin stock {% endif %}
diff --git a/tienda/templates/tienda/register.html b/tienda/templates/tienda/register.html index ce794d8..c648827 100644 --- a/tienda/templates/tienda/register.html +++ b/tienda/templates/tienda/register.html @@ -36,7 +36,7 @@ diff --git a/tienda/templates/tienda/search.html b/tienda/templates/tienda/search.html index 3641bdd..7fc8385 100644 --- a/tienda/templates/tienda/search.html +++ b/tienda/templates/tienda/search.html @@ -79,9 +79,11 @@ Ver detalles - - 🛒 - +
+ {% csrf_token %} + + +
diff --git a/tienda/tests.py b/tienda/tests.py index 2c0b210..284ddc2 100644 --- a/tienda/tests.py +++ b/tienda/tests.py @@ -1,4 +1,6 @@ import json +from pathlib import Path +import re from unittest.mock import MagicMock, patch from django.test import TestCase, override_settings @@ -1371,6 +1373,16 @@ class EndpointViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, 'site-title-mobile d-md-none') self.assertContains(response, 'site-title-desktop') + + def test_mobile_site_title_css_keeps_title_pinned_to_header_row(self): + css_path = Path(__file__).resolve().parent / "static" / "css" / "custom.css" + css_content = css_path.read_text(encoding="utf-8") + selector_match = re.search(r"\.navbar\.header \.site-title-mobile\s*\{(?P[^}]*)\}", css_content, re.DOTALL) + self.assertIsNotNone(selector_match) + + rule_block = selector_match.group("body") + self.assertRegex(rule_block, r"top:\s*calc\(var\(--bs-navbar-padding-y\)\s*\+\s*20px\);") + self.assertRegex(rule_block, r"transform:\s*translate\(-50%,\s*-50%\);") def test_home_mobile_welcome_title_centered(self): response = self.client.get(reverse("home")) html = response.content.decode() diff --git a/tienda/views.py b/tienda/views.py index a44db6b..001dfa8 100644 --- a/tienda/views.py +++ b/tienda/views.py @@ -16,6 +16,7 @@ from .vars import ( ) from django.conf import settings from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST from django.urls import reverse from django.utils import timezone from decimal import Decimal, ROUND_HALF_UP @@ -239,6 +240,9 @@ def login(request: HttpRequest): # Autenticar usuario user = authenticate(request, username=username, password=password) + if user is None: # Bug de error 500 en caso de fallar la contra + messages.error(request, "Correo electrónico o contraseña incorrectos.") + return render(request, "tienda/login.html") user = User.objects.get(username=user.username) if user.registration_status == "CR": audit_logger.info( @@ -704,6 +708,7 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi return order, "" +@require_POST def add_to_cart(request: HttpRequest, product_id: int): """Agrega un producto al carrito""" try: @@ -2253,13 +2258,6 @@ def verify(request: HttpRequest, code: str): return HttpResponse("

Error

No existe el codigo de verificación

") -def reset_password(request: HttpRequest): - if request.user.is_authenticated: - return redirect("index") - - - return render(request, "tienda/reset_password", {}) - def rgpd(request: HttpRequest): return render(request, "tienda/rgpd.html", {}) @@ -2312,6 +2310,7 @@ def reset_password_phase2(request: HttpRequest, code: str): user = ver_code.user user.set_password(password) user.save() + ver_code.delete() # Delete Verification code after changing password messages.success(request, "Se ha cambiado la contraseña!") return redirect(reverse("index"))