Compare commits

..

92 Commits

Author SHA1 Message Date
elordenador 72def373e3 Merge pull request 'Rewrite all forms to use Django Forms with validation' (#1) from form-rewrite into development
Reviewed-on: #1
2026-05-08 07:46:01 +00:00
elordenador a50cadc873 Finish Form Rewrite 2026-05-08 09:43:19 +02:00
elordenador 551057b067 Rewrite all forms to use Django Forms with validation
- Add ProductEditForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm
- Add ResetPasswordForm, ResetPasswordPhase2Form
- Update views to use new Django Forms
- Add form validation tests (terms required, password mismatch, etc)
- Update templates to use Django Forms {{ form.as_p }}
2026-05-08 09:42:44 +02:00
elordenador ad7ddbe887 Fix formatting in settings.json by adding a missing comma 2026-05-07 08:54:24 +02:00
elordenador d6b7cdfe6a Add error handling for product creation to manage DataError exceptions 2026-05-07 08:37:07 +02:00
elordenador 56286c2fd9 Add limit to briefdesc and description on Product model, for issue #73 2026-05-07 08:01:46 +02:00
elordenador ba4f6ad65d Add CSRF protection to payment endpoints 2026-05-07 07:53:38 +02:00
elordenador ed7041ae40 Add user ban check to login view and log failed attempts 2026-05-06 11:59:59 +02:00
elordenador fa948a98e2 Add desbanear_usuario_action to UserAdmin actions 2026-05-06 11:45:21 +02:00
elordenador e8a5091dfd Add unban email template to notify users of account reinstatement 2026-05-06 11:39:10 +02:00
elordenador a0ee6ecd14 Update short description for desbanear_usuario_action in UserAdmin 2026-05-06 11:37:43 +02:00
elordenador d6c9aa3db3 Implement user unban functionality and enhance ban action with product deletion 2026-05-06 11:37:26 +02:00
elordenador 9751d19401 Add desbanear_usuario task to send unban email notifications 2026-05-06 10:52:32 +02:00
elordenador cda9adb986 Enhance user ban action to delete products by creator and add success message 2026-05-06 10:37:48 +02:00
elordenador e7e7fd118d Refactor user ban action to streamline user deactivation and product deletion 2026-05-06 10:25:13 +02:00
elordenador 132b1e1722 Remove user ban link from admin submit line template 2026-05-06 10:22:34 +02:00
elordenador 7f557a3247 Implement user ban functionality to delete associated products 2026-05-06 09:48:55 +02:00
elordenador 8cf1a55161 Add user ban functionality with email notification 2026-05-06 09:47:47 +02:00
elordenador 61a04e5040 Fix logins int() None 2026-05-06 09:23:33 +02:00
elordenador e5a0caa8b6 Fix text overflow 2026-05-06 09:23:23 +02:00
elordenador 25e6088355 Fix: correct user_options assignment in Celery app 2026-05-05 16:19:59 +02:00
elordenador 8ec391ccde Update AGENTS.md 2026-05-05 15:51:52 +02:00
elordenador 3b007f324f Fix: add COMPRESS_URL setting 2026-05-05 14:03:27 +02:00
elordenador 6e003009fa Fix: add COMPRESS_ROOT setting 2026-05-05 14:01:44 +02:00
elordenador 69578f1dba Fix: add user_options attribute to Celery app 2026-05-05 14:00:36 +02:00
elordenador 3eb81b343c Fix celery worker initialization by setting up Django before Celery and fixing import name 2026-05-05 13:09:54 +02:00
elordenador ce5aac0e89 Fix celery user_options attribute for worker 2026-05-05 13:03:44 +02:00
elordenador c534f500ad Fix: remove CELERY_APP setting 2026-05-05 12:57:51 +02:00
elordenador 63c6b645c3 Fix Celery: add CELERY_APP setting 2026-05-05 12:53:43 +02:00
elordenador b16cb367d3 Fix Celery: restore autodiscover_tasks to default behavior 2026-05-05 12:45:54 +02:00
elordenador 503233d323 Fix Celery: use autodiscover_tasks with full module path 2026-05-05 12:39:45 +02:00
elordenador b50ab06a22 Fix Celery: use CELERY_IMPORTS instead of autodiscover 2026-05-05 12:35:53 +02:00
elordenador cda339a336 Restore autodiscover_tasks to working state 2026-05-05 12:33:14 +02:00
elordenador 541a73ce36 Fix Celery: add django.setup() before importing tasks 2026-05-05 12:31:54 +02:00
elordenador 8932eeefbf Fix Celery worker: import tasks directly instead of autodiscover 2026-05-05 12:29:09 +02:00
elordenador 80e5e2a422 Fix Celery 2026-05-05 12:22:07 +02:00
elordenador a686bccd54 Update AGENTS.md 2026-05-05 12:20:11 +02:00
elordenador 6be67a9100 Add SKU field to Product model (issue #67) 2026-05-05 09:01:24 +02:00
elordenador bee360dfbb Fix POSTGRES_ENABLED check in ShippingAddress.clean() (issue #66) 2026-05-05 08:52:02 +02:00
elordenador a20a61be82 Add postal code validation to ShippingAddress model (issue #66) 2026-05-05 08:46:34 +02:00
elordenador b9675385aa Fix Github issue #69 2026-05-05 07:44:32 +02:00
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
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
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
66 changed files with 1596 additions and 199378 deletions
+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
+1
View File
@@ -8,3 +8,4 @@ __pycache__/
tienda/__pycache__/
proyecto/__pycache__/
media
staticfiles
+2 -1
View File
@@ -1,3 +1,4 @@
{
"python.REPL.enableREPLSmartSend": false
"python.REPL.enableREPLSmartSend": false,
"makefile.configureOnOpen": false
}
+57
View File
@@ -0,0 +1,57 @@
# AGENTS.md - Django Tienda Project
## Commands
```bash
make test # Run tests
python manage.py runserver # Dev server
python manage.py migrate # Migrations
python manage.py collectstatic # Static files (production)
```
## Prerequisites
- **Redis**: `redis://127.0.0.1:6379/1` (Linux: `sudo systemctl start redis-server`)
- **PostgreSQL**: Default. Set `POSTGRES_ENABLED=False` for SQLite
- **Environment**: Copy `.env.example` to `.env`
## Quirks
1. **Migrations**: If `makemigrations` fails with error 130, check `tienda/migrations/` - file often created anyway
2. **Test DB**: Always uses SQLite (hardcoded)
3. **App URL**: `http://localhost:8000/tienda/` (not `/`)
4. **Admin**: `/admin/`
5. **User Model**: Use `tienda.User` for queries
## Architecture
- `proyecto/` - Django settings, URLs, WSGI/ASGI
- `tienda/` - Main app (models, views, admin, templates)
- Templates extend `tienda/templates/tienda/base.html`
## Shipping
Only Almería province, Spain (04xxx). Country: "España".
## External Services
- **Payment**: Stripe + PayPal (via .env)
- **Storage**: S3 - set `S3_ENABLE=True`
- **Email**: SMTP (see .env.example)
- **Async**: Celery + Redis
## Deploy
- Push to `origin` and `github` after changes
- GitHub Actions updates `ghcr.io/dsaub/proyecto-mvc:development`
- SSH: `debian@172.16.14.221` (requires VPN - skip if unavailable)
- Stack: `/home/debian/composes/mvc/mvc.yml` on swarm
- Timeout: 1 minute max when updating services
- Docker job requires Test job to pass first
## Other
- **Repo**: https://github.com/dsaub/proyecto-final
- **Python**: 3.14, use `.venv` virtualenv
- **GIT**: origin → git.elordenador.org, github → GitHub
- **Docs**: `.github/copilot-instructions.md`, `docs/`
+2 -2
View File
@@ -1,2 +1,2 @@
from .celery import app as celery_app
__all__ = ('celery_app',)
from .celery import app as celery
__all__ = ('celery',)
+6 -1
View File
@@ -1,8 +1,13 @@
from celery import Celery
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proyecto.settings')
django.setup()
app = Celery('proyecto')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proyecto.settings')
app.config_from_object('django.conf:settings', namespace="CELERY")
user_options = {}
app.autodiscover_tasks()
-11
View File
@@ -1,11 +0,0 @@
from jinja2 import Environment
from django.urls import reverse
from django.templatetags.static import static
def environment(**options):
env = Environment(**options)
env.globals.update({
'static': static,
'url': reverse,
})
return env
+11 -8
View File
@@ -14,6 +14,7 @@ import logging
import os, sys
from pathlib import Path
DEV_ENV = (sys.argv[1] == 'runserver')
RUNNING_TESTS = any(arg in {'test', 'pytest'} for arg in sys.argv) or 'PYTEST_CURRENT_TEST' in os.environ
@@ -101,6 +102,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.forms',
'compressor',
]
@@ -136,14 +138,6 @@ TEMPLATES = [
],
},
},
{
'BACKEND': 'django.template.backends.jinja2.Jinja2',
'DIRS': [BASE_DIR / 'templates/jinja2'],
'APP_DIRS': True,
'OPTIONS': {
'environment': 'proyecto.jinja2.environment',
},
}
]
WSGI_APPLICATION = 'proyecto.wsgi.application'
@@ -216,6 +210,8 @@ USE_TZ = True
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
COMPRESS_ROOT = STATIC_ROOT
COMPRESS_URL = STATIC_URL
STATICFILES_DIRS = [
BASE_DIR / 'tienda' / 'static',
]
@@ -428,3 +424,10 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SECURE_REFERER_POLICY = "strict-origin-when-cross-origin"
from django.forms.renderers import TemplatesSetting
class CustomFormRenderer(TemplatesSetting):
form_template_name = "tienda/form_snippet.html"
FORM_RENDERER = "proyecto.settings.CustomFormRenderer"
+4 -4
View File
@@ -19,14 +19,14 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from tienda import views as tienda_views
from tienda.api import api
urlpatterns = [
path('', tienda_views.home, name='home'),
path('admin/', admin.site.urls),
path('tienda/', include('tienda.urls')),
path('api/', api.urls)
path('tienda/', include('tienda.urls'))
]
if settings.DEBUG and not settings.S3_ENABLE:
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)
+27 -23
View File
@@ -1,47 +1,51 @@
amqp==5.3.1
asgiref==3.11.0
asgiref==3.11.1
billiard==4.2.4
celery==5.6.2
certifi==2026.1.4
boto3==1.43.5
botocore==1.43.5
celery==5.6.3
certifi==2026.4.22
cffi==2.0.0
charset-normalizer==3.4.4
click==8.3.1
charset-normalizer==3.4.7
click==8.3.3
click-didyoumean==0.3.1
click-plugins==1.1.1.2
click-repl==0.3.0
cryptography==46.0.7
Django==6.0.4
cryptography==48.0.0
defusedxml==0.7.1
Django==6.0.5
django-appconf==1.2.0
django-redis==5.4.0
django-redis==6.0.0
django-storages==1.14.6
django_compressor==4.6.0
django-storages[boto3]==1.14.6
gunicorn==25.1.0
idna==3.11
Jinja2==3.1.6
fonttools==4.62.1
fpdf2==2.8.7
gunicorn==26.0.0
idna==3.13
jmespath==1.1.0
kombu==5.6.2
MarkupSafe==3.0.3
packaging==26.0
packaging==26.2
paypalrestsdk==1.13.3
pillow==12.2.0
boto3==1.42.97
prompt_toolkit==3.0.52
psycopg2-binary==2.9.12
pycparser==3.0
pyOpenSSL==26.0.0
pyOpenSSL==26.2.0
python-dateutil==2.9.0.post0
rcssmin==1.2.2
redis==5.2.1
requests==2.33.0
redis==7.4.0
requests==2.33.1
rjsmin==1.2.5
s3transfer==0.17.0
six==1.17.0
sqlparse==0.5.5
stripe==14.3.0
stripe==15.1.0
typing_extensions==4.15.0
tzdata==2025.3
tzdata==2026.2
tzlocal==5.3.1
urllib3==2.6.3
vine==5.1.0
wcwidth==0.6.0
wcwidth==0.7.0
whitenoise==6.12.0
fpdf2==2.8.7
psycopg2-binary==2.9.11
django-ninja==1.6.2
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+60 -3
View File
@@ -1,17 +1,74 @@
from django.contrib import admin
from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod
# Register your models here.
from django.shortcuts import redirect
from django.urls import path
from django.contrib import messages
from . import tasks
admin.site.register(Category)
admin.site.register(Image)
admin.site.register(User)
admin.site.register(VerificationCode)
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
search_fields = ('username',)
actions = ['banear_usuario_action', 'desbanear_usuario_action']
def has_change_permission(self, request, obj = ...):
return super().has_change_permission(request, obj)
def banear_usuario_action(self, request, queryset):
usuarios_baneados = 0
for user in queryset:
user: User = user
# Desactiva usuario
if user.registration_status == User.RegisterStatus.BANNED:
continue
user.is_active = False
user.registration_status = User.RegisterStatus.BANNED
user.save()
# Enviar task a Worker
tasks.banear_usuario.delay(user.email)
# Borrar productos
Product.objects.filter(creator=user).delete()
usuarios_baneados+=1
self.message_user(
request,
f"Se ha(n) baneado {usuarios_baneados} usuario(s) correctamente.",
level=messages.SUCCESS
)
def desbanear_usuario_action(self, request, queryset):
user_desbaneados = 0
for user in queryset:
user: User = user
if user.registration_status != User.RegisterStatus.BANNED:
continue
user.is_active = True
user.registration_status = User.RegisterStatus.ACTIVE
user.save()
tasks.desbanear_usuario.delay(user.email)
user_desbaneados -= 1
self.message_user(
request,
f"Se ha(n) desbaneado {user_desbaneados} usuario(s)",
level=messages.SUCCESS
)
banear_usuario_action.short_description = "Banear usuarios seleccionados"
desbanear_usuario_action.short_description = "Desbanear usuarios seleccionados"
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'price', 'stock', 'category', 'creator')
search_fields = ('name', 'creator__username', 'creator__email')
list_display = ('id', 'sku', 'name', 'price', 'stock', 'category', 'creator')
search_fields = ('name', 'sku', 'creator__username', 'creator__email')
list_filter = ('category',)
class CartItemInline(admin.TabularInline):
model = CartItem
-12
View File
@@ -1,12 +0,0 @@
from ninja import NinjaAPI
from .models import Product
api = NinjaAPI()
@api.get("/hola")
def hola(request):
return {"mensaje": "¡Hola Mundo!"}
@api.get("/products")
def products(request):
productos = Product.objects.all()
return [producto.to_dict() for producto in productos]
+354
View File
@@ -0,0 +1,354 @@
from django import forms
from django.core.exceptions import ValidationError
from .models import Category
class ProductForm(forms.Form):
name = forms.CharField(
label="Nombre del Producto",
max_length=200,
required = True,
widget=forms.TextInput(
attrs = {
'class': 'form-control',
'placeholder': 'Ej: iPhone 15 Pro Max'
}
)
)
briefdesc = forms.CharField(
label="Descripción Breve",
max_length=250,
required = True,
widget = forms.TextInput(
attrs = {
'class': 'form-control',
'placeholder': 'Una descripción corta para mostrar en las tarjetas de producto'
}
)
)
description = forms.CharField(
widget=forms.Textarea(attrs={"rows": "5", "class": "form-control"}),
max_length=5000,
label="Descripción completa",
required = True
)
price = forms.FloatField(
label="Precio (en €)",
required = True,
widget = forms.TextInput(
attrs = {
'class': 'form-control',
'placeholder': '15.99'
}
)
)
stock = forms.IntegerField(
label="Stock Disponible",
required = True,
widget = forms.TextInput(
attrs = {
'class': 'form-control'
}
)
)
category = forms.ModelChoiceField(
queryset=Category.objects.all(),
label="Categoría",
required=True,
widget=forms.Select(attrs={'class': 'form-control'})
)
primary_image = forms.ImageField(
label="Imagen Principal",
required = False,
widget = forms.ClearableFileInput(
attrs = {
'class': 'form-control',
'accept': 'image/*'
}
)
)
class ProductEditForm(forms.Form):
name = forms.CharField(
label="Nombre del Producto",
max_length=200,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: iPhone 15 Pro Max'})
)
briefdesc = forms.CharField(
label="Descripción Breve",
max_length=250,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Una descripción corta'})
)
description = forms.CharField(
widget=forms.Textarea(attrs={"rows": "5", "class": "form-control"}),
max_length=5000,
label="Descripción completa",
required=True
)
price = forms.FloatField(
label="Precio (en €)",
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '15.99'})
)
stock = forms.IntegerField(
label="Stock Disponible",
required=True,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
category = forms.ModelChoiceField(
queryset=Category.objects.all(),
label="Categoría",
required=True,
widget=forms.Select(attrs={'class': 'form-control'})
)
primary_image = forms.ImageField(
label="Imagen Principal (opcional)",
required=False,
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'})
)
class SecondaryImageForm(forms.Form):
image = forms.ImageField(
label="Seleccionar Imagen",
required = True,
widget = forms.ClearableFileInput(
attrs = {
'class': 'form-control',
'accept': 'image/*'
}
)
)
alt = forms.CharField(
label="Texto Alternativo",
max_length=255,
required = False,
widget = forms.TextInput(
attrs = {
'class': 'form-control',
'placeholder': 'Descripción opcional de la imagen'
}
)
)
class UserLoginForm(forms.Form):
email = forms.EmailField(
label = "Correo Electrónico",
max_length=255,
required = True,
widget = forms.TextInput(
attrs = {
'class': 'form-control',
'placeholder': 'Correo Electronico de tu cuenta...'
}
)
)
password = forms.CharField(
label="Contraseña",
max_length = 255,
required = True,
widget = forms.PasswordInput(
attrs = {
'class': 'form-control',
'placeholder': 'Contraseña del usuario'
}
)
)
remember = forms.BooleanField(
required = False,
label = "Recuerdame",
widget = forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
class UserRegisterForm(forms.Form):
name = forms.CharField(
label = "Nombre Completo",
max_length = 255,
required = True,
widget = forms.TextInput(
attrs = {
'class': 'form-control'
}
)
)
email = forms.EmailField(
label = "Correo Electrónico",
max_length = 255,
required = True,
widget = forms.TextInput(
attrs = {
'class': 'form-control'
}
)
)
password = forms.CharField(
label = "Contraseña",
max_length = 255,
required = True,
widget = forms.PasswordInput(
attrs = {
'class': 'form-control'
}
)
)
password_confirm = forms.CharField(
label = "Verificar Contraseña",
max_length = 255,
required = True,
widget = forms.PasswordInput(
attrs = {
'class': 'form-control'
}
)
)
terms = forms.BooleanField(
required = True,
label = "Acepto los terminos y condiciones",
widget = forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
def clean(self):
cleaned_data = super().clean()
password = cleaned_data.get("password")
password_confirm = cleaned_data.get("password_confirm")
if password and password_confirm and password != password_confirm:
raise ValidationError("Las contraseñas no coinciden.")
class EditProfileForm(forms.Form):
first_name = forms.CharField(
label="Nombre",
max_length=150,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
last_name = forms.CharField(
label="Apellidos",
max_length=150,
required=False,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
email = forms.EmailField(
label="Correo Electrónico",
max_length=254,
required=True,
widget=forms.EmailInput(attrs={'class': 'form-control'})
)
class ChangePasswordForm(forms.Form):
current_password = forms.CharField(
label="Contraseña Actual",
max_length=128,
required=True,
widget=forms.PasswordInput(attrs={'class': 'form-control'})
)
new_password = forms.CharField(
label="Nueva Contraseña",
max_length=128,
required=True,
widget=forms.PasswordInput(attrs={'class': 'form-control'})
)
confirm_password = forms.CharField(
label="Confirmar Nueva Contraseña",
max_length=128,
required=True,
widget=forms.PasswordInput(attrs={'class': 'form-control'})
)
def clean(self):
cleaned_data = super().clean()
new_password = cleaned_data.get("new_password")
confirm_password = cleaned_data.get("confirm_password")
if new_password and confirm_password and new_password != confirm_password:
raise ValidationError("Las contraseñas no coinciden.")
if new_password and len(new_password) < 8:
raise ValidationError("La contraseña debe tener al menos 8 caracteres.")
class ShippingAddressForm(forms.Form):
full_name = forms.CharField(
label="Nombre Completo",
max_length=255,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Juan Pérez García'})
)
address_line_1 = forms.CharField(
label="Dirección",
max_length=255,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Calle Mayor 123'})
)
address_line_2 = forms.CharField(
label="Dirección (línea 2)",
max_length=255,
required=False,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Piso, puerta, etc.'})
)
city = forms.CharField(
label="Población",
max_length=100,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Almería'})
)
postal_code = forms.CharField(
label="Código Postal",
max_length=5,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '04001'})
)
country = forms.CharField(
label="País",
max_length=100,
required=False,
initial="España",
widget=forms.TextInput(attrs={'class': 'form-control', 'readonly': True})
)
phone = forms.CharField(
label="Teléfono",
max_length=20,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '612 345 678'})
)
is_default = forms.BooleanField(
label="Establecer como dirección predeterminada",
required=False,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
class ResetPasswordForm(forms.Form):
email = forms.EmailField(
label="Correo Electrónico",
max_length=254,
required=True,
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tu@email.com'})
)
class ResetPasswordPhase2Form(forms.Form):
password = forms.CharField(
label="Nueva Contraseña",
max_length=128,
required=True,
widget=forms.PasswordInput(attrs={'class': 'form-control'})
)
verify_password = forms.CharField(
label="Confirmar Contraseña",
max_length=128,
required=True,
widget=forms.PasswordInput(attrs={'class': 'form-control'})
)
def clean(self):
cleaned_data = super().clean()
password = cleaned_data.get("password")
verify_password = cleaned_data.get("verify_password")
if password and verify_password and password != verify_password:
raise ValidationError("Las contraseñas no coinciden.")
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-05-05 07:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0006_alter_category_name'),
]
operations = [
migrations.AddField(
model_name='product',
name='sku',
field=models.CharField(blank=True, max_length=50, null=True, unique=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.4 on 2026-05-07 08:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0007_add_product_sku'),
]
operations = [
migrations.AlterField(
model_name='product',
name='briefdesc',
field=models.TextField(default='', max_length=250),
),
migrations.AlterField(
model_name='product',
name='description',
field=models.TextField(default='', max_length=5000),
),
]
+33 -6
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import unicodedata
from django.db import models
from django.contrib.auth.models import User, AbstractUser
from django.utils.crypto import get_random_string
@@ -46,7 +47,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,
@@ -83,8 +84,9 @@ class Image(models.Model):
class Product(models.Model):
name = models.CharField(max_length=200, default="")
description = models.TextField(default = "")
briefdesc = models.TextField(default = "")
sku = models.CharField(max_length=50, unique=True, blank=True, null=True)
description = models.TextField(default = "", max_length=5000)
briefdesc = models.TextField(default = "", max_length=250)
price = models.FloatField(default = 0)
stock = models.PositiveIntegerField(default=0)
category = models.ForeignKey(Category, on_delete=models.CASCADE)
@@ -106,14 +108,15 @@ class Product(models.Model):
def to_dict(self):
return {
"name": self.name,
"sku": self.sku,
"description": self.description,
"briefdesc": self.briefdesc,
"price": self.price,
"stock": self.stock,
"category": self.category.to_dict(),
"primary_image": self.primary_image.to_dict(),
"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()
"creator": self.creator.to_dict() if self.creator else None
}
@@ -342,8 +345,32 @@ class ShippingAddress(models.Model):
def __str__(self):
return f"{self.full_name} - {self.city}"
def clean(self):
from django.core.exceptions import ValidationError
from django.conf import settings
from .vars import ALMERIA_POSTAL_CODE_PREFIX, ALMERIA_MUNICIPALITIES_DISPLAY
from .views import _normalize_location_text
postal_code = (self.postal_code or "").strip()
city = (self.city or "").strip()
almeria_prefix = getattr(settings, 'POSTGRES_ENABLED', False) and "04" or ALMERIA_POSTAL_CODE_PREFIX
if len(postal_code) != 5 or not postal_code.isdigit() or not postal_code.startswith(almeria_prefix):
raise ValidationError({
'postal_code': 'Solo realizamos envíos en la provincia de Almería (código postal 04xxx).'
})
normalized_city = _normalize_location_text(city)
normalized_municipalities = {_normalize_location_text(m) for m in ALMERIA_MUNICIPALITIES_DISPLAY}
if normalized_city not in normalized_municipalities:
raise ValidationError({
'city': 'El pueblo/ciudad debe pertenecer a la provincia de Almería.'
})
def save(self, *args, **kwargs):
# Si se marca como predeterminada, desmarcar las demás del usuario
self.full_clean()
if self.is_default:
ShippingAddress.objects.filter(user=self.user, is_default=True).update(is_default=False)
super().save(*args, **kwargs)
+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;
@@ -290,5 +315,6 @@ p.price {
}
.texto-ajustado {
overflow-wrap: anywhere;
overflow-wrap: break-word;
word-wrap: break-word;
}
+28 -6
View File
@@ -11,14 +11,32 @@ from .models import User, VerificationCode
@shared_task
def enviar_correo_bienvenida(email_usuario: str, nombre_usuario: str):
html_content = render_to_string(
'emails/welcome.html',
'tienda/emails/welcome.html',
{
"name": nombre_usuario
},
using='jinja2'
)
send_hemail(email_usuario, "Inicio de Sesión correcto", html_content, "Has iniciado sesión...")
@shared_task
def banear_usuario(email_usuario: str):
html_content = render_to_string(
'tienda/emails/ban.html',
{
},
)
send_hemail(email_usuario, "Cuenta Bloqueada", html_content, "Tu cuenta ha sido bloqueada...")
@shared_task
def desbanear_usuario(email_usuario: str):
html_content = render_to_string(
'tienda/emails/unban.html',
{},
)
send_hemail(email_usuario, "Cuenta Desbloqueada", html_content, "Tu cuenta ha sido desbloqueada...")
@shared_task
def enviar_correo_confirmacion(id: int):
usuario = User.objects.get(id=id)
@@ -33,7 +51,11 @@ def enviar_correo_confirmacion(id: int):
@shared_task
def enviar_correo_recuperacion(email: str):
usuario = User.objects.get(email=email)
usuario: User | None
try:
usuario = User.objects.get(email=email)
except User.DoesNotExist as e:
usuario = None
if usuario is not None:
ver_code = VerificationCode.objects.create(
code_mode = VerificationCode.VerificationModes.RESET_PASSWORD,
@@ -42,18 +64,18 @@ def enviar_correo_recuperacion(email: str):
)
ver_code.save()
html_content = render_to_string(
'emails/reset_pass.html',
'tienda/emails/reset_pass.html',
{
"name": usuario.get_full_name(),
"domain": settings.DOMAIN,
"protocol": settings.PROTOCOL,
"code": ver_code.code
},
using='jinja2'
)
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>
+64 -16
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'));
document.querySelectorAll('.payment-tab-content').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(btn.dataset.tab).classList.add('active');
const paymentTabs = Array.from(document.querySelectorAll('#paymentTabs .nav-link[role="tab"]'));
function activateTab(tab) {
paymentTabs.forEach(b => {
const isSelected = b === tab;
b.classList.toggle('active', isSelected);
b.setAttribute('aria-selected', isSelected ? 'true' : 'false');
b.setAttribute('tabindex', isSelected ? '0' : '-1');
});
document.querySelectorAll('.payment-tab-content').forEach(p => p.classList.remove('active'));
document.getElementById(tab.dataset.tab).classList.add('active');
}
paymentTabs.forEach(btn => {
btn.addEventListener('click', () => activateTab(btn));
btn.addEventListener('keydown', e => {
const idx = paymentTabs.indexOf(e.currentTarget);
if (e.key === 'ArrowRight') {
e.preventDefault();
const next = paymentTabs[(idx + 1) % paymentTabs.length];
activateTab(next);
next.focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prev = paymentTabs[(idx - 1 + paymentTabs.length) % paymentTabs.length];
activateTab(prev);
prev.focus();
} else if (e.key === 'Home') {
e.preventDefault();
activateTab(paymentTabs[0]);
paymentTabs[0].focus();
} else if (e.key === 'End') {
e.preventDefault();
activateTab(paymentTabs[paymentTabs.length - 1]);
paymentTabs[paymentTabs.length - 1].focus();
}
});
});
@@ -254,10 +289,13 @@ document.getElementById('pay-card-btn').addEventListener('click', async () => {
const addressId = document.getElementById('shipping-address').value;
if (!addressId) {
alert('Selecciona una dirección de envío para continuar.');
const cardErrors = document.getElementById('card-errors');
if (cardErrors) cardErrors.textContent = 'Selecciona una dirección de envío para continuar.';
return;
}
const cardErrorsEl = document.getElementById('card-errors');
if (cardErrorsEl) cardErrorsEl.textContent = '';
const btn = document.getElementById('pay-card-btn');
const spinner = document.getElementById('card-spinner');
btn.disabled = true;
@@ -335,9 +373,15 @@ paypal.Buttons({
createOrder: async () => {
const addressId = document.getElementById('shipping-address').value;
if (!addressId) {
alert('Selecciona una dirección de envío para continuar.');
const paypalErrors = document.getElementById('paypal-errors');
if (paypalErrors) {
paypalErrors.textContent = 'Selecciona una dirección de envío para continuar.';
paypalErrors.classList.remove('d-none');
}
return Promise.reject(new Error('Sin dirección'));
}
const paypalErrorsEl = document.getElementById('paypal-errors');
if (paypalErrorsEl) paypalErrorsEl.classList.add('d-none');
const resp = await fetch('{% url "crear_orden_paypal" %}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
@@ -359,7 +403,11 @@ paypal.Buttons({
showSuccess(result.transaction_code);
},
onError: (err) => {
alert('Error en el pago con PayPal: ' + err);
const paypalErrors = document.getElementById('paypal-errors');
if (paypalErrors) {
paypalErrors.textContent = 'Error en el pago con PayPal: ' + err;
paypalErrors.classList.remove('d-none');
}
},
}).render('#paypal-button-container');
{% endif %}
+1 -68
View File
@@ -13,74 +13,7 @@
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
<!-- Nombre del producto -->
<div class="mb-3">
<label for="name" class="form-label">Nombre del Producto <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required maxlength="200"
placeholder="Ej: iPhone 15 Pro Max">
</div>
<!-- Descripción breve -->
<div class="mb-3">
<label for="briefdesc" class="form-label">Descripción Breve</label>
<input type="text" class="form-control" id="briefdesc" name="briefdesc" maxlength="250"
placeholder="Una descripción corta para mostrar en las tarjetas de producto">
<div class="form-text">Opcional. Se mostrará en las vistas de listado de productos.</div>
</div>
<!-- Descripción completa -->
<div class="mb-3">
<label for="description" class="form-label">Descripción Completa <span class="text-danger">*</span></label>
<textarea class="form-control" id="description" name="description" rows="5" required
placeholder="Describe tu producto en detalle..."></textarea>
</div>
<!-- Precio -->
<div class="mb-3">
<label for="price" class="form-label">Precio <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text"></span>
<input type="number" class="form-control" id="price" name="price" required
min="0" step="0.01" placeholder="0.00">
</div>
</div>
<!-- Stock -->
<div class="mb-3">
<label for="stock" class="form-label">Stock disponible <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="stock" name="stock" required
min="0" step="1" placeholder="0">
<div class="form-text">Cantidad máxima que podrán comprar los clientes.</div>
</div>
<!-- Categoría -->
<div class="mb-3">
<label for="category" class="form-label">Categoría <span class="text-danger">*</span></label>
<select class="form-select" id="category" name="category" required>
<option value="" selected disabled>Selecciona una categoría</option>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
</div>
<!-- Imagen principal -->
<div class="mb-3">
<label for="primary_image" class="form-label">Imagen Principal</label>
<input type="file" class="form-control" id="primary_image" name="primary_image"
accept="image/*">
<div class="form-text">Opcional. Esta será la imagen destacada del producto.</div>
</div>
<!-- Imágenes secundarias -->
<div class="mb-4">
<label for="secondary_images" class="form-label">Imágenes Secundarias</label>
<input type="file" class="form-control" id="secondary_images" name="secondary_images"
accept="image/*" multiple>
<div class="form-text">Opcional. Puedes seleccionar múltiples imágenes adicionales.</div>
</div>
{{ form }}
<!-- Botones -->
<div class="d-flex justify-content-end gap-2">
<a href="{% url 'mis_productos' %}" class="btn btn-secondary">Cancelar</a>
+1 -49
View File
@@ -21,52 +21,7 @@
<div class="card-body">
<form method="POST">
{% csrf_token %}
<div class="mb-3">
<label for="full_name" class="form-label">Nombre Completo *</label>
<input type="text" class="form-control" id="full_name" name="full_name" value="{{ direccion.full_name|default:'' }}" required>
</div>
<div class="mb-3">
<label for="address_line_1" class="form-label">Dirección *</label>
<input type="text" class="form-control" id="address_line_1" name="address_line_1" value="{{ direccion.address_line_1|default:'' }}" placeholder="Calle, número, piso, puerta" required>
</div>
<div class="mb-3">
<label for="address_line_2" class="form-label">Dirección (línea 2)</label>
<input type="text" class="form-control" id="address_line_2" name="address_line_2" value="{{ direccion.address_line_2|default:'' }}" placeholder="Edificio, bloque, etc. (opcional)">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="city" class="form-label">Ciudad/Pueblo (Almería) *</label>
<input type="text" class="form-control" id="city" name="city" value="{{ direccion.city|default:'' }}" list="almeria-towns" autocomplete="off" required>
<datalist id="almeria-towns">
{% for town in almeria_municipalities %}
<option value="{{ town }}"></option>
{% endfor %}
</datalist>
<div class="form-text">Selecciona o escribe un municipio de la provincia de Almería.</div>
<div class="invalid-feedback" id="city-validation-message">
El pueblo/ciudad debe pertenecer a la provincia de Almería.
</div>
</div>
<div class="col-md-6 mb-3">
<label for="postal_code" class="form-label">Código Postal *</label>
<input type="text" class="form-control" id="postal_code" name="postal_code" value="{{ direccion.postal_code|default:'' }}" pattern="04[0-9]{3}" maxlength="5" placeholder="04XXX" required>
<div class="form-text">Solo aceptamos códigos postales de Almería (04xxx).</div>
</div>
</div>
<div class="mb-3">
<label for="country" class="form-label">País *</label>
<input type="text" class="form-control" id="country" name="country" value="España" readonly>
</div>
<div class="mb-3">
<label for="phone" class="form-label">Teléfono *</label>
<input type="tel" class="form-control" id="phone" name="phone" value="{{ direccion.phone|default:'' }}" placeholder="+34 600 000 000" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="is_default" name="is_default" {% if direccion.is_default %}checked{% endif %}>
<label class="form-check-label" for="is_default">
Establecer como dirección predeterminada
</label>
</div>
{{ form.as_p }}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">{% if direccion %}Actualizar{% else %}Crear{% endif %} Dirección</button>
<a href="{% url 'direcciones_usuario' %}" class="btn btn-secondary">Cancelar</a>
@@ -80,7 +35,6 @@
<script>
(function () {
const cityInput = document.getElementById('city');
const cityValidationMessage = document.getElementById('city-validation-message');
const form = cityInput ? cityInput.form : null;
if (!cityInput || !form) {
@@ -123,8 +77,6 @@
cityInput.setCustomValidity('El pueblo/ciudad debe pertenecer a la provincia de Almería.');
cityInput.classList.add('is-invalid');
}
cityValidationMessage.textContent = cityInput.validationMessage || 'El pueblo/ciudad debe pertenecer a la provincia de Almería.';
}
cityInput.addEventListener('input', validateTown);
+2 -25
View File
@@ -37,18 +37,7 @@
<div class="card-body">
<form method="POST">
{% csrf_token %}
<div class="mb-3">
<label for="first_name" class="form-label">Nombre</label>
<input type="text" class="form-control" id="first_name" name="first_name" value="{{ user.first_name }}" required>
</div>
<div class="mb-3">
<label for="last_name" class="form-label">Apellidos</label>
<input type="text" class="form-control" id="last_name" name="last_name" value="{{ user.last_name }}">
</div>
<div class="mb-3">
<label for="email" class="form-label">Correo Electrónico</label>
<input type="email" class="form-control" id="email" name="email" value="{{ user.email }}" required>
</div>
{{ form.as_p }}
<div class="mb-3">
<label for="username" class="form-label">Nombre de Usuario</label>
<input type="text" class="form-control" id="username" value="{{ user.username }}" disabled>
@@ -69,19 +58,7 @@
<div class="card-body">
<form method="POST" action="{% url 'cambiar_contrasena' %}">
{% csrf_token %}
<div class="mb-3">
<label for="current_password" class="form-label">Contraseña Actual</label>
<input type="password" class="form-control" id="current_password" name="current_password" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">Nueva Contraseña</label>
<input type="password" class="form-control" id="new_password" name="new_password" required>
<small class="text-muted">Mínimo 8 caracteres</small>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Confirmar Nueva Contraseña</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
</div>
{{ password_form.as_p }}
<button type="submit" class="btn btn-warning">Cambiar Contraseña</button>
</form>
</div>
+2 -60
View File
@@ -13,67 +13,9 @@
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<!-- Nombre del producto -->
<div class="mb-3">
<label for="name" class="form-label">Nombre del Producto <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required maxlength="200"
value="{{ producto.name }}" placeholder="Ej: iPhone 15 Pro Max">
</div>
<!-- Descripción breve -->
<div class="mb-3">
<label for="briefdesc" class="form-label">Descripción Breve</label>
<input type="text" class="form-control" id="briefdesc" name="briefdesc" maxlength="250"
value="{{ producto.briefdesc }}" placeholder="Una descripción corta para mostrar en las tarjetas de producto">
<div class="form-text">Opcional. Se mostrará en las vistas de listado de productos.</div>
</div>
<!-- Descripción completa -->
<div class="mb-3">
<label for="description" class="form-label">Descripción Completa <span class="text-danger">*</span></label>
<textarea class="form-control" id="description" name="description" rows="5" required
placeholder="Describe tu producto en detalle...">{{ producto.description }}</textarea>
</div>
<!-- Precio -->
<div class="mb-3">
<label for="price" class="form-label">Precio <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text"></span>
<input type="number" class="form-control" id="price" name="price" required
min="0" step="0.01" value="{{ producto.price }}" placeholder="0.00">
</div>
</div>
<!-- Stock -->
<div class="mb-3">
<label for="stock" class="form-label">Stock disponible <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="stock" name="stock" required
min="0" step="1" value="{{ producto.stock }}" placeholder="0">
<div class="form-text">Cantidad máxima que podrán comprar los clientes.</div>
</div>
<!-- Categoría -->
<div class="mb-3">
<label for="category" class="form-label">Categoría <span class="text-danger">*</span></label>
<select class="form-select" id="category" name="category" required>
<option value="" disabled>Selecciona una categoría</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if producto.category.id == category.id %}selected{% endif %}>{{ category.name }}</option>
{% endfor %}
</select>
</div>
<!-- Imagen principal -->
<div class="mb-3">
<label for="primary_image" class="form-label">Imagen Principal</label>
<input type="file" class="form-control" id="primary_image" name="primary_image"
accept="image/*">
<div class="form-text">Opcional. Si subes una nueva, reemplazará la actual.</div>
</div>
<!-- Imágenes secundarias -->
<!-- Imágenes secundarias (no incluidas en el form) -->
<div class="mb-4">
<label for="secondary_images" class="form-label">Imágenes Secundarias</label>
<input type="file" class="form-control" id="secondary_images" name="secondary_images"
+27
View File
@@ -0,0 +1,27 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr>
<td align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido bloqueada</h1>
</td>
</tr>
<tr>
<td align="center" style="padding: 40px">
<svg fill="#FF0000" width="128px" height="128px" viewBox="-3.2 -3.2 38.40 38.40" version="1.1" xmlns="http://www.w3.org/2000/svg" stroke="#FF0000" stroke-width="0.00032"><g id="SVGRepo_bgCarrier" stroke-width="0" transform="translate(6.4,6.4), scale(0.6)"><rect x="-3.2" y="-3.2" width="38.40" height="38.40" rx="19.2" fill="#1a5fb4" strokewidth="0"></rect></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="1.152"> <title>alert</title> <path d="M14.611 18.856c-0.346 0.352-0.52 0.782-0.52 1.292 0 0.551 0.197 1.014 0.59 1.389 0.363 0.346 0.799 0.519 1.309 0.519 0.521 0 0.971-0.188 1.346-0.566s0.562-0.828 0.562-1.35c0-0.504-0.182-0.943-0.545-1.318-0.363-0.381-0.801-0.571-1.311-0.571-0.567-0.001-1.044 0.201-1.431 0.605v0zM14.391 10.788c-0.299 0.451-0.447 1.011-0.447 1.679 0 0.545 0.092 1.146 0.276 1.802s0.435 1.271 0.751 1.846c0.428 0.779 0.76 1.169 0.994 1.169 0.24 0 0.557-0.305 0.949-0.914 0.346-0.539 0.622-1.152 0.83-1.841s0.312-1.332 0.312-1.93c0-0.902-0.244-1.6-0.73-2.092-0.363-0.375-0.805-0.563-1.326-0.563-0.703 0-1.24 0.282-1.609 0.844v0z"></path> </g><g id="SVGRepo_iconCarrier"> <title>alert</title> <path d="M14.611 18.856c-0.346 0.352-0.52 0.782-0.52 1.292 0 0.551 0.197 1.014 0.59 1.389 0.363 0.346 0.799 0.519 1.309 0.519 0.521 0 0.971-0.188 1.346-0.566s0.562-0.828 0.562-1.35c0-0.504-0.182-0.943-0.545-1.318-0.363-0.381-0.801-0.571-1.311-0.571-0.567-0.001-1.044 0.201-1.431 0.605v0zM14.391 10.788c-0.299 0.451-0.447 1.011-0.447 1.679 0 0.545 0.092 1.146 0.276 1.802s0.435 1.271 0.751 1.846c0.428 0.779 0.76 1.169 0.994 1.169 0.24 0 0.557-0.305 0.949-0.914 0.346-0.539 0.622-1.152 0.83-1.841s0.312-1.332 0.312-1.93c0-0.902-0.244-1.6-0.73-2.092-0.363-0.375-0.805-0.563-1.326-0.563-0.703 0-1.24 0.282-1.609 0.844v0z"></path> </g></svg>
</td>
</tr>
<tr>
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
<p>Lamentamos informarle de que el equipo de moderación ha tomado acciones en su cuenta</p>
<p>Su cuenta ha sido bloqueada indefinidamente y sus productos han sido eliminados de la tienda.</p>
<p>Si desea apelar, por favor, contacte con Soporte Técnico</p>
<p></p>
<p style="color: gray;">Este email ha sido enviado automaticamente, no responda a este correo.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
+27
View File
@@ -0,0 +1,27 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr>
<td align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido desbloqueada</h1>
</td>
</tr>
<tr>
<td align="center" style="padding: 40px">
<svg fill="#FF0000" width="128px" height="128px" viewBox="-3.2 -3.2 38.40 38.40" version="1.1" xmlns="http://www.w3.org/2000/svg" stroke="#FF0000" stroke-width="0.00032"><g id="SVGRepo_bgCarrier" stroke-width="0" transform="translate(6.4,6.4), scale(0.6)"><rect x="-3.2" y="-3.2" width="38.40" height="38.40" rx="19.2" fill="#1a5fb4" strokewidth="0"></rect></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="1.152"> <title>alert</title> <path d="M14.611 18.856c-0.346 0.352-0.52 0.782-0.52 1.292 0 0.551 0.197 1.014 0.59 1.389 0.363 0.346 0.799 0.519 1.309 0.519 0.521 0 0.971-0.188 1.346-0.566s0.562-0.828 0.562-1.35c0-0.504-0.182-0.943-0.545-1.318-0.363-0.381-0.801-0.571-1.311-0.571-0.567-0.001-1.044 0.201-1.431 0.605v0zM14.391 10.788c-0.299 0.451-0.447 1.011-0.447 1.679 0 0.545 0.092 1.146 0.276 1.802s0.435 1.271 0.751 1.846c0.428 0.779 0.76 1.169 0.994 1.169 0.24 0 0.557-0.305 0.949-0.914 0.346-0.539 0.622-1.152 0.83-1.841s0.312-1.332 0.312-1.93c0-0.902-0.244-1.6-0.73-2.092-0.363-0.375-0.805-0.563-1.326-0.563-0.703 0-1.24 0.282-1.609 0.844v0z"></path> </g><g id="SVGRepo_iconCarrier"> <title>alert</title> <path d="M14.611 18.856c-0.346 0.352-0.52 0.782-0.52 1.292 0 0.551 0.197 1.014 0.59 1.389 0.363 0.346 0.799 0.519 1.309 0.519 0.521 0 0.971-0.188 1.346-0.566s0.562-0.828 0.562-1.35c0-0.504-0.182-0.943-0.545-1.318-0.363-0.381-0.801-0.571-1.311-0.571-0.567-0.001-1.044 0.201-1.431 0.605v0zM14.391 10.788c-0.299 0.451-0.447 1.011-0.447 1.679 0 0.545 0.092 1.146 0.276 1.802s0.435 1.271 0.751 1.846c0.428 0.779 0.76 1.169 0.994 1.169 0.24 0 0.557-0.305 0.949-0.914 0.346-0.539 0.622-1.152 0.83-1.841s0.312-1.332 0.312-1.93c0-0.902-0.244-1.6-0.73-2.092-0.363-0.375-0.805-0.563-1.326-0.563-0.703 0-1.24 0.282-1.609 0.844v0z"></path> </g></svg>
</td>
</tr>
<tr>
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
<p>Hemos aceptado la apelación del previo baneo de su cuenta</p>
<p>Su cuenta ha sido desbloqueada y ya puede entrar, pero los productos seguirán eliminados, por lo que deberá recrearlos para seguir vendiendolos</p>
<p>Muchas gracias por su paciencia</p>
<p></p>
<p style="color: gray;">Este email ha sido enviado automaticamente, no responda a este correo.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
@@ -0,0 +1,6 @@
{% for field in form %}
<div class="mb-3">
{{ field.errors }}
{{ field.label_tag }} {{ field }}
</div>
{% endfor %}
@@ -0,0 +1,81 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row mt-4 mb-5">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2>Gestionar Imágenes</h2>
<p class="text-muted mb-0">Producto: <strong>{{ producto.name }}</strong></p>
</div>
<a href="{% url 'mis_productos' %}" class="btn btn-outline-secondary">← Volver a Mis Productos</a>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Imagen Principal</h5>
</div>
<div class="card-body">
{% if producto.primary_image %}
<img src="{{ producto.primary_image.image.url }}" alt="{{ producto.primary_image.alt|default:producto.name }}" class="rounded" style="width: 200px; height: 200px; object-fit: cover;">
<p class="mt-2 text-muted mb-0">Esta imagen no se puede cambiar desde aquí.</p>
{% else %}
<p class="text-muted">No hay imagen principal asignada.</p>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Imágenes Secundarias</h5>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#agregarImagenModal">
Agregar Imagen
</button>
</div>
<div class="card-body">
{% if secondary_images %}
<div class="row">
{% for img in secondary_images %}
<div class="col-md-3 col-sm-4 col-6 mb-3">
<div class="card">
<img src="{{ img.image.url }}" alt="{{ img.alt|default:producto.name }}" class="card-img-top" style="height: 180px; object-fit: cover;">
<div class="card-body p-2">
<form method="POST" action="{% url 'eliminar_imagen_secundaria' producto.id img.id %}" onsubmit="return confirm('¿Seguro que quieres eliminar esta imagen?');">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm w-100">🗑 Eliminar</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted text-center py-4">No hay imágenes secundarias. ¡Agrega una!</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="modal fade" id="agregarImagenModal" tabindex="-1" aria-labelledby="agregarImagenModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="agregarImagenModalLabel">Agregar Imagen Secundaria</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-body">
{{ form }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary">Subir Imagen</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+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>
+1 -16
View File
@@ -12,22 +12,7 @@
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<div class="mb-3">
<label for="loginEmail" class="form-label">Correo Electrónico</label>
<input type="email" class="form-control" id="loginEmail" name="email" required>
</div>
<div class="mb-3">
<label for="loginPassword" class="form-label">Contraseña</label>
<input type="password" class="form-control" id="loginPassword" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="rememberMe" name="remember">
<label class="form-check-label" for="rememberMe">
Recordarme
</label>
</div>
{{ form }}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Iniciar Sesión</button>
@@ -57,6 +57,7 @@
<td class="text-end">{{ producto.stock }}</td>
<td class="text-end">
<div class="d-flex justify-content-end gap-2">
<a href="{% url 'gestionar_imagenes' producto.id %}" class="btn btn-outline-secondary btn-sm">Gestionar Imágenes</a>
<a href="{% url 'editar_producto' producto.id %}" class="btn btn-outline-primary btn-sm">Editar</a>
<form method="POST" action="{% url 'borrar_producto' producto.id %}" onsubmit="return confirm('¿Seguro que quieres borrar este producto?');">
{% csrf_token %}
+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 -27
View File
@@ -12,33 +12,7 @@
<form method="post" action="{% url 'register' %}">
{% csrf_token %}
<div class="mb-3">
<label for="registerName" class="form-label">Nombre Completo</label>
<input type="text" class="form-control" id="registerName" name="name" required>
</div>
<div class="mb-3">
<label for="registerEmail" class="form-label">Correo Electrónico</label>
<input type="email" class="form-control" id="registerEmail" name="email" required>
</div>
<div class="mb-3">
<label for="registerPassword" class="form-label">Contraseña</label>
<input type="password" class="form-control" id="registerPassword" name="password" required>
<div class="form-text">La contraseña debe tener al menos 8 caracteres.</div>
</div>
<div class="mb-3">
<label for="registerPasswordConfirm" class="form-label">Confirmar Contraseña</label>
<input type="password" class="form-control" id="registerPasswordConfirm" name="password_confirm" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="acceptTerms" name="terms" required>
<label class="form-check-label" for="acceptTerms">
Acepto los <a href="#" target="_blank">términos y condiciones</a>
</label>
</div>
{{ form }}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Crear Cuenta</button>
+1 -5
View File
@@ -11,11 +11,7 @@
<div class="card-body">
<form method="post" action="{% url 'reset_password' %}">
{% csrf_token %}
<div class="mb-3">
<label for="loginEmail" class="form-label">Correo Electrónico</label>
<input type="email" class="form-control" id="loginEmail" name="email" required>
</div>
{{ form.as_p }}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Recuperar contraseña</button>
@@ -11,16 +11,7 @@
<div class="card-body">
<form method="post" action="{% url 'reset_password_phase2' code %}">
{% csrf_token %}
<div class="mb-3">
<label for="password" class="form-label">Contraseña</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="verify_password" class="form-label">Verificar contraseña</label>
<input type="password" class="form-control" id="verify_password" name="verify_password" required>
</div>
{{ form.as_p }}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Recuperar contraseña</button>
+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>
+181
View File
@@ -14,6 +14,7 @@ from .models import (
StockReservation, StockReservationItem, Cart, CartItem,
Order, OrderItem, OrderMessage, SavedPaymentMethod, ShippingAddress
)
from .forms import UserRegisterForm, UserLoginForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX
import string
import random
@@ -23,6 +24,185 @@ import random
class UserModelTests(TestCase):
"""Tests exhaustivos para el modelo User."""
class FormTests(TestCase):
"""Tests para formularios Django."""
def test_user_register_form_terms_required(self):
"""El campo terms debe ser obligatorio."""
form = UserRegisterForm(data={
"name": "Test User",
"email": "test@example.com",
"password": "password123",
"password_confirm": "password123",
})
self.assertFalse(form.is_valid())
self.assertIn("terms", form.errors)
def test_user_register_form_terms_off_not_checked(self):
"""Si terms está en off (None/false), debe fallar."""
form = UserRegisterForm(data={
"name": "Test User",
"email": "test@example.com",
"password": "password123",
"password_confirm": "password123",
"terms": False,
})
self.assertFalse(form.is_valid())
self.assertIn("terms", form.errors)
def test_user_register_form_terms_on(self):
"""Si terms está marcado, el formulario debe ser válido."""
form = UserRegisterForm(data={
"name": "Test User",
"email": "test@example.com",
"password": "password123",
"password_confirm": "password123",
"terms": True,
})
self.assertTrue(form.is_valid())
def test_user_register_form_passwords_mismatch(self):
"""Las contraseñas deben coincidir."""
form = UserRegisterForm(data={
"name": "Test User",
"email": "test@example.com",
"password": "password123",
"password_confirm": "different_password",
"terms": True,
})
self.assertFalse(form.is_valid())
self.assertIn("__all__", form.errors)
def test_user_register_form_empty_fields(self):
"""Los campos obligatorios no pueden estar vacíos."""
form = UserRegisterForm(data={})
self.assertFalse(form.is_valid())
self.assertIn("name", form.errors)
self.assertIn("email", form.errors)
self.assertIn("password", form.errors)
self.assertIn("password_confirm", form.errors)
def test_user_login_form_valid(self):
"""Login con datos válidos."""
form = UserLoginForm(data={
"email": "test@example.com",
"password": "password123",
})
self.assertTrue(form.is_valid())
def test_user_login_form_missing_email(self):
"""Email es obligatorio en login."""
form = UserLoginForm(data={
"password": "password123",
})
self.assertFalse(form.is_valid())
self.assertIn("email", form.errors)
def test_user_login_form_invalid_email_format(self):
"""Email debe tener formato válido."""
form = UserLoginForm(data={
"email": "not-an-email",
"password": "password123",
})
self.assertFalse(form.is_valid())
self.assertIn("email", form.errors)
def test_edit_profile_form_valid(self):
"""Formulario de edición de perfil válido."""
form = EditProfileForm(data={
"first_name": "Juan",
"last_name": "Pérez",
"email": "juan@example.com",
})
self.assertTrue(form.is_valid())
def test_edit_profile_form_missing_email(self):
"""Email es obligatorio en perfil."""
form = EditProfileForm(data={
"first_name": "Juan",
"last_name": "Pérez",
})
self.assertFalse(form.is_valid())
self.assertIn("email", form.errors)
def test_change_password_form_passwords_mismatch(self):
"""Las nuevas contraseñas deben coincidir."""
form = ChangePasswordForm(data={
"current_password": "oldpass123",
"new_password": "newpass123",
"confirm_password": "differentpass",
})
self.assertFalse(form.is_valid())
self.assertIn("__all__", form.errors)
def test_change_password_form_short_password(self):
"""La nueva contraseña debe tener al menos 8 caracteres."""
form = ChangePasswordForm(data={
"current_password": "oldpass123",
"new_password": "short",
"confirm_password": "short",
})
self.assertFalse(form.is_valid())
self.assertIn("__all__", form.errors)
def test_shipping_address_form_valid(self):
"""Dirección con datos válidos."""
form = ShippingAddressForm(data={
"full_name": "Juan Pérez",
"address_line_1": "Calle Mayor 123",
"city": "Almería",
"postal_code": "04001",
"country": "España",
"phone": "612345678",
})
self.assertTrue(form.is_valid())
def test_shipping_address_form_missing_required_fields(self):
"""Campos obligatorios no pueden estar vacíos."""
form = ShippingAddressForm(data={})
self.assertFalse(form.is_valid())
self.assertIn("full_name", form.errors)
self.assertIn("address_line_1", form.errors)
self.assertIn("city", form.errors)
self.assertIn("postal_code", form.errors)
self.assertIn("phone", form.errors)
def test_reset_password_form_valid_email(self):
"""Formulario de recuperación de contraseña."""
form = ResetPasswordForm(data={
"email": "test@example.com",
})
self.assertTrue(form.is_valid())
def test_reset_password_form_invalid_email(self):
"""Email inválido."""
form = ResetPasswordForm(data={
"email": "not-an-email",
})
self.assertFalse(form.is_valid())
self.assertIn("email", form.errors)
def test_reset_password_phase2_form_valid(self):
"""Cambio de contraseña válido."""
form = ResetPasswordPhase2Form(data={
"password": "newpass123",
"verify_password": "newpass123",
})
self.assertTrue(form.is_valid())
def test_reset_password_phase2_form_mismatch(self):
"""Las contraseñas deben coincidir."""
form = ResetPasswordPhase2Form(data={
"password": "newpass123",
"verify_password": "different",
})
self.assertFalse(form.is_valid())
self.assertIn("__all__", form.errors)
# ==================== ENDPOINT VIEW TESTS ====================
def setUp(self):
self.user_data = {
"username": "testuser",
@@ -1455,6 +1635,7 @@ class EndpointViewTests(TestCase):
"email": "nuevo@example.com",
"password": self.password,
"password_confirm": self.password,
"terms": "on",
})
self.assertEqual(register_response.status_code, 302)
confirm_delay.assert_called_once()
+2
View File
@@ -18,6 +18,8 @@ urlpatterns = [
path("venta/crear-producto/", views.crear_producto, name="crear_producto"),
path("venta/editar-producto/<int:id>/", views.editar_producto, name="editar_producto"),
path("venta/borrar-producto/<int:id>/", views.borrar_producto, name="borrar_producto"),
path("venta/gestionar-imagenes/<int:id>/", views.gestionar_imagenes, name="gestionar_imagenes"),
path("venta/gestionar-imagenes/<int:product_id>/eliminar/<int:image_id>/", views.eliminar_imagen_secundaria, name="eliminar_imagen_secundaria"),
# Carrito
path("cart/", views.view_cart, name="view_cart"),
path("cart/add/<int:product_id>/", views.add_to_cart, name="add_to_cart"),
+397 -396
View File
File diff suppressed because it is too large Load Diff