Compare commits

..

66 Commits

Author SHA1 Message Date
elordenador c33def1124 Add staticfiles folder to .gitignore 2026-05-04 22:03:05 +02:00
elordenador 52dfa51af2 Remove Static Files 2026-05-04 22:02:19 +02:00
elordenador a02617f8d2 Move MD files and add an AGENTS.md 2026-05-04 22:01:27 +02:00
elordenador 53b4e89347 Fix tasks.py making tests fail 2026-05-04 22:01:12 +02:00
elordenador df0579dd86 Fix GH Issue #68 2026-05-04 21:59:28 +02:00
elordenador 1022a44f12 Fix GH Issue #65 2026-05-04 19:51:49 +02:00
elordenador bb697d92c6 Fix GH Issue #64 2026-05-04 19:45:47 +02:00
Daniel (elordenador) 191f8823d4 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-04 12:32:16 +02:00
elordenador d75165e31a Arreglar el bug de posiblemente creator y primary_image este en None... 2026-05-04 12:31:49 +02:00
elordenador 6ed4fb1954 Remove punctuation Signs so we generate 'url-safe' codes 2026-05-04 12:30:09 +02:00
elordenador c190a65e57 Opencore files 2026-05-04 12:25:21 +02:00
elordenador 756f1ad36b Remove entire api for issue #61 2026-04-30 07:43:18 +02:00
elordenador 033c52a365 Fix issue #60 verification code generation 2026-04-30 07:39:14 +02:00
elordenador 297b319a20 Fix issue #59 duplicate reset_password 2026-04-30 07:38:17 +02:00
elordenador 830966f3ee Fix issue #58 not deleting verification code. 2026-04-30 07:37:13 +02:00
elordenador 81d3694210 Solving issue #57 Auth 500 bug 2026-04-30 07:35:28 +02:00
Daniel (elordenador) dce0937511 Merge pull request #56 from dsaub/rama-usabilidad
Agregado parche de usabilidad
2026-04-29 17:02:39 +02:00
Daniel (elordenador) 7f8f70bc42 Merge pull request #55 from dsaub/copilot/unify-add-to-cart-post
[WIP] Fix inconsistency in add to cart action using POST
2026-04-29 11:18:42 +02:00
Daniel (elordenador) 7203a07350 Merge pull request #48 from dsaub/copilot/add-skip-link-to-body
Add "Saltar al contenido" skip link for keyboard/screen reader accessibility
2026-04-29 11:15:44 +02:00
copilot-swe-agent[bot] ba75a0ab2e Style skip link to visually integrate with navbar header
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/a04a8e28-dcc3-4338-8ee9-49c7494bf486

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-29 07:38:20 +00:00
Daniel (elordenador) 1f7db2db3a Merge pull request #54 from dsaub/copilot/fix-terms-link-destination
[WIP] Fix terms link without real destination
2026-04-29 09:30:50 +02:00
elordenador a2e6e5ad97 refactor: change StaticStorage to inherit from S3Storage instead of S3ManifestStaticStorage
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 09:30:09 +02:00
copilot-swe-agent[bot] e78a936b21 Fix terms link in register.html to point to terminos view
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/50c087d4-a283-4c38-bda2-5599d42d382f

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-29 07:24:53 +00:00
elordenador 30f260c9bf feat: add support for local asset URLs in S3 storage backends
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 08:12:57 +02:00
elordenador 84d8a0e3b6 Add S3 Storage... 2026-04-28 21:19:32 +02:00
Daniel (elordenador) 68dbbcad07 Merge pull request #52 from dsaub/copilot/fix-payment-errors-modal
[WIP] Fix payment errors with inline error container
2026-04-28 09:19:53 +02:00
copilot-swe-agent[bot] a94c256ad5 Changes before error encountered
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/f8687aac-de86-402f-b36d-ea422d24ed8e

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:18:04 +00:00
Daniel (elordenador) 0ff70589b9 Merge pull request #47 from dsaub/copilot/implement-aria-combobox-pattern
Implement ARIA combobox/listbox pattern for search suggestions
2026-04-28 09:17:11 +02:00
copilot-swe-agent[bot] 25c6fc7315 Add null guards for error container DOM lookups
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/e4ef062a-c246-4ec3-9424-987f29891c30

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:16:26 +00:00
Daniel (elordenador) 9f598f56fe Merge pull request #53 from dsaub/copilot/add-hidden-label-for-quantity
fix(a11y): add unique aria-label to cart quantity inputs
2026-04-28 09:16:22 +02:00
Daniel (elordenador) b905ef435a Merge pull request #49 from dsaub/copilot/improve-stock-iva-color-dependence
fix: replace color-only stock/IVA indicators with explicit text and icons (WCAG Perceptible)
2026-04-28 09:15:22 +02:00
copilot-swe-agent[bot] d849e7d3e6 Replace alert() payment errors with inline role=alert containers in checkout.html
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/e4ef062a-c246-4ec3-9424-987f29891c30

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:14:49 +00:00
Daniel (elordenador) 5fa127ddf7 Merge pull request #51 from dsaub/copilot/complete-aria-support-for-tabs
fix: complete WAI-ARIA tabs pattern in checkout payment tabs
2026-04-28 09:14:47 +02:00
copilot-swe-agent[bot] 3f521d81b4 fix(a11y): add aria-label to cart quantity input for each product
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/36168486-a2a4-41f3-b3a3-8adf781b354a

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:13:11 +00:00
copilot-swe-agent[bot] 6828074dd1 fix: complete WAI-ARIA tabs pattern in checkout.html (aria-selected, aria-controls, tabpanel, keyboard nav)
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/73a76f50-8c55-4285-81cf-931b63290b81

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:11:24 +00:00
copilot-swe-agent[bot] ad9fa741e5 fix: add role=status to stock badge indicators for better screen reader support
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/b6a3a32a-ff80-4431-9ba0-769cbd08b939

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:11:20 +00:00
Daniel (elordenador) 07486bb5ec Merge pull request #50 from dsaub/copilot/add-fieldset-legend-to-radio-group
fix(a11y): wrap saved-card radio group in fieldset/legend
2026-04-28 09:11:19 +02:00
copilot-swe-agent[bot] a36740b02d fix: add explicit text and icons for stock/IVA accessibility (WCAG Perceptible)
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/b6a3a32a-ff80-4431-9ba0-769cbd08b939

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:10:02 +00:00
copilot-swe-agent[bot] cb31784097 Implement ARIA combobox/listbox pattern for search suggestions
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/86ca48b3-a56a-4392-9295-0f45ed4f752f

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:09:44 +00:00
copilot-swe-agent[bot] 17935c6160 Add :focus-visible to skip link for better keyboard navigation UX
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/6f9c00f2-c1ee-4dc2-80fb-2596645e9221

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:09:31 +00:00
copilot-swe-agent[bot] 63df5cf73f Add skip link 'Saltar al contenido' for keyboard/screen reader accessibility
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/6f9c00f2-c1ee-4dc2-80fb-2596645e9221

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:08:35 +00:00
copilot-swe-agent[bot] fe61b3a212 fix: wrap saved-card radios in fieldset/legend for accessibility
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/bddffd0c-804e-448e-9954-98917149de3c

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 07:07:12 +00:00
copilot-swe-agent[bot] d55026b69d Initial plan 2026-04-28 07:06:40 +00:00
copilot-swe-agent[bot] bdae5b073c Initial plan 2026-04-28 07:06:32 +00:00
copilot-swe-agent[bot] 183685519a Initial plan 2026-04-28 07:06:25 +00:00
copilot-swe-agent[bot] 0a9b9138bc Initial plan 2026-04-28 07:06:16 +00:00
copilot-swe-agent[bot] 3eb963fadf Initial plan 2026-04-28 07:06:05 +00:00
copilot-swe-agent[bot] 8a5edce758 Initial plan 2026-04-28 07:05:57 +00:00
copilot-swe-agent[bot] 0eaaa8d19d Initial plan 2026-04-28 07:05:42 +00:00
copilot-swe-agent[bot] 71cbf6825e Initial plan 2026-04-28 07:05:32 +00:00
copilot-swe-agent[bot] dd49a6a7d6 Initial plan 2026-04-28 07:05:22 +00:00
Daniel (elordenador) f785b1862f Merge pull request #46 from dsaub/copilot/fix-search-button-icon
fix(a11y): add visible text and aria-label to search button
2026-04-28 09:04:47 +02:00
copilot-swe-agent[bot] ea6c9c49a0 fix: add aria-label and visible text to search button for accessibility
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/d1c1f40a-b3a3-4c18-98f9-be267a4a043b

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-28 06:56:38 +00:00
copilot-swe-agent[bot] edda5aca50 Initial plan 2026-04-28 06:50:48 +00:00
elordenador d8f6838f0c refactor: update Product model to use to_dict for image serialization 2026-04-22 09:52:30 +02:00
elordenador d9d9e5b1a6 refactor: update products endpoint to use to_dict method for consistency 2026-04-22 09:52:20 +02:00
elordenador 501d7aade5 chore: add media directory to .gitignore 2026-04-22 09:52:15 +02:00
elordenador 7d3cff0bd9 refactor: rename __dict__ methods to to_dict for consistency in models 2026-04-22 09:22:20 +02:00
elordenador 3cbca38c32 feat: add __dict__ method to models for JSON serialization 2026-04-22 09:15:32 +02:00
elordenador dc967c114f feat: implement initial API endpoints for product retrieval 2026-04-22 09:15:28 +02:00
elordenador 60cd29ee30 feat: add API URL routing to urlpatterns 2026-04-22 09:15:24 +02:00
elordenador 540b3fdc43 chore: add django-ninja to requirements 2026-04-22 09:15:19 +02:00
Daniel (elordenador) 9e33f5b89c Keep mobile navbar title anchored when collapse menu expands (#35)
* Initial plan

* fix: keep mobile header title aligned when navbar menu expands
2026-04-21 07:58:49 +02:00
copilot-swe-agent[bot] e1e175f18f test: make mobile navbar CSS regression assertion order-independent
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/8f4a8d58-4e90-48ad-8195-23b90d8b22d4

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-21 05:45:19 +00:00
copilot-swe-agent[bot] a45830cf25 fix: keep mobile header title aligned when navbar menu expands
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/8f4a8d58-4e90-48ad-8195-23b90d8b22d4

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-21 05:42:24 +00:00
copilot-swe-agent[bot] 369b6764c9 Initial plan 2026-04-21 05:36:42 +00:00
45 changed files with 465 additions and 198642 deletions
+12
View File
@@ -2,6 +2,7 @@
SECRET_KEY=django-insecure-change-me
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
S3_ENABLE=False
# PostgreSQL (por defecto habilitado; si POSTGRES_ENABLED=False se usa SQLite)
POSTGRES_ENABLED=True
@@ -14,6 +15,17 @@ POSTGRES_PORT=5432
# Redis
REDIS_URL=redis://127.0.0.1:6379/1
# S3 (activar con S3_ENABLE=True)
AWS_STORAGE_BUCKET_NAME=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_S3_REGION_NAME=
AWS_S3_ENDPOINT_URL=
AWS_S3_CUSTOM_DOMAIN=
AWS_S3_USE_SSL=True
AWS_QUERYSTRING_AUTH=False
AWS_DEFAULT_ACL=public-read
# Stripe
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
+1
View File
@@ -35,6 +35,7 @@ Templates use Django's inheritance pattern:
- **Image uploads**: Organized in `tienda/static/media/images/` via `upload_to='images/'` in ImageField
- **Access**: Media files served automatically in development via Django's static file handler
- **Image model**: Located in [tienda/models.py](tienda/models.py) with `ImageField(upload_to='images/')`
- **S3 mode**: if `S3_ENABLE=True` (case-insensitive), static and media switch to S3 storages instead of the local filesystem; Nginx should proxy the app only and the browser should load asset URLs from the bucket or CDN
## Shipping Restrictions
- **Zona de envío**: Solo se vende/envía dentro de la provincia de Almería
+33
View File
@@ -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
+2
View File
@@ -7,3 +7,5 @@ __pycache__/
*.pyc
tienda/__pycache__/
proyecto/__pycache__/
media
staticfiles
+61
View File
@@ -0,0 +1,61 @@
# AGENTS.md - Django Tienda Project
## Commands
```bash
# Run tests (runs tienda/tests.py by default)
make test
# Or manually
python manage.py test
# Run dev server
python manage.py runserver
# Run migrations
python manage.py migrate
# Static files (production)
python manage.py collectstatic
```
## Prerequisites
- **Redis**: Must be running on `redis://127.0.0.1:6379/1` for sessions and caching
- Start: `sudo systemctl start redis-server` (Linux) or `brew services start redis` (macOS)
- Verify: `redis-cli ping` → PONG
- **PostgreSQL**: Default database; set `POSTGRES_ENABLED=False` to use SQLite
- **Environment**: Copy `.env.example` to `.env` and configure required vars
## Important Quirks
1. **Migrations**: If `makemigrations` fails with error code 130, **check `tienda/migrations/`** - the file is often created despite the error
2. **Test DB**: Uses SQLite regardless of POSTGRES_ENABLED (hardcoded in settings.py)
3. **App URL**: Not at `/` - access at `http://localhost:8000/tienda/`
4. **Admin**: At `/admin/` (not `/tienda/admin/`)
5. **Custom User Model**: AUTH_USER_MODEL = 'tienda.User' - use for all user-related queries
## Architecture
- `proyecto/` - Django settings, URLs, WSGI/ASGI
- `tienda/` - Main app (models, views, admin, templates)
- `tienda/static/` - CSS, JS, images, fonts
- Templates extend `tienda/templates/tienda/base.html`
## Shipping Restrictions
Only sells to Almería province, Spain (postal codes 04xxx). All addresses saved with country "España".
## External Services
- **Payment**: Stripe + PayPal (configured via .env)
- **Storage**: S3 support - set `S3_ENABLE=True` to enable
- **Email**: SMTP required (see .env.example)
- **Async**: Celery uses Redis broker
## Useful References
- Full developer docs: `.github/copilot-instructions.md`
- Redis setup: `docs/REDIS_SETUP.md`
- PayPal: `docs/PAYPAL_SETUP.md`, `docs/PAYPAL_TROUBLESHOOTING.md`
- View documentation: `docs/views/`
+4 -2
View File
@@ -34,7 +34,9 @@ http {
listen 80;
server_name _;
# Archivos estáticos generados por collectstatic.
# Modo local: sirve static/media desde volúmenes montados.
# Si S3_ENABLE=True, estos bloques no se usan y el navegador debe
# cargar los assets directamente desde el bucket o CDN.
location /static/ {
alias /static/;
expires 30d;
@@ -42,7 +44,7 @@ http {
access_log off;
}
# Archivos subidos por usuarios.
# Archivos subidos por usuarios en modo local.
location /media/ {
alias /media/;
expires 7d;
+44 -3
View File
@@ -53,6 +53,21 @@ def env_int(name: str, default: int) -> int:
return default
return int(value)
def env_str(name: str, default: str = '') -> str:
value = os.getenv(name)
if value is None:
return default
return value.strip()
def env_optional_str(name: str) -> str | None:
value = os.getenv(name)
if value is None:
return None
value = value.strip()
return value or None
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')
@@ -66,6 +81,8 @@ SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-#g((q@lvnkt(j6)2(gvtn0px)r
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_bool('DEBUG', True)
S3_ENABLE = env_bool('S3_ENABLE', False)
S3_USE_LOCAL_URLS = env_bool('S3_USE_LOCAL_URLS', False)
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [
'192.168.1.142',
@@ -87,9 +104,11 @@ INSTALLED_APPS = [
'compressor',
]
if S3_ENABLE:
INSTALLED_APPS.append('storages')
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -98,6 +117,9 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
if not S3_ENABLE:
MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')
ROOT_URLCONF = 'proyecto.urls'
TEMPLATES = [
@@ -211,6 +233,27 @@ STORAGES = {
},
}
if S3_ENABLE:
AWS_STORAGE_BUCKET_NAME = env_str('AWS_STORAGE_BUCKET_NAME') or None
AWS_ACCESS_KEY_ID = env_optional_str('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = env_optional_str('AWS_SECRET_ACCESS_KEY')
AWS_S3_REGION_NAME = env_optional_str('AWS_S3_REGION_NAME')
AWS_S3_ENDPOINT_URL = env_optional_str('AWS_S3_ENDPOINT_URL')
AWS_S3_CUSTOM_DOMAIN = env_optional_str('AWS_S3_CUSTOM_DOMAIN')
AWS_S3_USE_SSL = env_bool('AWS_S3_USE_SSL', True)
AWS_QUERYSTRING_AUTH = env_bool('AWS_QUERYSTRING_AUTH', False)
AWS_DEFAULT_ACL = env_str('AWS_DEFAULT_ACL', 'public-read') or None
AWS_S3_OBJECT_PARAMETERS = {}
STORAGES = {
'default': {
'BACKEND': 'tienda.storage_backends.MediaStorage',
},
'staticfiles': {
'BACKEND': 'tienda.storage_backends.StaticStorage',
},
}
STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
@@ -385,5 +428,3 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SECURE_REFERER_POLICY = "strict-origin-when-cross-origin"
print(f"DEBUG: ALLOWED_HOSTS is {ALLOWED_HOSTS}")
+3 -1
View File
@@ -26,5 +26,7 @@ urlpatterns = [
path('tienda/', include('tienda.urls'))
]
if settings.DEBUG:
if settings.DEBUG and (
not settings.S3_ENABLE or getattr(settings, 'S3_USE_LOCAL_URLS', False)
):
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+2
View File
@@ -14,6 +14,7 @@ Django==6.0.4
django-appconf==1.2.0
django-redis==5.4.0
django_compressor==4.6.0
django-storages[boto3]==1.14.6
gunicorn==25.1.0
idna==3.11
Jinja2==3.1.6
@@ -22,6 +23,7 @@ MarkupSafe==3.0.3
packaging==26.0
paypalrestsdk==1.13.3
pillow==12.2.0
boto3==1.42.97
prompt_toolkit==3.0.52
pycparser==3.0
pyOpenSSL==26.0.0
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
+31 -1
View File
@@ -25,6 +25,11 @@ class User(AbstractUser):
choices = RegisterStatus.choices,
default = RegisterStatus.CONFIRMATION_REQUIRED
)
def to_dict(self):
return {
"username": self.username,
"fullname": self.get_full_name()
}
class VerificationCode(models.Model):
class VerificationModes(models.TextChoices):
@@ -41,7 +46,7 @@ class VerificationCode(models.Model):
def generate(user: User, code_mode: str) -> VerificationCode:
while True:
code = "".join(random.choices(string.ascii_letters+string.digits+string.punctuation))
code = "".join(random.choices(string.ascii_letters+string.digits, k=64))
if not VerificationCode.objects.filter(code=code).exists():
return VerificationCode.objects.create(
code = code,
@@ -56,6 +61,11 @@ class Category(models.Model):
def __str__(self):
return self.name
def to_dict(self):
return {
"name": self.name
}
class Image(models.Model):
name = models.CharField(max_length=200, default="")
image = models.ImageField(upload_to='images/')
@@ -64,6 +74,13 @@ class Image(models.Model):
def __str__(self):
return self.name
def to_dict(self):
return {
"name": self.name,
"image": self.image.url,
"alt": self.alt
}
class Product(models.Model):
name = models.CharField(max_length=200, default="")
description = models.TextField(default = "")
@@ -86,6 +103,19 @@ class Product(models.Model):
"""Retorna la cantidad de IVA"""
return round(self.price * VAT_RATE, 2)
def to_dict(self):
return {
"name": self.name,
"description": self.description,
"briefdesc": self.briefdesc,
"price": self.price,
"stock": self.stock,
"category": self.category.to_dict(),
"primary_image": self.primary_image.to_dict() if self.primary_image else None,
"secondary_images": [secondary_image.to_dict() for secondary_image in self.secondary_images.all()],
"creator": self.creator.to_dict() if self.creator else None
}
class StockReservation(models.Model):
STATUS_ACTIVE = "active"
+27 -1
View File
@@ -1,3 +1,28 @@
.skip-link {
position: fixed;
top: -100%;
left: 50%;
transform: translateX(-50%);
background: #fff;
color: #513CB0;
padding: 8px 24px;
font-weight: 700;
font-size: 0.9rem;
z-index: 10001;
text-decoration: none;
border-radius: 0 0 8px 8px;
border: 2px solid #513CB0;
border-top: none;
box-shadow: 0 4px 12px rgba(81, 60, 176, 0.25);
transition: top 0.2s ease;
outline: none;
white-space: nowrap;
}
.skip-link:focus,
.skip-link:focus-visible {
top: 0;
}
@media (min-width: 1250px) {
.grid {
display: grid;
@@ -63,8 +88,9 @@ p.price {
.navbar.header .site-title-mobile {
color: #FFF;
position: absolute;
top: calc(var(--bs-navbar-padding-y) + 20px);
left: 50%;
transform: translateX(-50%);
transform: translate(-50%, -50%);
margin: 0;
max-width: calc(100% - 9rem);
overflow: hidden;
+44
View File
@@ -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)
+6 -1
View File
@@ -33,7 +33,11 @@ def enviar_correo_confirmacion(id: int):
@shared_task
def enviar_correo_recuperacion(email: str):
usuario: User | None
try:
usuario = User.objects.get(email=email)
except User.DoesNotExist as e:
usuario = None
if usuario is not None:
ver_code = VerificationCode.objects.create(
code_mode = VerificationCode.VerificationModes.RESET_PASSWORD,
@@ -53,7 +57,8 @@ def enviar_correo_recuperacion(email: str):
)
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)
@shared_task
+77 -15
View File
@@ -50,8 +50,11 @@
transition: background-color 0.2s;
}
.search-suggestion-item:hover {
.search-suggestion-item:hover,
.search-suggestion-item.active {
background-color: #f8f9fa;
outline: 2px solid #0d6efd;
outline-offset: -2px;
}
.search-suggestion-item:last-child {
@@ -78,6 +81,7 @@
{% block head %}{% endblock %}
</head>
<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 %}
<nav class="navbar navbar-expand-md header" role="banner">
<div class="container-fluid">
@@ -107,10 +111,10 @@
<!-- Barra de búsqueda con sugerencias -->
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
<div class="input-group">
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off">
<button class="btn btn-outline-primary" type="submit">🔍</button>
<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" aria-label="Buscar productos">🔍 Buscar</button>
</div>
<div class="search-suggestions" id="searchSuggestions"></div>
<div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
</form>
<div class="navbar-nav ms-auto d-flex align-items-md-center gap-2 flex-wrap" role="navigation">
@@ -137,7 +141,7 @@
</nav>
{% 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 -->
{% if messages %}
<div class="row mt-3">
@@ -201,6 +205,35 @@
const searchSuggestions = document.getElementById('searchSuggestions');
const searchForm = document.getElementById('searchForm');
let searchTimeout;
let currentFocusIndex = -1;
// Helpers para gestionar el estado ARIA del combobox
function openSuggestions() {
searchSuggestions.classList.add('show');
searchInput.setAttribute('aria-expanded', 'true');
}
function closeSuggestions() {
searchSuggestions.classList.remove('show');
searchInput.setAttribute('aria-expanded', 'false');
searchInput.setAttribute('aria-activedescendant', '');
currentFocusIndex = -1;
}
function updateFocus(options) {
options.forEach((option, index) => {
const active = index === currentFocusIndex;
option.classList.toggle('active', active);
option.setAttribute('aria-selected', active ? 'true' : 'false');
});
if (currentFocusIndex >= 0) {
const activeOption = options[currentFocusIndex];
searchInput.setAttribute('aria-activedescendant', activeOption.id);
activeOption.scrollIntoView({ block: 'nearest' });
} else {
searchInput.setAttribute('aria-activedescendant', '');
}
}
// Escuchar cambios en el input
searchInput.addEventListener('input', function() {
@@ -208,7 +241,7 @@
const query = this.value.trim();
if (query.length < 2) {
searchSuggestions.classList.remove('show');
closeSuggestions();
return;
}
@@ -218,6 +251,31 @@
}, 300);
});
// Navegación por teclado (ArrowDown/ArrowUp/Enter/Escape)
searchInput.addEventListener('keydown', function(event) {
const options = searchSuggestions.querySelectorAll('[role="option"]');
if (!options.length || !searchSuggestions.classList.contains('show')) {
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
currentFocusIndex = Math.min(currentFocusIndex + 1, options.length - 1);
updateFocus(options);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
currentFocusIndex = Math.max(currentFocusIndex - 1, -1);
updateFocus(options);
} else if (event.key === 'Enter' && currentFocusIndex >= 0) {
event.preventDefault();
const selected = options[currentFocusIndex];
window.location.href = selected.dataset.href;
} else if (event.key === 'Escape') {
closeSuggestions();
searchInput.focus();
}
});
// Función para obtener sugerencias del servidor
function fetchSuggestions(query) {
fetch(`{% url 'search_suggestions' %}?q=${encodeURIComponent(query)}`)
@@ -227,33 +285,37 @@
})
.catch(error => {
console.error('Error fetching suggestions:', error);
searchSuggestions.classList.remove('show');
closeSuggestions();
});
}
// Función para mostrar las sugerencias
function displaySuggestions(suggestions, query) {
currentFocusIndex = -1;
if (suggestions.length === 0) {
searchSuggestions.innerHTML = '<div class="search-suggestion-item text-muted">No se encontraron productos</div>';
searchSuggestions.classList.add('show');
openSuggestions();
return;
}
let html = '';
suggestions.forEach(suggestion => {
suggestions.forEach((suggestion, index) => {
// Resaltar la coincidencia en el nombre
const highlightedName = highlightMatch(suggestion.name, query);
const priceWithVAT = (suggestion.price * 1.21).toFixed(2);
html += `
<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-price">€${priceWithVAT}</span>
</a>
</div>
`;
});
searchSuggestions.innerHTML = html;
searchSuggestions.classList.add('show');
openSuggestions();
}
// Función para resaltar el texto que coincide
@@ -265,19 +327,19 @@
// Cerrar sugerencias cuando se hace clic fuera
document.addEventListener('click', function(event) {
if (!searchForm.contains(event.target)) {
searchSuggestions.classList.remove('show');
closeSuggestions();
}
});
// Cerrar sugerencias al enviar el formulario
searchForm.addEventListener('submit', function() {
searchSuggestions.classList.remove('show');
closeSuggestions();
});
// Mostrar sugerencias al hacer clic en el input (si hay texto)
searchInput.addEventListener('focus', function() {
if (this.value.trim().length >= 2 && searchSuggestions.innerHTML) {
searchSuggestions.classList.add('show');
openSuggestions();
}
});
</script>
+3 -3
View File
@@ -51,7 +51,7 @@
<td>
<form method="post" action="{% url 'update_cart_item' item.id %}" class="d-flex align-items-center" style="max-width: 150px;">
{% 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>
</form>
</td>
@@ -59,7 +59,7 @@
{% if item.product.stock > 0 %}
{{ item.product.stock }}
{% else %}
<span class="text-danger">0</span>
<span class="text-danger" role="status"><span aria-hidden="true"></span> Sin stock</span>
{% endif %}
</td>
<td class="price">{{ item.get_subtotal_with_vat|format_price }} €</td>
@@ -89,7 +89,7 @@
</div>
<div class="d-flex justify-content-between mb-2">
<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 class="d-flex justify-content-between mb-2">
<span>Envío</span>
+63 -15
View File
@@ -127,22 +127,25 @@
<!-- Tabs -->
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist">
<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
</button>
</li>
<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
</button>
</li>
</ul>
<!-- 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 %}
<div class="mb-3">
<p class="fw-semibold">Tarjetas guardadas:</p>
<fieldset class="mb-3">
<legend class="fw-semibold fs-6 mb-2">Selección de tarjeta</legend>
{% for card in saved_cards %}
<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 %}>
@@ -156,7 +159,7 @@
<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>
</div>
</div>
</fieldset>
{% endif %}
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
@@ -183,7 +186,8 @@
</div>
<!-- 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 %}
<div class="alert alert-light border mb-3">
<small class="text-muted">Cuenta PayPal guardada:</small>
@@ -196,6 +200,7 @@
Guardar esta cuenta de PayPal para futuras compras
</label>
</div>
<div id="paypal-errors" class="alert alert-danger d-none mt-2" role="alert"></div>
<div id="paypal-button-container"></div>
{% 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>
@@ -221,12 +226,42 @@ const HAS_STOCK_ISSUES = {{ stock_issues|yesno:"true,false" }};
const HAS_ADDRESS = {{ addresses|yesno:"true,false" }};
// ---- Tab switching ----
document.querySelectorAll('#paymentTabs .nav-link').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#paymentTabs .nav-link').forEach(b => b.classList.remove('active'));
const paymentTabs = Array.from(document.querySelectorAll('#paymentTabs .nav-link[role="tab"]'));
function activateTab(tab) {
paymentTabs.forEach(b => {
const isSelected = b === tab;
b.classList.toggle('active', isSelected);
b.setAttribute('aria-selected', isSelected ? 'true' : 'false');
b.setAttribute('tabindex', isSelected ? '0' : '-1');
});
document.querySelectorAll('.payment-tab-content').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(btn.dataset.tab).classList.add('active');
document.getElementById(tab.dataset.tab).classList.add('active');
}
paymentTabs.forEach(btn => {
btn.addEventListener('click', () => activateTab(btn));
btn.addEventListener('keydown', e => {
const idx = paymentTabs.indexOf(e.currentTarget);
if (e.key === 'ArrowRight') {
e.preventDefault();
const next = paymentTabs[(idx + 1) % paymentTabs.length];
activateTab(next);
next.focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prev = paymentTabs[(idx - 1 + paymentTabs.length) % paymentTabs.length];
activateTab(prev);
prev.focus();
} else if (e.key === 'Home') {
e.preventDefault();
activateTab(paymentTabs[0]);
paymentTabs[0].focus();
} else if (e.key === 'End') {
e.preventDefault();
activateTab(paymentTabs[paymentTabs.length - 1]);
paymentTabs[paymentTabs.length - 1].focus();
}
});
});
@@ -254,10 +289,13 @@ document.getElementById('pay-card-btn').addEventListener('click', async () => {
const addressId = document.getElementById('shipping-address').value;
if (!addressId) {
alert('Selecciona una dirección de envío para continuar.');
const cardErrors = document.getElementById('card-errors');
if (cardErrors) cardErrors.textContent = 'Selecciona una dirección de envío para continuar.';
return;
}
const cardErrorsEl = document.getElementById('card-errors');
if (cardErrorsEl) cardErrorsEl.textContent = '';
const btn = document.getElementById('pay-card-btn');
const spinner = document.getElementById('card-spinner');
btn.disabled = true;
@@ -335,9 +373,15 @@ paypal.Buttons({
createOrder: async () => {
const addressId = document.getElementById('shipping-address').value;
if (!addressId) {
alert('Selecciona una dirección de envío para continuar.');
const paypalErrors = document.getElementById('paypal-errors');
if (paypalErrors) {
paypalErrors.textContent = 'Selecciona una dirección de envío para continuar.';
paypalErrors.classList.remove('d-none');
}
return Promise.reject(new Error('Sin dirección'));
}
const paypalErrorsEl = document.getElementById('paypal-errors');
if (paypalErrorsEl) paypalErrorsEl.classList.add('d-none');
const resp = await fetch('{% url "crear_orden_paypal" %}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
@@ -359,7 +403,11 @@ paypal.Buttons({
showSuccess(result.transaction_code);
},
onError: (err) => {
alert('Error en el pago con PayPal: ' + err);
const paypalErrors = document.getElementById('paypal-errors');
if (paypalErrors) {
paypalErrors.textContent = 'Error en el pago con PayPal: ' + err;
paypalErrors.classList.remove('d-none');
}
},
}).render('#paypal-button-container');
{% endif %}
+5 -3
View File
@@ -196,9 +196,11 @@
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
Ver detalles
</a>
<a href="{% url 'add_to_cart' product.id %}" class="btn btn-outline-primary btn-sm">
🛒
</a>
<form method="post" action="{% url 'add_to_cart' product.id %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="quantity" value="1">
<button type="submit" class="btn btn-outline-primary btn-sm">🛒</button>
</form>
</div>
</div>
</div>
+3 -3
View File
@@ -34,16 +34,16 @@
<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>
<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 id="descripcion" class="texto-ajustado">
{{ product.briefdesc }}
</div>
<div class="mt-3">
{% 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 %}
<span class="badge bg-danger">Sin stock</span>
<span class="badge bg-danger" role="status"><span aria-hidden="true"></span> Sin stock</span>
{% endif %}
</div>
+1 -1
View File
@@ -36,7 +36,7 @@
<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>
Acepto los <a href="{% url 'terminos' %}" target="_blank">términos y condiciones</a>
</label>
</div>
+5 -3
View File
@@ -79,9 +79,11 @@
<a href="{% url 'producto' product.id %}" class="btn btn-primary btn-sm flex-grow-1">
Ver detalles
</a>
<a href="{% url 'add_to_cart' product.id %}" class="btn btn-outline-primary btn-sm">
🛒
</a>
<form method="post" action="{% url 'add_to_cart' product.id %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="quantity" value="1">
<button type="submit" class="btn btn-outline-primary btn-sm">🛒</button>
</form>
</div>
</div>
</div>
+12
View File
@@ -1,4 +1,6 @@
import json
from pathlib import Path
import re
from unittest.mock import MagicMock, patch
from django.test import TestCase, override_settings
@@ -1371,6 +1373,16 @@ class EndpointViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'site-title-mobile d-md-none')
self.assertContains(response, 'site-title-desktop')
def test_mobile_site_title_css_keeps_title_pinned_to_header_row(self):
css_path = Path(__file__).resolve().parent / "static" / "css" / "custom.css"
css_content = css_path.read_text(encoding="utf-8")
selector_match = re.search(r"\.navbar\.header \.site-title-mobile\s*\{(?P<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):
response = self.client.get(reverse("home"))
html = response.content.decode()
+23 -7
View File
@@ -16,6 +16,7 @@ from .vars import (
)
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.urls import reverse
from django.utils import timezone
from decimal import Decimal, ROUND_HALF_UP
@@ -239,6 +240,26 @@ def login(request: HttpRequest):
# Autenticar usuario
user = authenticate(request, username=username, password=password)
if user is None:
data: str = cache.get(f"tries_login_{username}")
logins: int
if data is None:
logins = int(data)
else:
logins = 0
if logins >= 5:
# Si ha fallado 5 intentos de login...
audit_logger.info(
"LOGIN_FAILED email=%s reason=rate_limited", username
)
messages.error(request, "Has sufrido de Rate Limit por fallar 5 veces la contraseña")
return render(request, "tienda/login.html")
logins+=1
cache.set(f"tries_login_{username}", str(logins), 600)
messages.error(request, "Correo electrónico o contraseña incorrectos.")
return render(request, "tienda/login.html")
user = User.objects.get(username=user.username)
if user.registration_status == "CR":
audit_logger.info(
@@ -704,6 +725,7 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
return order, ""
@require_POST
def add_to_cart(request: HttpRequest, product_id: int):
"""Agrega un producto al carrito"""
try:
@@ -2253,13 +2275,6 @@ def verify(request: HttpRequest, code: str):
return HttpResponse("<h1>Error</h1><p>No existe el codigo de verificación</p>")
def reset_password(request: HttpRequest):
if request.user.is_authenticated:
return redirect("index")
return render(request, "tienda/reset_password", {})
def rgpd(request: HttpRequest):
return render(request, "tienda/rgpd.html", {})
@@ -2312,6 +2327,7 @@ def reset_password_phase2(request: HttpRequest, code: str):
user = ver_code.user
user.set_password(password)
user.save()
ver_code.delete() # Delete Verification code after changing password
messages.success(request, "Se ha cambiado la contraseña!")
return redirect(reverse("index"))