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 %}
+ Saltar al contenido
{% cache 500 sidebar request.user.username %}