Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72def373e3 | |||
| a50cadc873 | |||
| 551057b067 | |||
| ad7ddbe887 | |||
| d6b7cdfe6a | |||
| 56286c2fd9 | |||
| ba4f6ad65d | |||
| ed7041ae40 | |||
| fa948a98e2 | |||
| e8a5091dfd | |||
| a0ee6ecd14 | |||
| d6c9aa3db3 | |||
| 9751d19401 | |||
| cda9adb986 | |||
| e7e7fd118d | |||
| 132b1e1722 | |||
| 7f557a3247 | |||
| 8cf1a55161 | |||
| 61a04e5040 | |||
| e5a0caa8b6 | |||
| 25e6088355 | |||
| 8ec391ccde | |||
| 3b007f324f | |||
| 6e003009fa | |||
| 69578f1dba | |||
| 3eb81b343c | |||
| ce5aac0e89 | |||
| c534f500ad | |||
| 63c6b645c3 | |||
| b16cb367d3 | |||
| 503233d323 | |||
| b50ab06a22 | |||
| cda339a336 | |||
| 541a73ce36 | |||
| 8932eeefbf | |||
| 80e5e2a422 | |||
| a686bccd54 | |||
| 6be67a9100 | |||
| bee360dfbb | |||
| a20a61be82 | |||
| b9675385aa | |||
| c33def1124 | |||
| 52dfa51af2 | |||
| a02617f8d2 | |||
| 53b4e89347 | |||
| df0579dd86 | |||
| 1022a44f12 | |||
| bb697d92c6 | |||
| 191f8823d4 | |||
| d75165e31a | |||
| 6ed4fb1954 | |||
| c190a65e57 | |||
| 756f1ad36b | |||
| 033c52a365 | |||
| 297b319a20 | |||
| 830966f3ee | |||
| 81d3694210 | |||
| dce0937511 | |||
| 7f8f70bc42 | |||
| 7203a07350 | |||
| ba75a0ab2e | |||
| 1f7db2db3a | |||
| a2e6e5ad97 | |||
| e78a936b21 | |||
| 30f260c9bf | |||
| 84d8a0e3b6 | |||
| 68dbbcad07 | |||
| a94c256ad5 | |||
| 0ff70589b9 | |||
| 25c6fc7315 | |||
| 9f598f56fe | |||
| b905ef435a | |||
| d849e7d3e6 | |||
| 5fa127ddf7 | |||
| 3f521d81b4 | |||
| 6828074dd1 | |||
| ad9fa741e5 | |||
| 07486bb5ec | |||
| a36740b02d | |||
| cb31784097 | |||
| 17935c6160 | |||
| 63df5cf73f | |||
| fe61b3a212 | |||
| d55026b69d | |||
| bdae5b073c | |||
| 183685519a | |||
| 0a9b9138bc | |||
| 3eb963fadf | |||
| 8a5edce758 | |||
| 0eaaa8d19d | |||
| 71cbf6825e | |||
| dd49a6a7d6 | |||
| f785b1862f | |||
| ea6c9c49a0 | |||
| edda5aca50 | |||
| d8f6838f0c | |||
| d9d9e5b1a6 | |||
| 501d7aade5 | |||
| 7d3cff0bd9 | |||
| 3cbca38c32 | |||
| dc967c114f | |||
| 60cd29ee30 | |||
| 540b3fdc43 | |||
| 9e33f5b89c | |||
| e1e175f18f | |||
| a45830cf25 | |||
| 369b6764c9 |
@@ -2,6 +2,7 @@
|
|||||||
SECRET_KEY=django-insecure-change-me
|
SECRET_KEY=django-insecure-change-me
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
S3_ENABLE=False
|
||||||
|
|
||||||
# PostgreSQL (por defecto habilitado; si POSTGRES_ENABLED=False se usa SQLite)
|
# PostgreSQL (por defecto habilitado; si POSTGRES_ENABLED=False se usa SQLite)
|
||||||
POSTGRES_ENABLED=True
|
POSTGRES_ENABLED=True
|
||||||
@@ -14,6 +15,17 @@ POSTGRES_PORT=5432
|
|||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://127.0.0.1:6379/1
|
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
|
||||||
STRIPE_PUBLISHABLE_KEY=
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ Templates use Django's inheritance pattern:
|
|||||||
- **Image uploads**: Organized in `tienda/static/media/images/` via `upload_to='images/'` in ImageField
|
- **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
|
- **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/')`
|
- **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
|
## Shipping Restrictions
|
||||||
- **Zona de envío**: Solo se vende/envía dentro de la provincia de Almería
|
- **Zona de envío**: Solo se vende/envía dentro de la provincia de Almería
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -7,3 +7,5 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
tienda/__pycache__/
|
tienda/__pycache__/
|
||||||
proyecto/__pycache__/
|
proyecto/__pycache__/
|
||||||
|
media
|
||||||
|
staticfiles
|
||||||
|
|||||||
Vendored
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"python.REPL.enableREPLSmartSend": false
|
"python.REPL.enableREPLSmartSend": false,
|
||||||
|
"makefile.configureOnOpen": false
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# AGENTS.md - Django Tienda Project
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # Run tests
|
||||||
|
python manage.py runserver # Dev server
|
||||||
|
python manage.py migrate # Migrations
|
||||||
|
python manage.py collectstatic # Static files (production)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Redis**: `redis://127.0.0.1:6379/1` (Linux: `sudo systemctl start redis-server`)
|
||||||
|
- **PostgreSQL**: Default. Set `POSTGRES_ENABLED=False` for SQLite
|
||||||
|
- **Environment**: Copy `.env.example` to `.env`
|
||||||
|
|
||||||
|
## Quirks
|
||||||
|
|
||||||
|
1. **Migrations**: If `makemigrations` fails with error 130, check `tienda/migrations/` - file often created anyway
|
||||||
|
2. **Test DB**: Always uses SQLite (hardcoded)
|
||||||
|
3. **App URL**: `http://localhost:8000/tienda/` (not `/`)
|
||||||
|
4. **Admin**: `/admin/`
|
||||||
|
5. **User Model**: Use `tienda.User` for queries
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `proyecto/` - Django settings, URLs, WSGI/ASGI
|
||||||
|
- `tienda/` - Main app (models, views, admin, templates)
|
||||||
|
- Templates extend `tienda/templates/tienda/base.html`
|
||||||
|
|
||||||
|
## Shipping
|
||||||
|
|
||||||
|
Only Almería province, Spain (04xxx). Country: "España".
|
||||||
|
|
||||||
|
## External Services
|
||||||
|
|
||||||
|
- **Payment**: Stripe + PayPal (via .env)
|
||||||
|
- **Storage**: S3 - set `S3_ENABLE=True`
|
||||||
|
- **Email**: SMTP (see .env.example)
|
||||||
|
- **Async**: Celery + Redis
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
- Push to `origin` and `github` after changes
|
||||||
|
- GitHub Actions updates `ghcr.io/dsaub/proyecto-mvc:development`
|
||||||
|
- SSH: `debian@172.16.14.221` (requires VPN - skip if unavailable)
|
||||||
|
- Stack: `/home/debian/composes/mvc/mvc.yml` on swarm
|
||||||
|
- Timeout: 1 minute max when updating services
|
||||||
|
- Docker job requires Test job to pass first
|
||||||
|
|
||||||
|
## Other
|
||||||
|
|
||||||
|
- **Repo**: https://github.com/dsaub/proyecto-final
|
||||||
|
- **Python**: 3.14, use `.venv` virtualenv
|
||||||
|
- **GIT**: origin → git.elordenador.org, github → GitHub
|
||||||
|
- **Docs**: `.github/copilot-instructions.md`, `docs/`
|
||||||
+4
-2
@@ -34,7 +34,9 @@ http {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
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/ {
|
location /static/ {
|
||||||
alias /static/;
|
alias /static/;
|
||||||
expires 30d;
|
expires 30d;
|
||||||
@@ -42,7 +44,7 @@ http {
|
|||||||
access_log off;
|
access_log off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Archivos subidos por usuarios.
|
# Archivos subidos por usuarios en modo local.
|
||||||
location /media/ {
|
location /media/ {
|
||||||
alias /media/;
|
alias /media/;
|
||||||
expires 7d;
|
expires 7d;
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
from .celery import app as celery_app
|
from .celery import app as celery
|
||||||
__all__ = ('celery_app',)
|
__all__ = ('celery',)
|
||||||
+6
-1
@@ -1,8 +1,13 @@
|
|||||||
from celery import Celery
|
from celery import Celery
|
||||||
import os
|
import os
|
||||||
|
import django
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proyecto.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
app = Celery('proyecto')
|
app = Celery('proyecto')
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proyecto.settings')
|
|
||||||
app.config_from_object('django.conf:settings', namespace="CELERY")
|
app.config_from_object('django.conf:settings', namespace="CELERY")
|
||||||
|
|
||||||
|
user_options = {}
|
||||||
|
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from jinja2 import Environment
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.templatetags.static import static
|
|
||||||
|
|
||||||
def environment(**options):
|
|
||||||
env = Environment(**options)
|
|
||||||
env.globals.update({
|
|
||||||
'static': static,
|
|
||||||
'url': reverse,
|
|
||||||
})
|
|
||||||
return env
|
|
||||||
+54
-10
@@ -14,6 +14,7 @@ import logging
|
|||||||
import os, sys
|
import os, sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEV_ENV = (sys.argv[1] == 'runserver')
|
||||||
|
|
||||||
RUNNING_TESTS = any(arg in {'test', 'pytest'} for arg in sys.argv) or 'PYTEST_CURRENT_TEST' in os.environ
|
RUNNING_TESTS = any(arg in {'test', 'pytest'} for arg in sys.argv) or 'PYTEST_CURRENT_TEST' in os.environ
|
||||||
|
|
||||||
@@ -53,6 +54,21 @@ def env_int(name: str, default: int) -> int:
|
|||||||
return default
|
return default
|
||||||
return int(value)
|
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'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
load_dotenv(BASE_DIR / '.env')
|
load_dotenv(BASE_DIR / '.env')
|
||||||
@@ -66,6 +82,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!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = env_bool('DEBUG', True)
|
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', [
|
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [
|
||||||
'192.168.1.142',
|
'192.168.1.142',
|
||||||
@@ -84,12 +102,15 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
|
'django.forms',
|
||||||
'compressor',
|
'compressor',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if S3_ENABLE:
|
||||||
|
INSTALLED_APPS.append('storages')
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
@@ -98,6 +119,9 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if not S3_ENABLE:
|
||||||
|
MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')
|
||||||
|
|
||||||
ROOT_URLCONF = 'proyecto.urls'
|
ROOT_URLCONF = 'proyecto.urls'
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
@@ -114,14 +138,6 @@ TEMPLATES = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
'BACKEND': 'django.template.backends.jinja2.Jinja2',
|
|
||||||
'DIRS': [BASE_DIR / 'templates/jinja2'],
|
|
||||||
'APP_DIRS': True,
|
|
||||||
'OPTIONS': {
|
|
||||||
'environment': 'proyecto.jinja2.environment',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'proyecto.wsgi.application'
|
WSGI_APPLICATION = 'proyecto.wsgi.application'
|
||||||
@@ -194,6 +210,8 @@ USE_TZ = True
|
|||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = 'static/'
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
COMPRESS_ROOT = STATIC_ROOT
|
||||||
|
COMPRESS_URL = STATIC_URL
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
BASE_DIR / 'tienda' / 'static',
|
BASE_DIR / 'tienda' / 'static',
|
||||||
]
|
]
|
||||||
@@ -211,6 +229,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 = [
|
STATICFILES_FINDERS = [
|
||||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||||
@@ -386,4 +425,9 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|||||||
USE_X_FORWARDED_HOST = True
|
USE_X_FORWARDED_HOST = True
|
||||||
SECURE_REFERER_POLICY = "strict-origin-when-cross-origin"
|
SECURE_REFERER_POLICY = "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
print(f"DEBUG: ALLOWED_HOSTS is {ALLOWED_HOSTS}")
|
from django.forms.renderers import TemplatesSetting
|
||||||
|
|
||||||
|
class CustomFormRenderer(TemplatesSetting):
|
||||||
|
form_template_name = "tienda/form_snippet.html"
|
||||||
|
|
||||||
|
FORM_RENDERER = "proyecto.settings.CustomFormRenderer"
|
||||||
+3
-1
@@ -26,5 +26,7 @@ urlpatterns = [
|
|||||||
path('tienda/', include('tienda.urls'))
|
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)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
+27
-20
@@ -1,44 +1,51 @@
|
|||||||
amqp==5.3.1
|
amqp==5.3.1
|
||||||
asgiref==3.11.0
|
asgiref==3.11.1
|
||||||
billiard==4.2.4
|
billiard==4.2.4
|
||||||
celery==5.6.2
|
boto3==1.43.5
|
||||||
certifi==2026.1.4
|
botocore==1.43.5
|
||||||
|
celery==5.6.3
|
||||||
|
certifi==2026.4.22
|
||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.7
|
||||||
click==8.3.1
|
click==8.3.3
|
||||||
click-didyoumean==0.3.1
|
click-didyoumean==0.3.1
|
||||||
click-plugins==1.1.1.2
|
click-plugins==1.1.1.2
|
||||||
click-repl==0.3.0
|
click-repl==0.3.0
|
||||||
cryptography==46.0.7
|
cryptography==48.0.0
|
||||||
Django==6.0.4
|
defusedxml==0.7.1
|
||||||
|
Django==6.0.5
|
||||||
django-appconf==1.2.0
|
django-appconf==1.2.0
|
||||||
django-redis==5.4.0
|
django-redis==6.0.0
|
||||||
|
django-storages==1.14.6
|
||||||
django_compressor==4.6.0
|
django_compressor==4.6.0
|
||||||
gunicorn==25.1.0
|
fonttools==4.62.1
|
||||||
idna==3.11
|
fpdf2==2.8.7
|
||||||
Jinja2==3.1.6
|
gunicorn==26.0.0
|
||||||
|
idna==3.13
|
||||||
|
|
||||||
|
jmespath==1.1.0
|
||||||
kombu==5.6.2
|
kombu==5.6.2
|
||||||
MarkupSafe==3.0.3
|
MarkupSafe==3.0.3
|
||||||
packaging==26.0
|
packaging==26.2
|
||||||
paypalrestsdk==1.13.3
|
paypalrestsdk==1.13.3
|
||||||
pillow==12.2.0
|
pillow==12.2.0
|
||||||
prompt_toolkit==3.0.52
|
prompt_toolkit==3.0.52
|
||||||
|
psycopg2-binary==2.9.12
|
||||||
pycparser==3.0
|
pycparser==3.0
|
||||||
pyOpenSSL==26.0.0
|
pyOpenSSL==26.2.0
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
rcssmin==1.2.2
|
rcssmin==1.2.2
|
||||||
redis==5.2.1
|
redis==7.4.0
|
||||||
requests==2.33.0
|
requests==2.33.1
|
||||||
rjsmin==1.2.5
|
rjsmin==1.2.5
|
||||||
|
s3transfer==0.17.0
|
||||||
six==1.17.0
|
six==1.17.0
|
||||||
sqlparse==0.5.5
|
sqlparse==0.5.5
|
||||||
stripe==14.3.0
|
stripe==15.1.0
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
tzdata==2025.3
|
tzdata==2026.2
|
||||||
tzlocal==5.3.1
|
tzlocal==5.3.1
|
||||||
urllib3==2.6.3
|
urllib3==2.6.3
|
||||||
vine==5.1.0
|
vine==5.1.0
|
||||||
wcwidth==0.6.0
|
wcwidth==0.7.0
|
||||||
whitenoise==6.12.0
|
whitenoise==6.12.0
|
||||||
fpdf2==2.8.7
|
|
||||||
psycopg2-binary==2.9.11
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+60
-3
@@ -1,17 +1,74 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod
|
from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import path
|
||||||
|
from django.contrib import messages
|
||||||
|
from . import tasks
|
||||||
|
|
||||||
admin.site.register(Category)
|
admin.site.register(Category)
|
||||||
admin.site.register(Image)
|
admin.site.register(Image)
|
||||||
admin.site.register(User)
|
|
||||||
admin.site.register(VerificationCode)
|
admin.site.register(VerificationCode)
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(admin.ModelAdmin):
|
||||||
|
search_fields = ('username',)
|
||||||
|
actions = ['banear_usuario_action', 'desbanear_usuario_action']
|
||||||
|
def has_change_permission(self, request, obj = ...):
|
||||||
|
return super().has_change_permission(request, obj)
|
||||||
|
|
||||||
|
def banear_usuario_action(self, request, queryset):
|
||||||
|
usuarios_baneados = 0
|
||||||
|
for user in queryset:
|
||||||
|
user: User = user
|
||||||
|
# Desactiva usuario
|
||||||
|
if user.registration_status == User.RegisterStatus.BANNED:
|
||||||
|
continue
|
||||||
|
|
||||||
|
user.is_active = False
|
||||||
|
user.registration_status = User.RegisterStatus.BANNED
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
# Enviar task a Worker
|
||||||
|
tasks.banear_usuario.delay(user.email)
|
||||||
|
|
||||||
|
# Borrar productos
|
||||||
|
Product.objects.filter(creator=user).delete()
|
||||||
|
usuarios_baneados+=1
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"Se ha(n) baneado {usuarios_baneados} usuario(s) correctamente.",
|
||||||
|
level=messages.SUCCESS
|
||||||
|
)
|
||||||
|
def desbanear_usuario_action(self, request, queryset):
|
||||||
|
user_desbaneados = 0
|
||||||
|
for user in queryset:
|
||||||
|
user: User = user
|
||||||
|
if user.registration_status != User.RegisterStatus.BANNED:
|
||||||
|
continue
|
||||||
|
|
||||||
|
user.is_active = True
|
||||||
|
user.registration_status = User.RegisterStatus.ACTIVE
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
tasks.desbanear_usuario.delay(user.email)
|
||||||
|
|
||||||
|
user_desbaneados -= 1
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"Se ha(n) desbaneado {user_desbaneados} usuario(s)",
|
||||||
|
level=messages.SUCCESS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
banear_usuario_action.short_description = "Banear usuarios seleccionados"
|
||||||
|
desbanear_usuario_action.short_description = "Desbanear usuarios seleccionados"
|
||||||
|
|
||||||
@admin.register(Product)
|
@admin.register(Product)
|
||||||
class ProductAdmin(admin.ModelAdmin):
|
class ProductAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'name', 'price', 'stock', 'category', 'creator')
|
list_display = ('id', 'sku', 'name', 'price', 'stock', 'category', 'creator')
|
||||||
search_fields = ('name', 'creator__username', 'creator__email')
|
search_fields = ('name', 'sku', 'creator__username', 'creator__email')
|
||||||
list_filter = ('category',)
|
list_filter = ('category',)
|
||||||
class CartItemInline(admin.TabularInline):
|
class CartItemInline(admin.TabularInline):
|
||||||
model = CartItem
|
model = CartItem
|
||||||
|
|||||||
+354
@@ -0,0 +1,354 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from .models import Category
|
||||||
|
|
||||||
|
|
||||||
|
class ProductForm(forms.Form):
|
||||||
|
name = forms.CharField(
|
||||||
|
label="Nombre del Producto",
|
||||||
|
max_length=200,
|
||||||
|
required = True,
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Ej: iPhone 15 Pro Max'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
briefdesc = forms.CharField(
|
||||||
|
label="Descripción Breve",
|
||||||
|
max_length=250,
|
||||||
|
required = True,
|
||||||
|
widget = forms.TextInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Una descripción corta para mostrar en las tarjetas de producto'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
widget=forms.Textarea(attrs={"rows": "5", "class": "form-control"}),
|
||||||
|
max_length=5000,
|
||||||
|
label="Descripción completa",
|
||||||
|
required = True
|
||||||
|
)
|
||||||
|
price = forms.FloatField(
|
||||||
|
label="Precio (en €)",
|
||||||
|
required = True,
|
||||||
|
widget = forms.TextInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': '15.99'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stock = forms.IntegerField(
|
||||||
|
label="Stock Disponible",
|
||||||
|
required = True,
|
||||||
|
widget = forms.TextInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
category = forms.ModelChoiceField(
|
||||||
|
queryset=Category.objects.all(),
|
||||||
|
label="Categoría",
|
||||||
|
required=True,
|
||||||
|
widget=forms.Select(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
|
||||||
|
primary_image = forms.ImageField(
|
||||||
|
label="Imagen Principal",
|
||||||
|
required = False,
|
||||||
|
widget = forms.ClearableFileInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control',
|
||||||
|
'accept': 'image/*'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductEditForm(forms.Form):
|
||||||
|
name = forms.CharField(
|
||||||
|
label="Nombre del Producto",
|
||||||
|
max_length=200,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: iPhone 15 Pro Max'})
|
||||||
|
)
|
||||||
|
briefdesc = forms.CharField(
|
||||||
|
label="Descripción Breve",
|
||||||
|
max_length=250,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Una descripción corta'})
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
widget=forms.Textarea(attrs={"rows": "5", "class": "form-control"}),
|
||||||
|
max_length=5000,
|
||||||
|
label="Descripción completa",
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
price = forms.FloatField(
|
||||||
|
label="Precio (en €)",
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '15.99'})
|
||||||
|
)
|
||||||
|
stock = forms.IntegerField(
|
||||||
|
label="Stock Disponible",
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
category = forms.ModelChoiceField(
|
||||||
|
queryset=Category.objects.all(),
|
||||||
|
label="Categoría",
|
||||||
|
required=True,
|
||||||
|
widget=forms.Select(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
primary_image = forms.ImageField(
|
||||||
|
label="Imagen Principal (opcional)",
|
||||||
|
required=False,
|
||||||
|
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SecondaryImageForm(forms.Form):
|
||||||
|
image = forms.ImageField(
|
||||||
|
label="Seleccionar Imagen",
|
||||||
|
required = True,
|
||||||
|
widget = forms.ClearableFileInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control',
|
||||||
|
'accept': 'image/*'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
alt = forms.CharField(
|
||||||
|
label="Texto Alternativo",
|
||||||
|
max_length=255,
|
||||||
|
required = False,
|
||||||
|
widget = forms.TextInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Descripción opcional de la imagen'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLoginForm(forms.Form):
|
||||||
|
email = forms.EmailField(
|
||||||
|
label = "Correo Electrónico",
|
||||||
|
max_length=255,
|
||||||
|
required = True,
|
||||||
|
widget = forms.TextInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Correo Electronico de tu cuenta...'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
password = forms.CharField(
|
||||||
|
label="Contraseña",
|
||||||
|
max_length = 255,
|
||||||
|
required = True,
|
||||||
|
widget = forms.PasswordInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Contraseña del usuario'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
remember = forms.BooleanField(
|
||||||
|
required = False,
|
||||||
|
label = "Recuerdame",
|
||||||
|
widget = forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserRegisterForm(forms.Form):
|
||||||
|
name = forms.CharField(
|
||||||
|
label = "Nombre Completo",
|
||||||
|
max_length = 255,
|
||||||
|
required = True,
|
||||||
|
widget = forms.TextInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
email = forms.EmailField(
|
||||||
|
label = "Correo Electrónico",
|
||||||
|
max_length = 255,
|
||||||
|
required = True,
|
||||||
|
widget = forms.TextInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
password = forms.CharField(
|
||||||
|
label = "Contraseña",
|
||||||
|
max_length = 255,
|
||||||
|
required = True,
|
||||||
|
widget = forms.PasswordInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
password_confirm = forms.CharField(
|
||||||
|
label = "Verificar Contraseña",
|
||||||
|
max_length = 255,
|
||||||
|
required = True,
|
||||||
|
widget = forms.PasswordInput(
|
||||||
|
attrs = {
|
||||||
|
'class': 'form-control'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
terms = forms.BooleanField(
|
||||||
|
required = True,
|
||||||
|
label = "Acepto los terminos y condiciones",
|
||||||
|
widget = forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
password = cleaned_data.get("password")
|
||||||
|
password_confirm = cleaned_data.get("password_confirm")
|
||||||
|
if password and password_confirm and password != password_confirm:
|
||||||
|
raise ValidationError("Las contraseñas no coinciden.")
|
||||||
|
|
||||||
|
|
||||||
|
class EditProfileForm(forms.Form):
|
||||||
|
first_name = forms.CharField(
|
||||||
|
label="Nombre",
|
||||||
|
max_length=150,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
last_name = forms.CharField(
|
||||||
|
label="Apellidos",
|
||||||
|
max_length=150,
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
email = forms.EmailField(
|
||||||
|
label="Correo Electrónico",
|
||||||
|
max_length=254,
|
||||||
|
required=True,
|
||||||
|
widget=forms.EmailInput(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordForm(forms.Form):
|
||||||
|
current_password = forms.CharField(
|
||||||
|
label="Contraseña Actual",
|
||||||
|
max_length=128,
|
||||||
|
required=True,
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
new_password = forms.CharField(
|
||||||
|
label="Nueva Contraseña",
|
||||||
|
max_length=128,
|
||||||
|
required=True,
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
confirm_password = forms.CharField(
|
||||||
|
label="Confirmar Nueva Contraseña",
|
||||||
|
max_length=128,
|
||||||
|
required=True,
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
new_password = cleaned_data.get("new_password")
|
||||||
|
confirm_password = cleaned_data.get("confirm_password")
|
||||||
|
if new_password and confirm_password and new_password != confirm_password:
|
||||||
|
raise ValidationError("Las contraseñas no coinciden.")
|
||||||
|
if new_password and len(new_password) < 8:
|
||||||
|
raise ValidationError("La contraseña debe tener al menos 8 caracteres.")
|
||||||
|
|
||||||
|
|
||||||
|
class ShippingAddressForm(forms.Form):
|
||||||
|
full_name = forms.CharField(
|
||||||
|
label="Nombre Completo",
|
||||||
|
max_length=255,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Juan Pérez García'})
|
||||||
|
)
|
||||||
|
address_line_1 = forms.CharField(
|
||||||
|
label="Dirección",
|
||||||
|
max_length=255,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Calle Mayor 123'})
|
||||||
|
)
|
||||||
|
address_line_2 = forms.CharField(
|
||||||
|
label="Dirección (línea 2)",
|
||||||
|
max_length=255,
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Piso, puerta, etc.'})
|
||||||
|
)
|
||||||
|
city = forms.CharField(
|
||||||
|
label="Población",
|
||||||
|
max_length=100,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Almería'})
|
||||||
|
)
|
||||||
|
postal_code = forms.CharField(
|
||||||
|
label="Código Postal",
|
||||||
|
max_length=5,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '04001'})
|
||||||
|
)
|
||||||
|
country = forms.CharField(
|
||||||
|
label="País",
|
||||||
|
max_length=100,
|
||||||
|
required=False,
|
||||||
|
initial="España",
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'readonly': True})
|
||||||
|
)
|
||||||
|
phone = forms.CharField(
|
||||||
|
label="Teléfono",
|
||||||
|
max_length=20,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '612 345 678'})
|
||||||
|
)
|
||||||
|
is_default = forms.BooleanField(
|
||||||
|
label="Establecer como dirección predeterminada",
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordForm(forms.Form):
|
||||||
|
email = forms.EmailField(
|
||||||
|
label="Correo Electrónico",
|
||||||
|
max_length=254,
|
||||||
|
required=True,
|
||||||
|
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tu@email.com'})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordPhase2Form(forms.Form):
|
||||||
|
password = forms.CharField(
|
||||||
|
label="Nueva Contraseña",
|
||||||
|
max_length=128,
|
||||||
|
required=True,
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
verify_password = forms.CharField(
|
||||||
|
label="Confirmar Contraseña",
|
||||||
|
max_length=128,
|
||||||
|
required=True,
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
password = cleaned_data.get("password")
|
||||||
|
verify_password = cleaned_data.get("verify_password")
|
||||||
|
if password and verify_password and password != verify_password:
|
||||||
|
raise ValidationError("Las contraseñas no coinciden.")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.4 on 2026-05-05 07:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tienda', '0006_alter_category_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='sku',
|
||||||
|
field=models.CharField(blank=True, max_length=50, null=True, unique=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.4 on 2026-05-07 08:14
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tienda', '0007_add_product_sku'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='briefdesc',
|
||||||
|
field=models.TextField(default='', max_length=250),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='description',
|
||||||
|
field=models.TextField(default='', max_length=5000),
|
||||||
|
),
|
||||||
|
]
|
||||||
+61
-4
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import User, AbstractUser
|
from django.contrib.auth.models import User, AbstractUser
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
@@ -25,6 +26,11 @@ class User(AbstractUser):
|
|||||||
choices = RegisterStatus.choices,
|
choices = RegisterStatus.choices,
|
||||||
default = RegisterStatus.CONFIRMATION_REQUIRED
|
default = RegisterStatus.CONFIRMATION_REQUIRED
|
||||||
)
|
)
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"username": self.username,
|
||||||
|
"fullname": self.get_full_name()
|
||||||
|
}
|
||||||
|
|
||||||
class VerificationCode(models.Model):
|
class VerificationCode(models.Model):
|
||||||
class VerificationModes(models.TextChoices):
|
class VerificationModes(models.TextChoices):
|
||||||
@@ -41,7 +47,7 @@ class VerificationCode(models.Model):
|
|||||||
|
|
||||||
def generate(user: User, code_mode: str) -> VerificationCode:
|
def generate(user: User, code_mode: str) -> VerificationCode:
|
||||||
while True:
|
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():
|
if not VerificationCode.objects.filter(code=code).exists():
|
||||||
return VerificationCode.objects.create(
|
return VerificationCode.objects.create(
|
||||||
code = code,
|
code = code,
|
||||||
@@ -56,6 +62,11 @@ class Category(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"name": self.name
|
||||||
|
}
|
||||||
|
|
||||||
class Image(models.Model):
|
class Image(models.Model):
|
||||||
name = models.CharField(max_length=200, default="")
|
name = models.CharField(max_length=200, default="")
|
||||||
image = models.ImageField(upload_to='images/')
|
image = models.ImageField(upload_to='images/')
|
||||||
@@ -64,10 +75,18 @@ class Image(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"image": self.image.url,
|
||||||
|
"alt": self.alt
|
||||||
|
}
|
||||||
|
|
||||||
class Product(models.Model):
|
class Product(models.Model):
|
||||||
name = models.CharField(max_length=200, default="")
|
name = models.CharField(max_length=200, default="")
|
||||||
description = models.TextField(default = "")
|
sku = models.CharField(max_length=50, unique=True, blank=True, null=True)
|
||||||
briefdesc = models.TextField(default = "")
|
description = models.TextField(default = "", max_length=5000)
|
||||||
|
briefdesc = models.TextField(default = "", max_length=250)
|
||||||
price = models.FloatField(default = 0)
|
price = models.FloatField(default = 0)
|
||||||
stock = models.PositiveIntegerField(default=0)
|
stock = models.PositiveIntegerField(default=0)
|
||||||
category = models.ForeignKey(Category, on_delete=models.CASCADE)
|
category = models.ForeignKey(Category, on_delete=models.CASCADE)
|
||||||
@@ -86,6 +105,20 @@ class Product(models.Model):
|
|||||||
"""Retorna la cantidad de IVA"""
|
"""Retorna la cantidad de IVA"""
|
||||||
return round(self.price * VAT_RATE, 2)
|
return round(self.price * VAT_RATE, 2)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"sku": self.sku,
|
||||||
|
"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):
|
class StockReservation(models.Model):
|
||||||
STATUS_ACTIVE = "active"
|
STATUS_ACTIVE = "active"
|
||||||
@@ -312,8 +345,32 @@ class ShippingAddress(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.full_name} - {self.city}"
|
return f"{self.full_name} - {self.city}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.conf import settings
|
||||||
|
from .vars import ALMERIA_POSTAL_CODE_PREFIX, ALMERIA_MUNICIPALITIES_DISPLAY
|
||||||
|
from .views import _normalize_location_text
|
||||||
|
|
||||||
|
postal_code = (self.postal_code or "").strip()
|
||||||
|
city = (self.city or "").strip()
|
||||||
|
|
||||||
|
almeria_prefix = getattr(settings, 'POSTGRES_ENABLED', False) and "04" or ALMERIA_POSTAL_CODE_PREFIX
|
||||||
|
|
||||||
|
if len(postal_code) != 5 or not postal_code.isdigit() or not postal_code.startswith(almeria_prefix):
|
||||||
|
raise ValidationError({
|
||||||
|
'postal_code': 'Solo realizamos envíos en la provincia de Almería (código postal 04xxx).'
|
||||||
|
})
|
||||||
|
|
||||||
|
normalized_city = _normalize_location_text(city)
|
||||||
|
normalized_municipalities = {_normalize_location_text(m) for m in ALMERIA_MUNICIPALITIES_DISPLAY}
|
||||||
|
|
||||||
|
if normalized_city not in normalized_municipalities:
|
||||||
|
raise ValidationError({
|
||||||
|
'city': 'El pueblo/ciudad debe pertenecer a la provincia de Almería.'
|
||||||
|
})
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Si se marca como predeterminada, desmarcar las demás del usuario
|
self.full_clean()
|
||||||
if self.is_default:
|
if self.is_default:
|
||||||
ShippingAddress.objects.filter(user=self.user, is_default=True).update(is_default=False)
|
ShippingAddress.objects.filter(user=self.user, is_default=True).update(is_default=False)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|||||||
@@ -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) {
|
@media (min-width: 1250px) {
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -63,8 +88,9 @@ p.price {
|
|||||||
.navbar.header .site-title-mobile {
|
.navbar.header .site-title-mobile {
|
||||||
color: #FFF;
|
color: #FFF;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
top: calc(var(--bs-navbar-padding-y) + 20px);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translate(-50%, -50%);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: calc(100% - 9rem);
|
max-width: calc(100% - 9rem);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -289,5 +315,6 @@ p.price {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.texto-ajustado {
|
.texto-ajustado {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
+28
-6
@@ -11,14 +11,32 @@ from .models import User, VerificationCode
|
|||||||
@shared_task
|
@shared_task
|
||||||
def enviar_correo_bienvenida(email_usuario: str, nombre_usuario: str):
|
def enviar_correo_bienvenida(email_usuario: str, nombre_usuario: str):
|
||||||
html_content = render_to_string(
|
html_content = render_to_string(
|
||||||
'emails/welcome.html',
|
'tienda/emails/welcome.html',
|
||||||
{
|
{
|
||||||
"name": nombre_usuario
|
"name": nombre_usuario
|
||||||
},
|
},
|
||||||
using='jinja2'
|
|
||||||
)
|
)
|
||||||
send_hemail(email_usuario, "Inicio de Sesión correcto", html_content, "Has iniciado sesión...")
|
send_hemail(email_usuario, "Inicio de Sesión correcto", html_content, "Has iniciado sesión...")
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def banear_usuario(email_usuario: str):
|
||||||
|
html_content = render_to_string(
|
||||||
|
'tienda/emails/ban.html',
|
||||||
|
{
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
send_hemail(email_usuario, "Cuenta Bloqueada", html_content, "Tu cuenta ha sido bloqueada...")
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def desbanear_usuario(email_usuario: str):
|
||||||
|
html_content = render_to_string(
|
||||||
|
'tienda/emails/unban.html',
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
send_hemail(email_usuario, "Cuenta Desbloqueada", html_content, "Tu cuenta ha sido desbloqueada...")
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def enviar_correo_confirmacion(id: int):
|
def enviar_correo_confirmacion(id: int):
|
||||||
usuario = User.objects.get(id=id)
|
usuario = User.objects.get(id=id)
|
||||||
@@ -33,7 +51,11 @@ def enviar_correo_confirmacion(id: int):
|
|||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def enviar_correo_recuperacion(email: str):
|
def enviar_correo_recuperacion(email: str):
|
||||||
usuario = User.objects.get(email=email)
|
usuario: User | None
|
||||||
|
try:
|
||||||
|
usuario = User.objects.get(email=email)
|
||||||
|
except User.DoesNotExist as e:
|
||||||
|
usuario = None
|
||||||
if usuario is not None:
|
if usuario is not None:
|
||||||
ver_code = VerificationCode.objects.create(
|
ver_code = VerificationCode.objects.create(
|
||||||
code_mode = VerificationCode.VerificationModes.RESET_PASSWORD,
|
code_mode = VerificationCode.VerificationModes.RESET_PASSWORD,
|
||||||
@@ -42,18 +64,18 @@ def enviar_correo_recuperacion(email: str):
|
|||||||
)
|
)
|
||||||
ver_code.save()
|
ver_code.save()
|
||||||
html_content = render_to_string(
|
html_content = render_to_string(
|
||||||
'emails/reset_pass.html',
|
'tienda/emails/reset_pass.html',
|
||||||
{
|
{
|
||||||
"name": usuario.get_full_name(),
|
"name": usuario.get_full_name(),
|
||||||
"domain": settings.DOMAIN,
|
"domain": settings.DOMAIN,
|
||||||
"protocol": settings.PROTOCOL,
|
"protocol": settings.PROTOCOL,
|
||||||
"code": ver_code.code
|
"code": ver_code.code
|
||||||
},
|
},
|
||||||
using='jinja2'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
send_hemail(email, "Reset de Contraseña", html_content, "Estas reseteando la contraseña...")
|
send_hemail(email, "Reset de Contraseña", html_content, "Estas reseteando la contraseña...")
|
||||||
|
else:
|
||||||
|
print("User does not exist, Cancelling TASK.")
|
||||||
|
|
||||||
# Purchased items should be a list of dictionary, the dictionary must follow this tags: amount, product name, price (each)
|
# Purchased items should be a list of dictionary, the dictionary must follow this tags: amount, product name, price (each)
|
||||||
@shared_task
|
@shared_task
|
||||||
|
|||||||
@@ -50,8 +50,11 @@
|
|||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-suggestion-item:hover {
|
.search-suggestion-item:hover,
|
||||||
|
.search-suggestion-item.active {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
|
outline: 2px solid #0d6efd;
|
||||||
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-suggestion-item:last-child {
|
.search-suggestion-item:last-child {
|
||||||
@@ -78,6 +81,7 @@
|
|||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex flex-column min-vh-100">
|
<body class="d-flex flex-column min-vh-100">
|
||||||
|
<a href="#main-content" class="skip-link">Saltar al contenido</a>
|
||||||
{% cache 500 sidebar request.user.username %}
|
{% cache 500 sidebar request.user.username %}
|
||||||
<nav class="navbar navbar-expand-md header" role="banner">
|
<nav class="navbar navbar-expand-md header" role="banner">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
@@ -107,10 +111,10 @@
|
|||||||
<!-- Barra de búsqueda con sugerencias -->
|
<!-- Barra de búsqueda con sugerencias -->
|
||||||
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
|
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off">
|
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="searchSuggestions" aria-activedescendant="" aria-haspopup="listbox">
|
||||||
<button class="btn btn-outline-primary" type="submit">🔍</button>
|
<button class="btn btn-outline-primary" type="submit" aria-label="Buscar productos">🔍 Buscar</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-suggestions" id="searchSuggestions"></div>
|
<div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="navbar-nav ms-auto d-flex align-items-md-center gap-2 flex-wrap" role="navigation">
|
<div class="navbar-nav ms-auto d-flex align-items-md-center gap-2 flex-wrap" role="navigation">
|
||||||
@@ -137,7 +141,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
|
|
||||||
<div class="container-fluid flex-grow-1 d-flex flex-column" role="main">
|
<div id="main-content" class="container-fluid flex-grow-1 d-flex flex-column" role="main">
|
||||||
<!-- Mensajes -->
|
<!-- Mensajes -->
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
@@ -201,6 +205,35 @@
|
|||||||
const searchSuggestions = document.getElementById('searchSuggestions');
|
const searchSuggestions = document.getElementById('searchSuggestions');
|
||||||
const searchForm = document.getElementById('searchForm');
|
const searchForm = document.getElementById('searchForm');
|
||||||
let searchTimeout;
|
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
|
// Escuchar cambios en el input
|
||||||
searchInput.addEventListener('input', function() {
|
searchInput.addEventListener('input', function() {
|
||||||
@@ -208,7 +241,7 @@
|
|||||||
const query = this.value.trim();
|
const query = this.value.trim();
|
||||||
|
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
searchSuggestions.classList.remove('show');
|
closeSuggestions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +251,31 @@
|
|||||||
}, 300);
|
}, 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
|
// Función para obtener sugerencias del servidor
|
||||||
function fetchSuggestions(query) {
|
function fetchSuggestions(query) {
|
||||||
fetch(`{% url 'search_suggestions' %}?q=${encodeURIComponent(query)}`)
|
fetch(`{% url 'search_suggestions' %}?q=${encodeURIComponent(query)}`)
|
||||||
@@ -227,33 +285,37 @@
|
|||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error fetching suggestions:', error);
|
console.error('Error fetching suggestions:', error);
|
||||||
searchSuggestions.classList.remove('show');
|
closeSuggestions();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Función para mostrar las sugerencias
|
// Función para mostrar las sugerencias
|
||||||
function displaySuggestions(suggestions, query) {
|
function displaySuggestions(suggestions, query) {
|
||||||
|
currentFocusIndex = -1;
|
||||||
if (suggestions.length === 0) {
|
if (suggestions.length === 0) {
|
||||||
searchSuggestions.innerHTML = '<div class="search-suggestion-item text-muted">No se encontraron productos</div>';
|
searchSuggestions.innerHTML = '<div class="search-suggestion-item text-muted">No se encontraron productos</div>';
|
||||||
searchSuggestions.classList.add('show');
|
openSuggestions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
suggestions.forEach(suggestion => {
|
suggestions.forEach((suggestion, index) => {
|
||||||
// Resaltar la coincidencia en el nombre
|
// Resaltar la coincidencia en el nombre
|
||||||
const highlightedName = highlightMatch(suggestion.name, query);
|
const highlightedName = highlightMatch(suggestion.name, query);
|
||||||
const priceWithVAT = (suggestion.price * 1.21).toFixed(2);
|
const priceWithVAT = (suggestion.price * 1.21).toFixed(2);
|
||||||
html += `
|
html += `
|
||||||
<a href="/tienda/producto/${suggestion.id}" class="search-suggestion-item text-decoration-none">
|
<div class="search-suggestion-item" role="option" id="search-option-${index}"
|
||||||
|
aria-selected="false" tabindex="-1"
|
||||||
|
data-href="/tienda/producto/${suggestion.id}"
|
||||||
|
onclick="window.location.href=this.dataset.href">
|
||||||
<span class="suggestion-name">${highlightedName}</span>
|
<span class="suggestion-name">${highlightedName}</span>
|
||||||
<span class="suggestion-price">€${priceWithVAT}</span>
|
<span class="suggestion-price">€${priceWithVAT}</span>
|
||||||
</a>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
searchSuggestions.innerHTML = html;
|
searchSuggestions.innerHTML = html;
|
||||||
searchSuggestions.classList.add('show');
|
openSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Función para resaltar el texto que coincide
|
// Función para resaltar el texto que coincide
|
||||||
@@ -265,19 +327,19 @@
|
|||||||
// Cerrar sugerencias cuando se hace clic fuera
|
// Cerrar sugerencias cuando se hace clic fuera
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
if (!searchForm.contains(event.target)) {
|
if (!searchForm.contains(event.target)) {
|
||||||
searchSuggestions.classList.remove('show');
|
closeSuggestions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cerrar sugerencias al enviar el formulario
|
// Cerrar sugerencias al enviar el formulario
|
||||||
searchForm.addEventListener('submit', function() {
|
searchForm.addEventListener('submit', function() {
|
||||||
searchSuggestions.classList.remove('show');
|
closeSuggestions();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mostrar sugerencias al hacer clic en el input (si hay texto)
|
// Mostrar sugerencias al hacer clic en el input (si hay texto)
|
||||||
searchInput.addEventListener('focus', function() {
|
searchInput.addEventListener('focus', function() {
|
||||||
if (this.value.trim().length >= 2 && searchSuggestions.innerHTML) {
|
if (this.value.trim().length >= 2 && searchSuggestions.innerHTML) {
|
||||||
searchSuggestions.classList.add('show');
|
openSuggestions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<form method="post" action="{% url 'update_cart_item' item.id %}" class="d-flex align-items-center" style="max-width: 150px;">
|
<form method="post" action="{% url 'update_cart_item' item.id %}" class="d-flex align-items-center" style="max-width: 150px;">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="number" name="quantity" value="{{ item.quantity }}" min="1" max="{{ item.product.stock }}" class="form-control form-control-sm me-2" style="width: 70px;">
|
<input type="number" name="quantity" value="{{ item.quantity }}" min="1" max="{{ item.product.stock }}" class="form-control form-control-sm me-2" style="width: 70px;" aria-label="Cantidad para {{ item.product.name }}">
|
||||||
<button type="submit" class="btn btn-sm btn-primary">Actualizar</button>
|
<button type="submit" class="btn btn-sm btn-primary">Actualizar</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
{% if item.product.stock > 0 %}
|
{% if item.product.stock > 0 %}
|
||||||
{{ item.product.stock }}
|
{{ item.product.stock }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-danger">0</span>
|
<span class="text-danger" role="status"><span aria-hidden="true">✗</span> Sin stock</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="price">{{ item.get_subtotal_with_vat|format_price }} €</td>
|
<td class="price">{{ item.get_subtotal_with_vat|format_price }} €</td>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span>IVA (21%)</span>
|
<span>IVA (21%)</span>
|
||||||
<span class="price text-success">{{ cart.get_vat_amount|format_price }} €</span>
|
<span class="price text-success"><span aria-hidden="true">ℹ️</span> {{ cart.get_vat_amount|format_price }} €</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span>Envío</span>
|
<span>Envío</span>
|
||||||
|
|||||||
@@ -127,22 +127,25 @@
|
|||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist">
|
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link active" id="tab-card" data-tab="pane-card" type="button" role="tab">
|
<button class="nav-link active" id="tab-card" data-tab="pane-card" type="button"
|
||||||
|
role="tab" aria-selected="true" aria-controls="pane-card" tabindex="0">
|
||||||
💳 Tarjeta
|
💳 Tarjeta
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="tab-paypal" data-tab="pane-paypal" type="button" role="tab">
|
<button class="nav-link" id="tab-paypal" data-tab="pane-paypal" type="button"
|
||||||
|
role="tab" aria-selected="false" aria-controls="pane-paypal" tabindex="-1">
|
||||||
🅿️ PayPal
|
🅿️ PayPal
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Tarjeta tab -->
|
<!-- Tarjeta tab -->
|
||||||
<div id="pane-card" class="payment-tab-content active">
|
<div id="pane-card" class="payment-tab-content active"
|
||||||
|
role="tabpanel" aria-labelledby="tab-card" tabindex="0">
|
||||||
{% if saved_cards %}
|
{% if saved_cards %}
|
||||||
<div class="mb-3">
|
<fieldset class="mb-3">
|
||||||
<p class="fw-semibold">Tarjetas guardadas:</p>
|
<legend class="fw-semibold fs-6 mb-2">Selección de tarjeta</legend>
|
||||||
{% for card in saved_cards %}
|
{% for card in saved_cards %}
|
||||||
<div class="form-check mb-2">
|
<div class="form-check mb-2">
|
||||||
<input class="form-check-input" type="radio" name="saved_card_choice" id="card-{{ card.id }}" value="{{ card.id }}" data-pm-id="{{ card.stripe_payment_method_id }}" {% if card.is_default %}checked{% endif %}>
|
<input class="form-check-input" type="radio" name="saved_card_choice" id="card-{{ card.id }}" value="{{ card.id }}" data-pm-id="{{ card.stripe_payment_method_id }}" {% if card.is_default %}checked{% endif %}>
|
||||||
@@ -156,7 +159,7 @@
|
|||||||
<input class="form-check-input" type="radio" name="saved_card_choice" id="card-new" value="new">
|
<input class="form-check-input" type="radio" name="saved_card_choice" id="card-new" value="new">
|
||||||
<label class="form-check-label" for="card-new">Usar nueva tarjeta</label>
|
<label class="form-check-label" for="card-new">Usar nueva tarjeta</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
|
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
|
||||||
@@ -183,7 +186,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PayPal tab -->
|
<!-- PayPal tab -->
|
||||||
<div id="pane-paypal" class="payment-tab-content">
|
<div id="pane-paypal" class="payment-tab-content"
|
||||||
|
role="tabpanel" aria-labelledby="tab-paypal" tabindex="0">
|
||||||
{% if saved_paypal %}
|
{% if saved_paypal %}
|
||||||
<div class="alert alert-light border mb-3">
|
<div class="alert alert-light border mb-3">
|
||||||
<small class="text-muted">Cuenta PayPal guardada:</small>
|
<small class="text-muted">Cuenta PayPal guardada:</small>
|
||||||
@@ -196,6 +200,7 @@
|
|||||||
Guardar esta cuenta de PayPal para futuras compras
|
Guardar esta cuenta de PayPal para futuras compras
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="paypal-errors" class="alert alert-danger d-none mt-2" role="alert"></div>
|
||||||
<div id="paypal-button-container"></div>
|
<div id="paypal-button-container"></div>
|
||||||
{% if not addresses or stock_issues %}
|
{% if not addresses or stock_issues %}
|
||||||
<div class="alert alert-warning mt-2">Selecciona una dirección de envío válida para activar el pago.</div>
|
<div class="alert alert-warning mt-2">Selecciona una dirección de envío válida para activar el pago.</div>
|
||||||
@@ -221,12 +226,42 @@ const HAS_STOCK_ISSUES = {{ stock_issues|yesno:"true,false" }};
|
|||||||
const HAS_ADDRESS = {{ addresses|yesno:"true,false" }};
|
const HAS_ADDRESS = {{ addresses|yesno:"true,false" }};
|
||||||
|
|
||||||
// ---- Tab switching ----
|
// ---- Tab switching ----
|
||||||
document.querySelectorAll('#paymentTabs .nav-link').forEach(btn => {
|
const paymentTabs = Array.from(document.querySelectorAll('#paymentTabs .nav-link[role="tab"]'));
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('#paymentTabs .nav-link').forEach(b => b.classList.remove('active'));
|
function activateTab(tab) {
|
||||||
document.querySelectorAll('.payment-tab-content').forEach(p => p.classList.remove('active'));
|
paymentTabs.forEach(b => {
|
||||||
btn.classList.add('active');
|
const isSelected = b === tab;
|
||||||
document.getElementById(btn.dataset.tab).classList.add('active');
|
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;
|
const addressId = document.getElementById('shipping-address').value;
|
||||||
if (!addressId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardErrorsEl = document.getElementById('card-errors');
|
||||||
|
if (cardErrorsEl) cardErrorsEl.textContent = '';
|
||||||
const btn = document.getElementById('pay-card-btn');
|
const btn = document.getElementById('pay-card-btn');
|
||||||
const spinner = document.getElementById('card-spinner');
|
const spinner = document.getElementById('card-spinner');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -335,9 +373,15 @@ paypal.Buttons({
|
|||||||
createOrder: async () => {
|
createOrder: async () => {
|
||||||
const addressId = document.getElementById('shipping-address').value;
|
const addressId = document.getElementById('shipping-address').value;
|
||||||
if (!addressId) {
|
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'));
|
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" %}', {
|
const resp = await fetch('{% url "crear_orden_paypal" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||||
@@ -359,7 +403,11 @@ paypal.Buttons({
|
|||||||
showSuccess(result.transaction_code);
|
showSuccess(result.transaction_code);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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');
|
}).render('#paypal-button-container');
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -13,74 +13,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" enctype="multipart/form-data">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
<!-- Nombre del producto -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="name" class="form-label">Nombre del Producto <span class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control" id="name" name="name" required maxlength="200"
|
|
||||||
placeholder="Ej: iPhone 15 Pro Max">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Descripción breve -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="briefdesc" class="form-label">Descripción Breve</label>
|
|
||||||
<input type="text" class="form-control" id="briefdesc" name="briefdesc" maxlength="250"
|
|
||||||
placeholder="Una descripción corta para mostrar en las tarjetas de producto">
|
|
||||||
<div class="form-text">Opcional. Se mostrará en las vistas de listado de productos.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Descripción completa -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="description" class="form-label">Descripción Completa <span class="text-danger">*</span></label>
|
|
||||||
<textarea class="form-control" id="description" name="description" rows="5" required
|
|
||||||
placeholder="Describe tu producto en detalle..."></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Precio -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="price" class="form-label">Precio <span class="text-danger">*</span></label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">€</span>
|
|
||||||
<input type="number" class="form-control" id="price" name="price" required
|
|
||||||
min="0" step="0.01" placeholder="0.00">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stock -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="stock" class="form-label">Stock disponible <span class="text-danger">*</span></label>
|
|
||||||
<input type="number" class="form-control" id="stock" name="stock" required
|
|
||||||
min="0" step="1" placeholder="0">
|
|
||||||
<div class="form-text">Cantidad máxima que podrán comprar los clientes.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Categoría -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="category" class="form-label">Categoría <span class="text-danger">*</span></label>
|
|
||||||
<select class="form-select" id="category" name="category" required>
|
|
||||||
<option value="" selected disabled>Selecciona una categoría</option>
|
|
||||||
{% for category in categories %}
|
|
||||||
<option value="{{ category.id }}">{{ category.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Imagen principal -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="primary_image" class="form-label">Imagen Principal</label>
|
|
||||||
<input type="file" class="form-control" id="primary_image" name="primary_image"
|
|
||||||
accept="image/*">
|
|
||||||
<div class="form-text">Opcional. Esta será la imagen destacada del producto.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Imágenes secundarias -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="secondary_images" class="form-label">Imágenes Secundarias</label>
|
|
||||||
<input type="file" class="form-control" id="secondary_images" name="secondary_images"
|
|
||||||
accept="image/*" multiple>
|
|
||||||
<div class="form-text">Opcional. Puedes seleccionar múltiples imágenes adicionales.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Botones -->
|
<!-- Botones -->
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<div class="d-flex justify-content-end gap-2">
|
||||||
<a href="{% url 'mis_productos' %}" class="btn btn-secondary">Cancelar</a>
|
<a href="{% url 'mis_productos' %}" class="btn btn-secondary">Cancelar</a>
|
||||||
|
|||||||
@@ -21,52 +21,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="mb-3">
|
{{ form.as_p }}
|
||||||
<label for="full_name" class="form-label">Nombre Completo *</label>
|
|
||||||
<input type="text" class="form-control" id="full_name" name="full_name" value="{{ direccion.full_name|default:'' }}" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="address_line_1" class="form-label">Dirección *</label>
|
|
||||||
<input type="text" class="form-control" id="address_line_1" name="address_line_1" value="{{ direccion.address_line_1|default:'' }}" placeholder="Calle, número, piso, puerta" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="address_line_2" class="form-label">Dirección (línea 2)</label>
|
|
||||||
<input type="text" class="form-control" id="address_line_2" name="address_line_2" value="{{ direccion.address_line_2|default:'' }}" placeholder="Edificio, bloque, etc. (opcional)">
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="city" class="form-label">Ciudad/Pueblo (Almería) *</label>
|
|
||||||
<input type="text" class="form-control" id="city" name="city" value="{{ direccion.city|default:'' }}" list="almeria-towns" autocomplete="off" required>
|
|
||||||
<datalist id="almeria-towns">
|
|
||||||
{% for town in almeria_municipalities %}
|
|
||||||
<option value="{{ town }}"></option>
|
|
||||||
{% endfor %}
|
|
||||||
</datalist>
|
|
||||||
<div class="form-text">Selecciona o escribe un municipio de la provincia de Almería.</div>
|
|
||||||
<div class="invalid-feedback" id="city-validation-message">
|
|
||||||
El pueblo/ciudad debe pertenecer a la provincia de Almería.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="postal_code" class="form-label">Código Postal *</label>
|
|
||||||
<input type="text" class="form-control" id="postal_code" name="postal_code" value="{{ direccion.postal_code|default:'' }}" pattern="04[0-9]{3}" maxlength="5" placeholder="04XXX" required>
|
|
||||||
<div class="form-text">Solo aceptamos códigos postales de Almería (04xxx).</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="country" class="form-label">País *</label>
|
|
||||||
<input type="text" class="form-control" id="country" name="country" value="España" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="phone" class="form-label">Teléfono *</label>
|
|
||||||
<input type="tel" class="form-control" id="phone" name="phone" value="{{ direccion.phone|default:'' }}" placeholder="+34 600 000 000" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3 form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="is_default" name="is_default" {% if direccion.is_default %}checked{% endif %}>
|
|
||||||
<label class="form-check-label" for="is_default">
|
|
||||||
Establecer como dirección predeterminada
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">{% if direccion %}Actualizar{% else %}Crear{% endif %} Dirección</button>
|
<button type="submit" class="btn btn-primary">{% if direccion %}Actualizar{% else %}Crear{% endif %} Dirección</button>
|
||||||
<a href="{% url 'direcciones_usuario' %}" class="btn btn-secondary">Cancelar</a>
|
<a href="{% url 'direcciones_usuario' %}" class="btn btn-secondary">Cancelar</a>
|
||||||
@@ -80,7 +35,6 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const cityInput = document.getElementById('city');
|
const cityInput = document.getElementById('city');
|
||||||
const cityValidationMessage = document.getElementById('city-validation-message');
|
|
||||||
const form = cityInput ? cityInput.form : null;
|
const form = cityInput ? cityInput.form : null;
|
||||||
|
|
||||||
if (!cityInput || !form) {
|
if (!cityInput || !form) {
|
||||||
@@ -123,8 +77,6 @@
|
|||||||
cityInput.setCustomValidity('El pueblo/ciudad debe pertenecer a la provincia de Almería.');
|
cityInput.setCustomValidity('El pueblo/ciudad debe pertenecer a la provincia de Almería.');
|
||||||
cityInput.classList.add('is-invalid');
|
cityInput.classList.add('is-invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
cityValidationMessage.textContent = cityInput.validationMessage || 'El pueblo/ciudad debe pertenecer a la provincia de Almería.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cityInput.addEventListener('input', validateTown);
|
cityInput.addEventListener('input', validateTown);
|
||||||
|
|||||||
@@ -37,18 +37,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="mb-3">
|
{{ form.as_p }}
|
||||||
<label for="first_name" class="form-label">Nombre</label>
|
|
||||||
<input type="text" class="form-control" id="first_name" name="first_name" value="{{ user.first_name }}" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="last_name" class="form-label">Apellidos</label>
|
|
||||||
<input type="text" class="form-control" id="last_name" name="last_name" value="{{ user.last_name }}">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="email" class="form-label">Correo Electrónico</label>
|
|
||||||
<input type="email" class="form-control" id="email" name="email" value="{{ user.email }}" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">Nombre de Usuario</label>
|
<label for="username" class="form-label">Nombre de Usuario</label>
|
||||||
<input type="text" class="form-control" id="username" value="{{ user.username }}" disabled>
|
<input type="text" class="form-control" id="username" value="{{ user.username }}" disabled>
|
||||||
@@ -69,19 +58,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="{% url 'cambiar_contrasena' %}">
|
<form method="POST" action="{% url 'cambiar_contrasena' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="mb-3">
|
{{ password_form.as_p }}
|
||||||
<label for="current_password" class="form-label">Contraseña Actual</label>
|
|
||||||
<input type="password" class="form-control" id="current_password" name="current_password" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="new_password" class="form-label">Nueva Contraseña</label>
|
|
||||||
<input type="password" class="form-control" id="new_password" name="new_password" required>
|
|
||||||
<small class="text-muted">Mínimo 8 caracteres</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="confirm_password" class="form-label">Confirmar Nueva Contraseña</label>
|
|
||||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-warning">Cambiar Contraseña</button>
|
<button type="submit" class="btn btn-warning">Cambiar Contraseña</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,67 +13,9 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" enctype="multipart/form-data">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
|
||||||
<!-- Nombre del producto -->
|
<!-- Imágenes secundarias (no incluidas en el form) -->
|
||||||
<div class="mb-3">
|
|
||||||
<label for="name" class="form-label">Nombre del Producto <span class="text-danger">*</span></label>
|
|
||||||
<input type="text" class="form-control" id="name" name="name" required maxlength="200"
|
|
||||||
value="{{ producto.name }}" placeholder="Ej: iPhone 15 Pro Max">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Descripción breve -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="briefdesc" class="form-label">Descripción Breve</label>
|
|
||||||
<input type="text" class="form-control" id="briefdesc" name="briefdesc" maxlength="250"
|
|
||||||
value="{{ producto.briefdesc }}" placeholder="Una descripción corta para mostrar en las tarjetas de producto">
|
|
||||||
<div class="form-text">Opcional. Se mostrará en las vistas de listado de productos.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Descripción completa -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="description" class="form-label">Descripción Completa <span class="text-danger">*</span></label>
|
|
||||||
<textarea class="form-control" id="description" name="description" rows="5" required
|
|
||||||
placeholder="Describe tu producto en detalle...">{{ producto.description }}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Precio -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="price" class="form-label">Precio <span class="text-danger">*</span></label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">€</span>
|
|
||||||
<input type="number" class="form-control" id="price" name="price" required
|
|
||||||
min="0" step="0.01" value="{{ producto.price }}" placeholder="0.00">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stock -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="stock" class="form-label">Stock disponible <span class="text-danger">*</span></label>
|
|
||||||
<input type="number" class="form-control" id="stock" name="stock" required
|
|
||||||
min="0" step="1" value="{{ producto.stock }}" placeholder="0">
|
|
||||||
<div class="form-text">Cantidad máxima que podrán comprar los clientes.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Categoría -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="category" class="form-label">Categoría <span class="text-danger">*</span></label>
|
|
||||||
<select class="form-select" id="category" name="category" required>
|
|
||||||
<option value="" disabled>Selecciona una categoría</option>
|
|
||||||
{% for category in categories %}
|
|
||||||
<option value="{{ category.id }}" {% if producto.category.id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Imagen principal -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="primary_image" class="form-label">Imagen Principal</label>
|
|
||||||
<input type="file" class="form-control" id="primary_image" name="primary_image"
|
|
||||||
accept="image/*">
|
|
||||||
<div class="form-text">Opcional. Si subes una nueva, reemplazará la actual.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Imágenes secundarias -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="secondary_images" class="form-label">Imágenes Secundarias</label>
|
<label for="secondary_images" class="form-label">Imágenes Secundarias</label>
|
||||||
<input type="file" class="form-control" id="secondary_images" name="secondary_images"
|
<input type="file" class="form-control" id="secondary_images" name="secondary_images"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 20px;">
|
||||||
|
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background-color: #007bff; padding: 40px;">
|
||||||
|
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido bloqueada</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px">
|
||||||
|
<svg fill="#FF0000" width="128px" height="128px" viewBox="-3.2 -3.2 38.40 38.40" version="1.1" xmlns="http://www.w3.org/2000/svg" stroke="#FF0000" stroke-width="0.00032"><g id="SVGRepo_bgCarrier" stroke-width="0" transform="translate(6.4,6.4), scale(0.6)"><rect x="-3.2" y="-3.2" width="38.40" height="38.40" rx="19.2" fill="#1a5fb4" strokewidth="0"></rect></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="1.152"> <title>alert</title> <path d="M14.611 18.856c-0.346 0.352-0.52 0.782-0.52 1.292 0 0.551 0.197 1.014 0.59 1.389 0.363 0.346 0.799 0.519 1.309 0.519 0.521 0 0.971-0.188 1.346-0.566s0.562-0.828 0.562-1.35c0-0.504-0.182-0.943-0.545-1.318-0.363-0.381-0.801-0.571-1.311-0.571-0.567-0.001-1.044 0.201-1.431 0.605v0zM14.391 10.788c-0.299 0.451-0.447 1.011-0.447 1.679 0 0.545 0.092 1.146 0.276 1.802s0.435 1.271 0.751 1.846c0.428 0.779 0.76 1.169 0.994 1.169 0.24 0 0.557-0.305 0.949-0.914 0.346-0.539 0.622-1.152 0.83-1.841s0.312-1.332 0.312-1.93c0-0.902-0.244-1.6-0.73-2.092-0.363-0.375-0.805-0.563-1.326-0.563-0.703 0-1.24 0.282-1.609 0.844v0z"></path> </g><g id="SVGRepo_iconCarrier"> <title>alert</title> <path d="M14.611 18.856c-0.346 0.352-0.52 0.782-0.52 1.292 0 0.551 0.197 1.014 0.59 1.389 0.363 0.346 0.799 0.519 1.309 0.519 0.521 0 0.971-0.188 1.346-0.566s0.562-0.828 0.562-1.35c0-0.504-0.182-0.943-0.545-1.318-0.363-0.381-0.801-0.571-1.311-0.571-0.567-0.001-1.044 0.201-1.431 0.605v0zM14.391 10.788c-0.299 0.451-0.447 1.011-0.447 1.679 0 0.545 0.092 1.146 0.276 1.802s0.435 1.271 0.751 1.846c0.428 0.779 0.76 1.169 0.994 1.169 0.24 0 0.557-0.305 0.949-0.914 0.346-0.539 0.622-1.152 0.83-1.841s0.312-1.332 0.312-1.93c0-0.902-0.244-1.6-0.73-2.092-0.363-0.375-0.805-0.563-1.326-0.563-0.703 0-1.24 0.282-1.609 0.844v0z"></path> </g></svg>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
|
||||||
|
<p>Lamentamos informarle de que el equipo de moderación ha tomado acciones en su cuenta</p>
|
||||||
|
<p>Su cuenta ha sido bloqueada indefinidamente y sus productos han sido eliminados de la tienda.</p>
|
||||||
|
<p>Si desea apelar, por favor, contacte con Soporte Técnico</p>
|
||||||
|
<p></p>
|
||||||
|
<p style="color: gray;">Este email ha sido enviado automaticamente, no responda a este correo.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 20px;">
|
||||||
|
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background-color: #007bff; padding: 40px;">
|
||||||
|
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido desbloqueada</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px">
|
||||||
|
<svg fill="#FF0000" width="128px" height="128px" viewBox="-3.2 -3.2 38.40 38.40" version="1.1" xmlns="http://www.w3.org/2000/svg" stroke="#FF0000" stroke-width="0.00032"><g id="SVGRepo_bgCarrier" stroke-width="0" transform="translate(6.4,6.4), scale(0.6)"><rect x="-3.2" y="-3.2" width="38.40" height="38.40" rx="19.2" fill="#1a5fb4" strokewidth="0"></rect></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="1.152"> <title>alert</title> <path d="M14.611 18.856c-0.346 0.352-0.52 0.782-0.52 1.292 0 0.551 0.197 1.014 0.59 1.389 0.363 0.346 0.799 0.519 1.309 0.519 0.521 0 0.971-0.188 1.346-0.566s0.562-0.828 0.562-1.35c0-0.504-0.182-0.943-0.545-1.318-0.363-0.381-0.801-0.571-1.311-0.571-0.567-0.001-1.044 0.201-1.431 0.605v0zM14.391 10.788c-0.299 0.451-0.447 1.011-0.447 1.679 0 0.545 0.092 1.146 0.276 1.802s0.435 1.271 0.751 1.846c0.428 0.779 0.76 1.169 0.994 1.169 0.24 0 0.557-0.305 0.949-0.914 0.346-0.539 0.622-1.152 0.83-1.841s0.312-1.332 0.312-1.93c0-0.902-0.244-1.6-0.73-2.092-0.363-0.375-0.805-0.563-1.326-0.563-0.703 0-1.24 0.282-1.609 0.844v0z"></path> </g><g id="SVGRepo_iconCarrier"> <title>alert</title> <path d="M14.611 18.856c-0.346 0.352-0.52 0.782-0.52 1.292 0 0.551 0.197 1.014 0.59 1.389 0.363 0.346 0.799 0.519 1.309 0.519 0.521 0 0.971-0.188 1.346-0.566s0.562-0.828 0.562-1.35c0-0.504-0.182-0.943-0.545-1.318-0.363-0.381-0.801-0.571-1.311-0.571-0.567-0.001-1.044 0.201-1.431 0.605v0zM14.391 10.788c-0.299 0.451-0.447 1.011-0.447 1.679 0 0.545 0.092 1.146 0.276 1.802s0.435 1.271 0.751 1.846c0.428 0.779 0.76 1.169 0.994 1.169 0.24 0 0.557-0.305 0.949-0.914 0.346-0.539 0.622-1.152 0.83-1.841s0.312-1.332 0.312-1.93c0-0.902-0.244-1.6-0.73-2.092-0.363-0.375-0.805-0.563-1.326-0.563-0.703 0-1.24 0.282-1.609 0.844v0z"></path> </g></svg>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
|
||||||
|
<p>Hemos aceptado la apelación del previo baneo de su cuenta</p>
|
||||||
|
<p>Su cuenta ha sido desbloqueada y ya puede entrar, pero los productos seguirán eliminados, por lo que deberá recrearlos para seguir vendiendolos</p>
|
||||||
|
<p>Muchas gracias por su paciencia</p>
|
||||||
|
<p></p>
|
||||||
|
<p style="color: gray;">Este email ha sido enviado automaticamente, no responda a este correo.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{% for field in form %}
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ field.errors }}
|
||||||
|
{{ field.label_tag }} {{ field }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "tienda/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4 mb-5">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h2>Gestionar Imágenes</h2>
|
||||||
|
<p class="text-muted mb-0">Producto: <strong>{{ producto.name }}</strong></p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'mis_productos' %}" class="btn btn-outline-secondary">← Volver a Mis Productos</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Imagen Principal</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if producto.primary_image %}
|
||||||
|
<img src="{{ producto.primary_image.image.url }}" alt="{{ producto.primary_image.alt|default:producto.name }}" class="rounded" style="width: 200px; height: 200px; object-fit: cover;">
|
||||||
|
<p class="mt-2 text-muted mb-0">Esta imagen no se puede cambiar desde aquí.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No hay imagen principal asignada.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Imágenes Secundarias</h5>
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#agregarImagenModal">
|
||||||
|
➕ Agregar Imagen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if secondary_images %}
|
||||||
|
<div class="row">
|
||||||
|
{% for img in secondary_images %}
|
||||||
|
<div class="col-md-3 col-sm-4 col-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<img src="{{ img.image.url }}" alt="{{ img.alt|default:producto.name }}" class="card-img-top" style="height: 180px; object-fit: cover;">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<form method="POST" action="{% url 'eliminar_imagen_secundaria' producto.id img.id %}" onsubmit="return confirm('¿Seguro que quieres eliminar esta imagen?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm w-100">🗑 Eliminar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center py-4">No hay imágenes secundarias. ¡Agrega una!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="agregarImagenModal" tabindex="-1" aria-labelledby="agregarImagenModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="agregarImagenModalLabel">Agregar Imagen Secundaria</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ form }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Subir Imagen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -196,9 +196,11 @@
|
|||||||
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
|
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
|
||||||
Ver detalles
|
Ver detalles
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'add_to_cart' product.id %}" class="btn btn-outline-primary btn-sm">
|
<form method="post" action="{% url 'add_to_cart' product.id %}" style="display:inline;">
|
||||||
🛒
|
{% csrf_token %}
|
||||||
</a>
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm">🛒</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,22 +12,7 @@
|
|||||||
<form method="post" action="{% url 'login' %}">
|
<form method="post" action="{% url 'login' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="mb-3">
|
{{ form }}
|
||||||
<label for="loginEmail" class="form-label">Correo Electrónico</label>
|
|
||||||
<input type="email" class="form-control" id="loginEmail" name="email" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="loginPassword" class="form-label">Contraseña</label>
|
|
||||||
<input type="password" class="form-control" id="loginPassword" name="password" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3 form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="rememberMe" name="remember">
|
|
||||||
<label class="form-check-label" for="rememberMe">
|
|
||||||
Recordarme
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary">Iniciar Sesión</button>
|
<button type="submit" class="btn btn-primary">Iniciar Sesión</button>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
<td class="text-end">{{ producto.stock }}</td>
|
<td class="text-end">{{ producto.stock }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<div class="d-flex justify-content-end gap-2">
|
||||||
|
<a href="{% url 'gestionar_imagenes' producto.id %}" class="btn btn-outline-secondary btn-sm">Gestionar Imágenes</a>
|
||||||
<a href="{% url 'editar_producto' producto.id %}" class="btn btn-outline-primary btn-sm">Editar</a>
|
<a href="{% url 'editar_producto' producto.id %}" class="btn btn-outline-primary btn-sm">Editar</a>
|
||||||
<form method="POST" action="{% url 'borrar_producto' producto.id %}" onsubmit="return confirm('¿Seguro que quieres borrar este producto?');">
|
<form method="POST" action="{% url 'borrar_producto' producto.id %}" onsubmit="return confirm('¿Seguro que quieres borrar este producto?');">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -34,16 +34,16 @@
|
|||||||
|
|
||||||
<div class="small text-primary font-weight-bold mb-2">Precio total (IVA incluido):</div>
|
<div class="small text-primary font-weight-bold mb-2">Precio total (IVA incluido):</div>
|
||||||
<span class="price" style="font-size: 2rem; color: #28a745;">€{{ product.get_price_with_vat|format_price }}</span>
|
<span class="price" style="font-size: 2rem; color: #28a745;">€{{ product.get_price_with_vat|format_price }}</span>
|
||||||
<div class="small text-success mt-2">IVA: €{{ product.get_vat_amount|format_price }}</div>
|
<div class="small text-success mt-2"><span aria-hidden="true">ℹ️</span> IVA incluido: €{{ product.get_vat_amount|format_price }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="descripcion" class="texto-ajustado">
|
<div id="descripcion" class="texto-ajustado">
|
||||||
{{ product.briefdesc }}
|
{{ product.briefdesc }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
{% if product.stock > 0 %}
|
{% if product.stock > 0 %}
|
||||||
<span class="badge bg-success">Stock disponible: {{ product.stock }}</span>
|
<span class="badge bg-success" role="status"><span aria-hidden="true">✓</span> Stock disponible: {{ product.stock }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-danger">Sin stock</span>
|
<span class="badge bg-danger" role="status"><span aria-hidden="true">✗</span> Sin stock</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,33 +12,7 @@
|
|||||||
<form method="post" action="{% url 'register' %}">
|
<form method="post" action="{% url 'register' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="mb-3">
|
{{ form }}
|
||||||
<label for="registerName" class="form-label">Nombre Completo</label>
|
|
||||||
<input type="text" class="form-control" id="registerName" name="name" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="registerEmail" class="form-label">Correo Electrónico</label>
|
|
||||||
<input type="email" class="form-control" id="registerEmail" name="email" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="registerPassword" class="form-label">Contraseña</label>
|
|
||||||
<input type="password" class="form-control" id="registerPassword" name="password" required>
|
|
||||||
<div class="form-text">La contraseña debe tener al menos 8 caracteres.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="registerPasswordConfirm" class="form-label">Confirmar Contraseña</label>
|
|
||||||
<input type="password" class="form-control" id="registerPasswordConfirm" name="password_confirm" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3 form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="acceptTerms" name="terms" required>
|
|
||||||
<label class="form-check-label" for="acceptTerms">
|
|
||||||
Acepto los <a href="#" target="_blank">términos y condiciones</a>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary">Crear Cuenta</button>
|
<button type="submit" class="btn btn-primary">Crear Cuenta</button>
|
||||||
|
|||||||
@@ -11,11 +11,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="{% url 'reset_password' %}">
|
<form method="post" action="{% url 'reset_password' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
<div class="mb-3">
|
|
||||||
<label for="loginEmail" class="form-label">Correo Electrónico</label>
|
|
||||||
<input type="email" class="form-control" id="loginEmail" name="email" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary">Recuperar contraseña</button>
|
<button type="submit" class="btn btn-primary">Recuperar contraseña</button>
|
||||||
|
|||||||
@@ -11,16 +11,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="{% url 'reset_password_phase2' code %}">
|
<form method="post" action="{% url 'reset_password_phase2' code %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Contraseña</label>
|
|
||||||
<input type="password" class="form-control" id="password" name="password" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="verify_password" class="form-label">Verificar contraseña</label>
|
|
||||||
<input type="password" class="form-control" id="verify_password" name="verify_password" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary">Recuperar contraseña</button>
|
<button type="submit" class="btn btn-primary">Recuperar contraseña</button>
|
||||||
|
|||||||
@@ -79,9 +79,11 @@
|
|||||||
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
|
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
|
||||||
Ver detalles
|
Ver detalles
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'add_to_cart' product.id %}" class="btn btn-outline-primary btn-sm">
|
<form method="post" action="{% url 'add_to_cart' product.id %}" style="display:inline;">
|
||||||
🛒
|
{% csrf_token %}
|
||||||
</a>
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm">🛒</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+193
@@ -1,4 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
@@ -12,6 +14,7 @@ from .models import (
|
|||||||
StockReservation, StockReservationItem, Cart, CartItem,
|
StockReservation, StockReservationItem, Cart, CartItem,
|
||||||
Order, OrderItem, OrderMessage, SavedPaymentMethod, ShippingAddress
|
Order, OrderItem, OrderMessage, SavedPaymentMethod, ShippingAddress
|
||||||
)
|
)
|
||||||
|
from .forms import UserRegisterForm, UserLoginForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form
|
||||||
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX
|
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX
|
||||||
import string
|
import string
|
||||||
import random
|
import random
|
||||||
@@ -21,6 +24,185 @@ import random
|
|||||||
class UserModelTests(TestCase):
|
class UserModelTests(TestCase):
|
||||||
"""Tests exhaustivos para el modelo User."""
|
"""Tests exhaustivos para el modelo User."""
|
||||||
|
|
||||||
|
|
||||||
|
class FormTests(TestCase):
|
||||||
|
"""Tests para formularios Django."""
|
||||||
|
|
||||||
|
def test_user_register_form_terms_required(self):
|
||||||
|
"""El campo terms debe ser obligatorio."""
|
||||||
|
form = UserRegisterForm(data={
|
||||||
|
"name": "Test User",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"password_confirm": "password123",
|
||||||
|
})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("terms", form.errors)
|
||||||
|
|
||||||
|
def test_user_register_form_terms_off_not_checked(self):
|
||||||
|
"""Si terms está en off (None/false), debe fallar."""
|
||||||
|
form = UserRegisterForm(data={
|
||||||
|
"name": "Test User",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"password_confirm": "password123",
|
||||||
|
"terms": False,
|
||||||
|
})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("terms", form.errors)
|
||||||
|
|
||||||
|
def test_user_register_form_terms_on(self):
|
||||||
|
"""Si terms está marcado, el formulario debe ser válido."""
|
||||||
|
form = UserRegisterForm(data={
|
||||||
|
"name": "Test User",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"password_confirm": "password123",
|
||||||
|
"terms": True,
|
||||||
|
})
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
def test_user_register_form_passwords_mismatch(self):
|
||||||
|
"""Las contraseñas deben coincidir."""
|
||||||
|
form = UserRegisterForm(data={
|
||||||
|
"name": "Test User",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"password_confirm": "different_password",
|
||||||
|
"terms": True,
|
||||||
|
})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("__all__", form.errors)
|
||||||
|
|
||||||
|
def test_user_register_form_empty_fields(self):
|
||||||
|
"""Los campos obligatorios no pueden estar vacíos."""
|
||||||
|
form = UserRegisterForm(data={})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("name", form.errors)
|
||||||
|
self.assertIn("email", form.errors)
|
||||||
|
self.assertIn("password", form.errors)
|
||||||
|
self.assertIn("password_confirm", form.errors)
|
||||||
|
|
||||||
|
def test_user_login_form_valid(self):
|
||||||
|
"""Login con datos válidos."""
|
||||||
|
form = UserLoginForm(data={
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
})
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
def test_user_login_form_missing_email(self):
|
||||||
|
"""Email es obligatorio en login."""
|
||||||
|
form = UserLoginForm(data={
|
||||||
|
"password": "password123",
|
||||||
|
})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("email", form.errors)
|
||||||
|
|
||||||
|
def test_user_login_form_invalid_email_format(self):
|
||||||
|
"""Email debe tener formato válido."""
|
||||||
|
form = UserLoginForm(data={
|
||||||
|
"email": "not-an-email",
|
||||||
|
"password": "password123",
|
||||||
|
})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("email", form.errors)
|
||||||
|
|
||||||
|
def test_edit_profile_form_valid(self):
|
||||||
|
"""Formulario de edición de perfil válido."""
|
||||||
|
form = EditProfileForm(data={
|
||||||
|
"first_name": "Juan",
|
||||||
|
"last_name": "Pérez",
|
||||||
|
"email": "juan@example.com",
|
||||||
|
})
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
def test_edit_profile_form_missing_email(self):
|
||||||
|
"""Email es obligatorio en perfil."""
|
||||||
|
form = EditProfileForm(data={
|
||||||
|
"first_name": "Juan",
|
||||||
|
"last_name": "Pérez",
|
||||||
|
})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("email", form.errors)
|
||||||
|
|
||||||
|
def test_change_password_form_passwords_mismatch(self):
|
||||||
|
"""Las nuevas contraseñas deben coincidir."""
|
||||||
|
form = ChangePasswordForm(data={
|
||||||
|
"current_password": "oldpass123",
|
||||||
|
"new_password": "newpass123",
|
||||||
|
"confirm_password": "differentpass",
|
||||||
|
})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("__all__", form.errors)
|
||||||
|
|
||||||
|
def test_change_password_form_short_password(self):
|
||||||
|
"""La nueva contraseña debe tener al menos 8 caracteres."""
|
||||||
|
form = ChangePasswordForm(data={
|
||||||
|
"current_password": "oldpass123",
|
||||||
|
"new_password": "short",
|
||||||
|
"confirm_password": "short",
|
||||||
|
})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("__all__", form.errors)
|
||||||
|
|
||||||
|
def test_shipping_address_form_valid(self):
|
||||||
|
"""Dirección con datos válidos."""
|
||||||
|
form = ShippingAddressForm(data={
|
||||||
|
"full_name": "Juan Pérez",
|
||||||
|
"address_line_1": "Calle Mayor 123",
|
||||||
|
"city": "Almería",
|
||||||
|
"postal_code": "04001",
|
||||||
|
"country": "España",
|
||||||
|
"phone": "612345678",
|
||||||
|
})
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
def test_shipping_address_form_missing_required_fields(self):
|
||||||
|
"""Campos obligatorios no pueden estar vacíos."""
|
||||||
|
form = ShippingAddressForm(data={})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("full_name", form.errors)
|
||||||
|
self.assertIn("address_line_1", form.errors)
|
||||||
|
self.assertIn("city", form.errors)
|
||||||
|
self.assertIn("postal_code", form.errors)
|
||||||
|
self.assertIn("phone", form.errors)
|
||||||
|
|
||||||
|
def test_reset_password_form_valid_email(self):
|
||||||
|
"""Formulario de recuperación de contraseña."""
|
||||||
|
form = ResetPasswordForm(data={
|
||||||
|
"email": "test@example.com",
|
||||||
|
})
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
def test_reset_password_form_invalid_email(self):
|
||||||
|
"""Email inválido."""
|
||||||
|
form = ResetPasswordForm(data={
|
||||||
|
"email": "not-an-email",
|
||||||
|
})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("email", form.errors)
|
||||||
|
|
||||||
|
def test_reset_password_phase2_form_valid(self):
|
||||||
|
"""Cambio de contraseña válido."""
|
||||||
|
form = ResetPasswordPhase2Form(data={
|
||||||
|
"password": "newpass123",
|
||||||
|
"verify_password": "newpass123",
|
||||||
|
})
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
def test_reset_password_phase2_form_mismatch(self):
|
||||||
|
"""Las contraseñas deben coincidir."""
|
||||||
|
form = ResetPasswordPhase2Form(data={
|
||||||
|
"password": "newpass123",
|
||||||
|
"verify_password": "different",
|
||||||
|
})
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
self.assertIn("__all__", form.errors)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== ENDPOINT VIEW TESTS ====================
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user_data = {
|
self.user_data = {
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
@@ -1371,6 +1553,16 @@ class EndpointViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'site-title-mobile d-md-none')
|
self.assertContains(response, 'site-title-mobile d-md-none')
|
||||||
self.assertContains(response, 'site-title-desktop')
|
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<body>[^}]*)\}", 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):
|
def test_home_mobile_welcome_title_centered(self):
|
||||||
response = self.client.get(reverse("home"))
|
response = self.client.get(reverse("home"))
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
@@ -1443,6 +1635,7 @@ class EndpointViewTests(TestCase):
|
|||||||
"email": "nuevo@example.com",
|
"email": "nuevo@example.com",
|
||||||
"password": self.password,
|
"password": self.password,
|
||||||
"password_confirm": self.password,
|
"password_confirm": self.password,
|
||||||
|
"terms": "on",
|
||||||
})
|
})
|
||||||
self.assertEqual(register_response.status_code, 302)
|
self.assertEqual(register_response.status_code, 302)
|
||||||
confirm_delay.assert_called_once()
|
confirm_delay.assert_called_once()
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ urlpatterns = [
|
|||||||
path("venta/crear-producto/", views.crear_producto, name="crear_producto"),
|
path("venta/crear-producto/", views.crear_producto, name="crear_producto"),
|
||||||
path("venta/editar-producto/<int:id>/", views.editar_producto, name="editar_producto"),
|
path("venta/editar-producto/<int:id>/", views.editar_producto, name="editar_producto"),
|
||||||
path("venta/borrar-producto/<int:id>/", views.borrar_producto, name="borrar_producto"),
|
path("venta/borrar-producto/<int:id>/", views.borrar_producto, name="borrar_producto"),
|
||||||
|
path("venta/gestionar-imagenes/<int:id>/", views.gestionar_imagenes, name="gestionar_imagenes"),
|
||||||
|
path("venta/gestionar-imagenes/<int:product_id>/eliminar/<int:image_id>/", views.eliminar_imagen_secundaria, name="eliminar_imagen_secundaria"),
|
||||||
# Carrito
|
# Carrito
|
||||||
path("cart/", views.view_cart, name="view_cart"),
|
path("cart/", views.view_cart, name="view_cart"),
|
||||||
path("cart/add/<int:product_id>/", views.add_to_cart, name="add_to_cart"),
|
path("cart/add/<int:product_id>/", views.add_to_cart, name="add_to_cart"),
|
||||||
|
|||||||
+397
-396
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user