Compare commits

...

81 Commits

Author SHA1 Message Date
Daniel (elordenador) 4661bcdffd Merge pull request #62 from dsaub/development
Merge entire work to latest
2026-05-04 13:43:50 +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
Daniel (elordenador) 27c06fe0b5 Merge pull request #33 from dsaub/development
Build and Push Docker Image / test (push) Waiting to run
Build and Push Docker Image / docker (push) Blocked by required conditions
Fix mobile header alignment and improve navbar responsiveness
2026-04-20 15:57:04 +02:00
Daniel (elordenador) 69da8d81e7 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-20 14:08:30 +02:00
Daniel (elordenador) 4011f96ca6 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-20 14:04:29 +02:00
Daniel (elordenador) f736597b8c Merge pull request #32 from dsaub/copilot/create-docker-workflow-without-push
Split Docker CI by branch: push only on `development`/`latest`, build-only elsewhere
2026-04-20 13:49:45 +02:00
copilot-swe-agent[bot] 58127de1a7 Add explicit workflow token permissions to docker-no-push workflow
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/3c214772-f4aa-46da-8791-a049cb3ca666

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-20 11:44:27 +00:00
copilot-swe-agent[bot] 03399077d0 Separate Docker workflows by branch and remove push on non-release branches
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/3c214772-f4aa-46da-8791-a049cb3ca666

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-20 11:43:26 +00:00
Daniel (elordenador) 1b872f1905 Merge pull request #30 from dsaub/copilot/fix-navbar-button-positioning
Adjust responsive navbar layout so search stays visible and action buttons collapse cleanly
2026-04-20 13:37:28 +02:00
copilot-swe-agent[bot] 769915b962 chore: merge development - resolve conflicts, keep .pyc files removed
Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-20 11:35:54 +00:00
copilot-swe-agent[bot] a5562623c1 chore: remove all tracked .pyc cache files from git index
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/98022683-01d1-44fd-8264-e4f025890faa

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-20 11:29:45 +00:00
Daniel (elordenador) 5da73a9408 Merge pull request #31 from dsaub/copilot/add-categories-toggle-button
Mostrar categorías en móvil con botón desplegable sobre el grid de productos
2026-04-20 13:23:30 +02:00
Daniel (elordenador) 3337503473 Merge branch 'development' into copilot/add-categories-toggle-button 2026-04-20 13:23:04 +02:00
copilot-swe-agent[bot] cd40105bbb test: harden navbar responsive structure checks
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/7bd2469a-6cfb-4a01-824a-07dfafa2392c

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-20 11:22:54 +00:00
Daniel (elordenador) ccd65d87a7 Delete tienda/migrations/__pycache__/0001_initial.cpython-312.pyc 2026-04-20 13:21:51 +02:00
copilot-swe-agent[bot] 23abe3f832 fix: improve responsive navbar button placement
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/7bd2469a-6cfb-4a01-824a-07dfafa2392c

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-20 11:19:30 +00:00
copilot-swe-agent[bot] 362a636f5f chore: ajustar acento en categorías tras validación
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/2db304a5-95b5-4161-99c1-ce4d68b014df

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-20 11:17:32 +00:00
copilot-swe-agent[bot] b0edc7a1f3 fix: mostrar categorias con toggle en diseño móvil
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/2db304a5-95b5-4161-99c1-ce4d68b014df

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-20 11:14:53 +00:00
copilot-swe-agent[bot] 82376b0aed chore: plan mobile categories toggle fix
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/2db304a5-95b5-4161-99c1-ce4d68b014df

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-20 11:06:46 +00:00
copilot-swe-agent[bot] 465e71e83d chore: plan navbar responsive fix
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/7bd2469a-6cfb-4a01-824a-07dfafa2392c

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-04-20 11:04:43 +00:00
copilot-swe-agent[bot] 6b194623c8 Initial plan 2026-04-20 11:02:52 +00:00
copilot-swe-agent[bot] f4ec7aab13 Initial plan 2026-04-20 11:02:44 +00:00
Daniel (elordenador) 44bf6df686 Merge pull request #22 from dsaub/development
Enhance stock management, payment systems, and testing coverage
2026-04-20 12:25:33 +02:00
43 changed files with 462 additions and 67 deletions
+12
View File
@@ -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=
+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 - **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
+49
View File
@@ -0,0 +1,49 @@
name: Build Docker Image (No Push)
on:
push:
branches-ignore:
- development
- latest
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout del código
uses: actions/checkout@v6
- name: Configurar Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Instalar dependencias
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Ejecutar tests
env:
DJANGO_SETTINGS_MODULE: proyecto.settings
run: |
python manage.py test
docker:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
steps:
- name: Checkout del código
uses: actions/checkout@v6
- name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build (sin push)
uses: docker/build-push-action@v6
with:
context: .
push: false
+2 -1
View File
@@ -3,7 +3,8 @@ name: Build and Push Docker Image
on: on:
push: push:
branches: branches:
- '**' # Esto aplica para cualquier rama - development
- latest
jobs: jobs:
test: test:
+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
+3
View File
@@ -3,5 +3,8 @@ db.sqlite3
.venv .venv
.env .env
logs/ logs/
__pycache__/
*.pyc
tienda/__pycache__/ tienda/__pycache__/
proyecto/__pycache__/ proyecto/__pycache__/
media
Binary file not shown.
Binary file not shown.
+4 -2
View File
@@ -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;
+44 -3
View File
@@ -53,6 +53,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 +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! # 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',
@@ -87,9 +104,11 @@ INSTALLED_APPS = [
'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 +117,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 = [
@@ -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 = [ STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
@@ -385,5 +428,3 @@ 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}")
+3 -1
View File
@@ -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)
+2
View File
@@ -14,6 +14,7 @@ Django==6.0.4
django-appconf==1.2.0 django-appconf==1.2.0
django-redis==5.4.0 django-redis==5.4.0
django_compressor==4.6.0 django_compressor==4.6.0
django-storages[boto3]==1.14.6
gunicorn==25.1.0 gunicorn==25.1.0
idna==3.11 idna==3.11
Jinja2==3.1.6 Jinja2==3.1.6
@@ -22,6 +23,7 @@ MarkupSafe==3.0.3
packaging==26.0 packaging==26.0
paypalrestsdk==1.13.3 paypalrestsdk==1.13.3
pillow==12.2.0 pillow==12.2.0
boto3==1.42.97
prompt_toolkit==3.0.52 prompt_toolkit==3.0.52
pycparser==3.0 pycparser==3.0
pyOpenSSL==26.0.0 pyOpenSSL==26.0.0
+31 -1
View File
@@ -25,6 +25,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 +46,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 +61,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,6 +74,13 @@ 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 = "") description = models.TextField(default = "")
@@ -86,6 +103,19 @@ 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,
"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"
+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) { @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;
+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)
+77 -15
View File
@@ -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>
+3 -3
View File
@@ -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>
+64 -16
View File
@@ -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 %}
+5 -3
View File
@@ -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>
+19 -3
View File
@@ -2,8 +2,24 @@
{% load vat_filters %} {% load vat_filters %}
{% block content %} {% block content %}
<div class="row mt-2"> <div class="row mt-2">
<div class="col-md-2 d-none d-lg-block"> <div class="col-12 d-lg-none mb-3">
<h5 class="categorias-titulo">Categorias</h5> <button class="btn btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#mobileCategoriasCollapse" aria-expanded="false" aria-controls="mobileCategoriasCollapse">
Categorías
</button>
<div class="collapse mt-2" id="mobileCategoriasCollapse">
<ul class="list-group categorias-lista">
{% if categories %}
{% for category in categories %}
<li class="list-group-item categoria-item">
<a href="{% url 'categoria' category.id %}">{{ category.name }}</a>
</li>
{% endfor %}
{% endif %}
</ul>
</div>
</div>
<div class="col-lg-2 d-none d-lg-block">
<h5 class="categorias-titulo">Categorías</h5>
<ul class="list-group categorias-lista"> <ul class="list-group categorias-lista">
{% if categories %} {% if categories %}
{% for category in categories %} {% for category in categories %}
@@ -14,7 +30,7 @@
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
<div class="col-12 col-md-10 grid"> <div class="col-12 col-lg-10 grid">
{% if products %} {% if products %}
{% for producto in products %} {% for producto in products %}
<div class="card card-producto mt-5" style="width: 18rem;"> <div class="card card-producto mt-5" style="width: 18rem;">
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -36,7 +36,7 @@
<div class="mb-3 form-check"> <div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="acceptTerms" name="terms" required> <input type="checkbox" class="form-check-input" id="acceptTerms" name="terms" required>
<label class="form-check-label" for="acceptTerms"> <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> </label>
</div> </div>
+5 -3
View File
@@ -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>
+20
View File
@@ -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
@@ -1358,11 +1360,29 @@ class EndpointViewTests(TestCase):
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_index_shows_mobile_categories_toggle(self):
response = self.client.get(reverse("index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'data-bs-target="#mobileCategoriasCollapse"')
self.assertContains(response, 'id="mobileCategoriasCollapse"')
self.assertContains(response, "Categorías")
def test_home_header_renders_mobile_title_outside_collapsible_menu(self): def test_home_header_renders_mobile_title_outside_collapsible_menu(self):
response = self.client.get(reverse("home")) response = self.client.get(reverse("home"))
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()
+6 -7
View File
@@ -16,6 +16,7 @@ from .vars import (
) )
from django.conf import settings from django.conf import settings
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
@@ -239,6 +240,9 @@ def login(request: HttpRequest):
# Autenticar usuario # Autenticar usuario
user = authenticate(request, username=username, password=password) user = authenticate(request, username=username, password=password)
if user is None: # Bug de error 500 en caso de fallar la contra
messages.error(request, "Correo electrónico o contraseña incorrectos.")
return render(request, "tienda/login.html")
user = User.objects.get(username=user.username) user = User.objects.get(username=user.username)
if user.registration_status == "CR": if user.registration_status == "CR":
audit_logger.info( audit_logger.info(
@@ -704,6 +708,7 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
return order, "" return order, ""
@require_POST
def add_to_cart(request: HttpRequest, product_id: int): def add_to_cart(request: HttpRequest, product_id: int):
"""Agrega un producto al carrito""" """Agrega un producto al carrito"""
try: try:
@@ -2253,13 +2258,6 @@ def verify(request: HttpRequest, code: str):
return HttpResponse("<h1>Error</h1><p>No existe el codigo de verificación</p>") 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): def rgpd(request: HttpRequest):
return render(request, "tienda/rgpd.html", {}) return render(request, "tienda/rgpd.html", {})
@@ -2312,6 +2310,7 @@ def reset_password_phase2(request: HttpRequest, code: str):
user = ver_code.user user = ver_code.user
user.set_password(password) user.set_password(password)
user.save() user.save()
ver_code.delete() # Delete Verification code after changing password
messages.success(request, "Se ha cambiado la contraseña!") messages.success(request, "Se ha cambiado la contraseña!")
return redirect(reverse("index")) return redirect(reverse("index"))