diff --git a/.gitignore b/.gitignore index e3b2dd4..1abf015 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ venv/ .venv # Archivos de base de datos +db.sqlite3 # Archivos de migraciones (si quieres omitirlos) **/migrations/__pycache__/ @@ -21,4 +22,5 @@ venv/ # Archivos de Django estáticos staticfiles/ -**/*.sqlite3 \ No newline at end of file +# Archivos subidos por el usuario +media/ \ No newline at end of file diff --git "a/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/03.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/03.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..9af2d44 Binary files /dev/null and "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/03.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/04.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/04.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..0e9f2a2 Binary files /dev/null and "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/04.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/05.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/05.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..6b0be90 Binary files /dev/null and "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/05.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/06.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/06.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..f964b1b Binary files /dev/null and "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/06.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/07.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/07.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..996820b Binary files /dev/null and "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/07.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/08.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/08.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..3b36990 Binary files /dev/null and "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/08.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/09.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/09.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..5e7261d Binary files /dev/null and "b/docs/Iteraci\303\263n 1/Informes de Seguimiento 1/09.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/11.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/11.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..783410b Binary files /dev/null and "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/11.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/12.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/12.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..9e68f45 Binary files /dev/null and "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/12.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/13.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/13.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..58edad6 Binary files /dev/null and "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/13.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/14.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/14.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..f9ec6f9 Binary files /dev/null and "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/14.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/15.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/15.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..fc87c34 Binary files /dev/null and "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/15.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/16.11.25 Informe de seguimiento.pdf" "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/16.11.25 Informe de seguimiento.pdf" new file mode 100644 index 0000000..8708c1e Binary files /dev/null and "b/docs/Iteraci\303\263n 2/Informes de Seguimiento 2/16.11.25 Informe de seguimiento.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/REGISTRO DE DECISIONES_v2.0.pdf" "b/docs/Iteraci\303\263n 2/REGISTRO DE DECISIONES_v2.0.pdf" new file mode 100644 index 0000000..25bded6 Binary files /dev/null and "b/docs/Iteraci\303\263n 2/REGISTRO DE DECISIONES_v2.0.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/REGISTRO DE INCIDENCIAS_v2.0.pdf" "b/docs/Iteraci\303\263n 2/REGISTRO DE INCIDENCIAS_v2.0.pdf" new file mode 100644 index 0000000..e326b64 Binary files /dev/null and "b/docs/Iteraci\303\263n 2/REGISTRO DE INCIDENCIAS_v2.0.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/SPRINT RETROSPECTIVE 2.pdf" "b/docs/Iteraci\303\263n 2/SPRINT RETROSPECTIVE 2.pdf" new file mode 100644 index 0000000..67fdbda Binary files /dev/null and "b/docs/Iteraci\303\263n 2/SPRINT RETROSPECTIVE 2.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/SPRINT REVIEW 2.pdf" "b/docs/Iteraci\303\263n 2/SPRINT REVIEW 2.pdf" new file mode 100644 index 0000000..68c00e2 Binary files /dev/null and "b/docs/Iteraci\303\263n 2/SPRINT REVIEW 2.pdf" differ diff --git "a/docs/Iteraci\303\263n 2/Sprint Backlog 2.pdf" "b/docs/Iteraci\303\263n 2/Sprint Backlog 2.pdf" new file mode 100644 index 0000000..6b8cc7d Binary files /dev/null and "b/docs/Iteraci\303\263n 2/Sprint Backlog 2.pdf" differ diff --git a/essenza/_sample_assets/products/aceite_capilar.jpg b/essenza/_sample_assets/products/aceite_capilar.jpg new file mode 100644 index 0000000..6a85d21 Binary files /dev/null and b/essenza/_sample_assets/products/aceite_capilar.jpg differ diff --git a/essenza/_sample_assets/products/crema_antiedad.jpg b/essenza/_sample_assets/products/crema_antiedad.jpg new file mode 100644 index 0000000..91c7067 Binary files /dev/null and b/essenza/_sample_assets/products/crema_antiedad.jpg differ diff --git a/essenza/_sample_assets/products/crema_hidratante.jpg b/essenza/_sample_assets/products/crema_hidratante.jpg new file mode 100644 index 0000000..f2bb003 Binary files /dev/null and b/essenza/_sample_assets/products/crema_hidratante.jpg differ diff --git a/essenza/_sample_assets/products/crema_pies.jpg b/essenza/_sample_assets/products/crema_pies.jpg new file mode 100644 index 0000000..2b095d5 Binary files /dev/null and b/essenza/_sample_assets/products/crema_pies.jpg differ diff --git a/essenza/_sample_assets/products/crema_solar.jpg b/essenza/_sample_assets/products/crema_solar.jpg new file mode 100644 index 0000000..8951692 Binary files /dev/null and b/essenza/_sample_assets/products/crema_solar.jpg differ diff --git a/essenza/_sample_assets/products/desodorante.jpg b/essenza/_sample_assets/products/desodorante.jpg new file mode 100644 index 0000000..a329289 Binary files /dev/null and b/essenza/_sample_assets/products/desodorante.jpg differ diff --git a/essenza/_sample_assets/products/gel_antibacterial.jpg b/essenza/_sample_assets/products/gel_antibacterial.jpg new file mode 100644 index 0000000..f035fcc Binary files /dev/null and b/essenza/_sample_assets/products/gel_antibacterial.jpg differ diff --git a/essenza/_sample_assets/products/laca_pelo.jpg b/essenza/_sample_assets/products/laca_pelo.jpg new file mode 100644 index 0000000..ae4e7ec Binary files /dev/null and b/essenza/_sample_assets/products/laca_pelo.jpg differ diff --git a/essenza/_sample_assets/products/limpieza_facial.jpg b/essenza/_sample_assets/products/limpieza_facial.jpg new file mode 100644 index 0000000..c988ce3 Binary files /dev/null and b/essenza/_sample_assets/products/limpieza_facial.jpg differ diff --git a/essenza/_sample_assets/products/maquillaje_base.jpg b/essenza/_sample_assets/products/maquillaje_base.jpg new file mode 100644 index 0000000..683a2ea Binary files /dev/null and b/essenza/_sample_assets/products/maquillaje_base.jpg differ diff --git a/essenza/_sample_assets/products/mascarilla_facial.jpg b/essenza/_sample_assets/products/mascarilla_facial.jpg new file mode 100644 index 0000000..517de1e Binary files /dev/null and b/essenza/_sample_assets/products/mascarilla_facial.jpg differ diff --git a/essenza/_sample_assets/products/perfume_floral.jpg b/essenza/_sample_assets/products/perfume_floral.jpg new file mode 100644 index 0000000..cd2dd8a Binary files /dev/null and b/essenza/_sample_assets/products/perfume_floral.jpg differ diff --git a/essenza/_sample_assets/products/pincel_maquillaje.jpg b/essenza/_sample_assets/products/pincel_maquillaje.jpg new file mode 100644 index 0000000..413bc33 Binary files /dev/null and b/essenza/_sample_assets/products/pincel_maquillaje.jpg differ diff --git a/essenza/_sample_assets/products/rizador_pelo.jpg b/essenza/_sample_assets/products/rizador_pelo.jpg new file mode 100644 index 0000000..1056977 Binary files /dev/null and b/essenza/_sample_assets/products/rizador_pelo.jpg differ diff --git a/essenza/_sample_assets/products/secador_pelo.jpg b/essenza/_sample_assets/products/secador_pelo.jpg new file mode 100644 index 0000000..02d5e8f Binary files /dev/null and b/essenza/_sample_assets/products/secador_pelo.jpg differ diff --git a/essenza/_sample_assets/products/shampoo_anticaspa.jpg b/essenza/_sample_assets/products/shampoo_anticaspa.jpg new file mode 100644 index 0000000..e59a462 Binary files /dev/null and b/essenza/_sample_assets/products/shampoo_anticaspa.jpg differ diff --git a/essenza/_sample_assets/products/shampoo_reconstructivo.jpg b/essenza/_sample_assets/products/shampoo_reconstructivo.jpg new file mode 100644 index 0000000..261d54b Binary files /dev/null and b/essenza/_sample_assets/products/shampoo_reconstructivo.jpg differ diff --git a/essenza/_sample_assets/products/shampoo_voluminizador.jpg b/essenza/_sample_assets/products/shampoo_voluminizador.jpg new file mode 100644 index 0000000..fcf6a33 Binary files /dev/null and b/essenza/_sample_assets/products/shampoo_voluminizador.jpg differ diff --git a/essenza/_sample_assets/products/tinte_cabello.jpg b/essenza/_sample_assets/products/tinte_cabello.jpg new file mode 100644 index 0000000..0331b64 Binary files /dev/null and b/essenza/_sample_assets/products/tinte_cabello.jpg differ diff --git a/essenza/_sample_assets/products/toallitas_desmaquillantes.jpg b/essenza/_sample_assets/products/toallitas_desmaquillantes.jpg new file mode 100644 index 0000000..b35cc92 Binary files /dev/null and b/essenza/_sample_assets/products/toallitas_desmaquillantes.jpg differ diff --git a/essenza/_sample_assets/profile_pics/admin.jpg b/essenza/_sample_assets/profile_pics/admin.jpg new file mode 100644 index 0000000..dff3fbd Binary files /dev/null and b/essenza/_sample_assets/profile_pics/admin.jpg differ diff --git a/essenza/profile_pics/user2.avif b/essenza/_sample_assets/profile_pics/user1.jpg similarity index 100% rename from essenza/profile_pics/user2.avif rename to essenza/_sample_assets/profile_pics/user1.jpg diff --git a/essenza/profile_pics/user1.avif b/essenza/_sample_assets/profile_pics/user2.jpg similarity index 100% rename from essenza/profile_pics/user1.avif rename to essenza/_sample_assets/profile_pics/user2.jpg diff --git a/essenza/profile_pics/user3.avif b/essenza/_sample_assets/profile_pics/user3.jpg similarity index 100% rename from essenza/profile_pics/user3.avif rename to essenza/_sample_assets/profile_pics/user3.jpg diff --git a/essenza/db.sqlite3 b/essenza/db.sqlite3 deleted file mode 100644 index 836a999..0000000 Binary files a/essenza/db.sqlite3 and /dev/null differ diff --git a/essenza/deploy.sh b/essenza/deploy.sh new file mode 100644 index 0000000..7d64b16 --- /dev/null +++ b/essenza/deploy.sh @@ -0,0 +1,7 @@ +pip install -r requirements.txt && \ +python manage.py flush --noinput && \ +python manage.py migrate --noinput && \ +# python manage.py collectstatic --noinput && \ +python manage.py loaddata user/sample/sample.json && \ +python manage.py loaddata product/sample/sample.json && \ +python manage.py loaddata order/sample/sample.json \ No newline at end of file diff --git a/essenza/essenza/settings.py b/essenza/essenza/settings.py index 662ee0e..73abfdc 100644 --- a/essenza/essenza/settings.py +++ b/essenza/essenza/settings.py @@ -20,7 +20,7 @@ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-7+c*kj699pt34%5ub-x04i3%nlbhc@y+7sdew3+7!z5h-z1k_v' +SECRET_KEY = "django-insecure-7+c*kj699pt34%5ub-x04i3%nlbhc@y+7sdew3+7!z5h-z1k_v" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -31,57 +31,58 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'user', - 'product', - 'order', - 'info', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", + "user", + "product", + "order", + "info", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.locale.LocaleMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'essenza.urls' +ROOT_URLCONF = "essenza.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ BASE_DIR / 'templates' ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.template.context_processors.i18n', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.template.context_processors.i18n", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'essenza.wsgi.application' +WSGI_APPLICATION = "essenza.wsgi.application" # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } @@ -91,16 +92,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -108,9 +109,9 @@ # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ -LANGUAGE_CODE = 'es' +LANGUAGE_CODE = "es" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -120,16 +121,21 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.2/howto/static-files/ -STATIC_URL = 'static/' -STATICFILES_DIRS = [BASE_DIR / 'static'] +STATIC_URL = "static/" +STATICFILES_DIRS = [BASE_DIR / "static"] +STATIC_ROOT = BASE_DIR / "staticfiles" + +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # ----------------------------------------------------------------- # SOLUCIÓN AL ERROR E304 -# Especifica que nuestro modelo 'Usuario' en la app 'user' +# Especifica que nuestro modelo 'Usuario' en la app 'user' # es el modelo de autenticación oficial. # ----------------------------------------------------------------- -AUTH_USER_MODEL = 'user.Usuario' \ No newline at end of file +AUTH_USER_MODEL = "user.Usuario" diff --git a/essenza/essenza/urls.py b/essenza/essenza/urls.py index e08e7df..2390f45 100644 --- a/essenza/essenza/urls.py +++ b/essenza/essenza/urls.py @@ -1,106 +1,20 @@ +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin -from django.urls import path, include -from django.http import HttpResponse +from django.urls import include, path from info.views import info_view -from product.views import EscaparateView -import user - -def home(request): - html = """ - - - Essenza - - - - i -

Bienvenidos a Essenza

-

Tu espacio online de cosmética natural, belleza y cuidado personal.

-

Explora nuestros productos, descubre nuevas fragancias y disfruta de la experiencia Essenza 🌸

- -
- Registro - Iniciar sesión - Continuar como invitado -
- - - """ - return HttpResponse(html) +from product.views import DashboardView +from product.views import CatalogView, CatalogDetailView urlpatterns = [ - path('', home, name='home'), - path('info/', info_view, name='info-home'), - path("user/", include("user.urls")), - path('admin/', admin.site.urls), - path('escaparate/', EscaparateView.as_view(), name='escaparate') -] \ No newline at end of file + path("info/", info_view, name="info-home"), + path("user/", include("user.urls")), + path("admin/", admin.site.urls), + path("product/", include("product.urls")), + path("", DashboardView.as_view(), name="dashboard"), + path("catalogo/", CatalogView.as_view(), name="catalog"), + path("catalogo//", CatalogDetailView.as_view(), name="catalog_detail"), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/essenza/info/views.py b/essenza/info/views.py index 2429570..3127567 100644 --- a/essenza/info/views.py +++ b/essenza/info/views.py @@ -1,101 +1,6 @@ # views.py (donde se encuentra la función info_view) -from django.http import HttpResponse -# from django.shortcuts import render # No se usa ya que el HTML está en HttpResponse +from django.shortcuts import render def info_view(request): - # CIF inventado para la S.L. Essenza: B87654321 - - html = """ - - - - - Condiciones Legales e Información de Essenza - - - - -
-

Información Legal y Condiciones de Venta de Essenza

-

Volver a la Página Principal

-
- -

1. Identificación y Aviso Legal

-

Essenza S.L. es la denominación social y el nombre comercial de la tienda online dedicada a la venta de productos de cosmética y cuidado personal, rigiéndose su actividad por la legislación española vigente. En cumplimiento del deber de información recogido en la Ley 34/2002 de Servicios de la Sociedad de la Información y Comercio Electrónico (LSSI-CE) y en el Real Decreto 85/2018 sobre productos cosméticos, a continuación se detallan los datos de identificación del titular de este sitio web:

- - -

2. Condiciones Generales de Venta (CGV)

-

Estas condiciones regulan la relación contractual de compraventa entre Essenza y usted desde el momento en que realiza un pedido en nuestra web. La formalización de un pedido implica la lectura, comprensión y aceptación expresa de estas Condiciones Generales de Venta en su totalidad, siendo de obligado cumplimiento para ambas partes.

- -

2.1. Información, Trazabilidad y Calidad del Producto

-

Essenza garantiza que todos sus productos han pasado rigurosos controles de calidad y cumplen con los requisitos de seguridad establecidos por la normativa europea (Reglamento CE 1223/2009). La información detallada de ingredientes, modo de uso, precauciones y el Periodo Después de la Apertura (PAO) se encuentra de forma clara y accesible en la ficha de cada producto y en su etiquetado. Mantenemos un estricto control de trazabilidad para garantizar la seguridad de todos los artículos de cosmética que comercializamos.

- -

2.2. Proceso de Compra y Precio

-

El proceso de compra se considera finalizado y vinculante una vez que el pago ha sido confirmado. Todos los precios mostrados en el sitio web están expresados en euros (€) e incluyen el Impuesto sobre el Valor Añadido (I.V.A.) legalmente aplicable. Los gastos de envío serán calculados en base al peso y la dirección de entrega, siendo detallados y aceptados por el Cliente antes de la confirmación final de la compra.

- -

2.3. Envíos, Plazos y Riesgos

- - -

2.4. Política de Desistimiento y Devoluciones

-

De acuerdo con la Ley General para la Defensa de los Consumidores y Usuarios, el Cliente dispone de 14 días naturales desde la recepción del pedido para ejercer su derecho de desistimiento. Condición Específica para Cosmética: Por estrictas razones de higiene, seguridad y protección de la salud, no se admitirá la devolución de productos cosméticos que hayan sido abiertos, desprecintados o usados. En caso de desistimiento válido, los costes directos de la devolución (envío de vuelta) correrán a cargo del cliente, salvo si la causa es un producto defectuoso o un error de Essenza.

- -

3. Política de Privacidad (RGPD)

-

En Essenza, su privacidad es nuestra prioridad. Los datos personales recabados (nombre, dirección, email, datos de pago) a través de la web son tratados bajo la legitimación de la ejecución de un contrato (para gestionar su pedido) o el consentimiento (para el newsletter). Nos comprometemos a no ceder sus datos a terceros, salvo obligación legal. Usted puede ejercer en todo momento sus derechos de Acceso, Rectificación, Supresión, Limitación, Portabilidad y Oposición (ARSLOP) enviando una solicitud a info@essenza.com.

- - - - - - """ - return HttpResponse(html) \ No newline at end of file + return render(request, "info/info.html") \ No newline at end of file diff --git a/essenza/load_samples.bat b/essenza/load_samples.bat new file mode 100644 index 0000000..8374cf9 --- /dev/null +++ b/essenza/load_samples.bat @@ -0,0 +1,48 @@ +@echo off +REM --------------------------------------------------------- +REM IMPORTANTE: Este archivo borra todos los datos de tu BD local (y la crea con los datos de sampleo). +REM Las imágenes de sampleo se copian a la carpeta 'media/'. +REM También instala las dependencias necesarias definidas en 'requirements.txt' (si aun no lo están). +REM --------------------------------------------------------- + +echo --- Instalando dependencias (pip)... +pip install -r requirements.txt && ( + + echo --- Borrando TODOS los datos de la BD... + python manage.py flush --noinput && ( + + echo. + echo --- Aplicando migraciones... + python manage.py migrate --noinput && ( + + echo. + echo --- Copiando imagenes de sampleo a 'media/'... + REM XCOPY [origen] [destino] /E /I /Y + REM /E = Copia subdirectorios (incluso vacíos) + REM /I = Si el destino no existe, asume que es un directorio + REM /Y = Suprime la pregunta de "sobreescribir archivo" + XCOPY _sample_assets media /E /I /Y && ( + + echo. + echo --- Cargando datos de USER... + python manage.py loaddata user/sample/sample.json && ( + + echo. + echo --- Cargando datos de PRODUCT... + python manage.py loaddata product/sample/sample.json && ( + + echo. + echo --- Cargando datos de ORDER... + python manage.py loaddata order/sample/sample.json && ( + + echo. + echo --- !Proceso completado! La base de datos esta lista. --- + ) + ) + ) + ) + ) + ) +) + +@echo on \ No newline at end of file diff --git a/essenza/order/admin.py b/essenza/order/admin.py index 54957e8..031b084 100644 --- a/essenza/order/admin.py +++ b/essenza/order/admin.py @@ -1,5 +1,7 @@ from django.contrib import admin -from .models import Order, OrderProduct, Status + +from .models import Order, OrderProduct + # Register your models here. admin.site.register(Order) diff --git a/essenza/order/apps.py b/essenza/order/apps.py index 42888e4..b2f0796 100644 --- a/essenza/order/apps.py +++ b/essenza/order/apps.py @@ -2,5 +2,5 @@ class OrderConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'order' + default_auto_field = "django.db.models.BigAutoField" + name = "order" diff --git a/essenza/order/models.py b/essenza/order/models.py index d623635..2857975 100644 --- a/essenza/order/models.py +++ b/essenza/order/models.py @@ -1,27 +1,43 @@ from django.db import models +from django.utils import timezone + # Create your models here. class Status(models.TextChoices): - PENDING = 'pending', 'Pending' - PAID = 'paid', 'Paid' - SHIPPED = 'shipped', 'Shipped' + PENDING = "pending", "Pending" + PAID = "paid", "Paid" + SHIPPED = "shipped", "Shipped" + class Order(models.Model): - user = models.ForeignKey('user.Usuario', on_delete=models.CASCADE, related_name='orders') - adress = models.CharField(max_length=255) - placed_at = models.DateTimeField(auto_now=True) - total_price = models.DecimalField(max_digits=10, decimal_places=2) - status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING) + user = models.ForeignKey( + "user.Usuario", on_delete=models.CASCADE, related_name="orders" + ) + address = models.CharField(max_length=255) + placed_at = models.DateTimeField(default=timezone.now) + status = models.CharField( + max_length=10, choices=Status.choices, default=Status.PENDING + ) + + @property + def total_price(self): + total = 0 + for product in self.order_products.all(): + total += product.quantity * product.product.price + return total def __str__(self): return f"Order {self.id} by {self.user.email}" class OrderProduct(models.Model): - order = models.ForeignKey('order.Order', on_delete=models.CASCADE, related_name='order_products') - product = models.ForeignKey('product.Product', on_delete=models.CASCADE, related_name='product_orders') + order = models.ForeignKey( + "order.Order", on_delete=models.CASCADE, related_name="order_products" + ) + product = models.ForeignKey( + "product.Product", on_delete=models.CASCADE, related_name="product_orders" + ) quantity = models.IntegerField() - unity_price = models.DecimalField(max_digits=10, decimal_places=2) def __str__(self): return f"{self.quantity} of {self.product.name} in order {self.order.id}" diff --git a/essenza/order/sample/sample.json b/essenza/order/sample/sample.json new file mode 100644 index 0000000..fc7017d --- /dev/null +++ b/essenza/order/sample/sample.json @@ -0,0 +1,372 @@ +[ + { + "model": "order.order", + "pk": 1, + "fields": { + "user": 1, + "address": "Calle Gran Vía, 23, Madrid, 28013", + "placed_at": "2025-11-12T10:30:00Z", + "status": "pending" + } + }, + { + "model": "order.order", + "pk": 2, + "fields": { + "user": 2, + "address": "Avenida de la Constitución, 8, Sevilla, 41001", + "placed_at": "2025-11-11T15:10:00Z", + "status": "paid" + } + }, + { + "model": "order.order", + "pk": 3, + "fields": { + "user": 3, + "address": "Carrer de Pau Claris, 60, Barcelona, 08010", + "placed_at": "2025-11-10T19:25:00Z", + "status": "shipped" + } + }, + { + "model": "order.order", + "pk": 4, + "fields": { + "user": 1, + "address": "Calle Alcalá, 120, Madrid, 28009", + "placed_at": "2025-11-09T09:00:00Z", + "status": "pending" + } + }, + { + "model": "order.order", + "pk": 5, + "fields": { + "user": 2, + "address": "Plaza Nueva, 10, Bilbao, 48001", + "placed_at": "2025-11-08T12:15:00Z", + "status": "shipped" + } + }, + { + "model": "order.order", + "pk": 6, + "fields": { + "user": 3, + "address": "Calle Larios, 5, Málaga, 29001", + "placed_at": "2025-11-07T14:00:00Z", + "status": "paid" + } + }, + { + "model": "order.order", + "pk": 7, + "fields": { + "user": 1, + "address": "Paseo de Gracia, 92, Barcelona, 08008", + "placed_at": "2025-11-06T18:45:00Z", + "status": "pending" + } + }, + { + "model": "order.order", + "pk": 8, + "fields": { + "user": 2, + "address": "Calle de la Paz, 1, Valencia, 46003", + "placed_at": "2025-11-06T10:00:00Z", + "status": "shipped" + } + }, + { + "model": "order.order", + "pk": 9, + "fields": { + "user": 3, + "address": "Calle Mayor, 30, Zaragoza, 50001", + "placed_at": "2025-10-15T11:00:00Z", + "status": "shipped" + } + }, + { + "model": "order.order", + "pk": 10, + "fields": { + "user": 1, + "address": "Rúa do Vilar, 50, Santiago de Compostela, 15705", + "placed_at": "2025-10-28T08:30:00Z", + "status": "paid" + } + }, + { + "model": "order.orderproduct", + "pk": 1, + "fields": { + "order": 1, + "product": 1, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 2, + "fields": { + "order": 1, + "product": 18, + "quantity": 2 + } + }, + { + "model": "order.orderproduct", + "pk": 3, + "fields": { + "order": 2, + "product": 2, + "quantity": 2 + } + }, + { + "model": "order.orderproduct", + "pk": 4, + "fields": { + "order": 3, + "product": 5, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 5, + "fields": { + "order": 3, + "product": 11, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 6, + "fields": { + "order": 3, + "product": 15, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 7, + "fields": { + "order": 4, + "product": 4, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 8, + "fields": { + "order": 5, + "product": 8, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 9, + "fields": { + "order": 6, + "product": 3, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 10, + "fields": { + "order": 6, + "product": 6, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 11, + "fields": { + "order": 7, + "product": 10, + "quantity": 2 + } + }, + { + "model": "order.orderproduct", + "pk": 12, + "fields": { + "order": 8, + "product": 20, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 13, + "fields": { + "order": 8, + "product": 17, + "quantity": 3 + } + }, + { + "model": "order.orderproduct", + "pk": 14, + "fields": { + "order": 9, + "product": 9, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 15, + "fields": { + "order": 9, + "product": 12, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 16, + "fields": { + "order": 10, + "product": 7, + "quantity": 5 + } + }, + { + "model": "order.orderproduct", + "pk": 17, + "fields": { + "order": 10, + "product": 16, + "quantity": 2 + } + }, + { + "model": "order.orderproduct", + "pk": 18, + "fields": { + "order": 1, + "product": 10, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 19, + "fields": { + "order": 2, + "product": 7, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 20, + "fields": { + "order": 4, + "product": 2, + "quantity": 3 + } + }, + { + "model": "order.orderproduct", + "pk": 21, + "fields": { + "order": 5, + "product": 19, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 22, + "fields": { + "order": 7, + "product": 1, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 23, + "fields": { + "order": 7, + "product": 18, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 24, + "fields": { + "order": 9, + "product": 5, + "quantity": 2 + } + }, + { + "model": "order.orderproduct", + "pk": 25, + "fields": { + "order": 9, + "product": 14, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 26, + "fields": { + "order": 10, + "product": 8, + "quantity": 2 + } + }, + { + "model": "order.orderproduct", + "pk": 27, + "fields": { + "order": 1, + "product": 4, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 28, + "fields": { + "order": 3, + "product": 6, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 29, + "fields": { + "order": 8, + "product": 13, + "quantity": 1 + } + }, + { + "model": "order.orderproduct", + "pk": 30, + "fields": { + "order": 2, + "product": 3, + "quantity": 1 + } + } +] \ No newline at end of file diff --git a/essenza/order/tests.py b/essenza/order/tests.py index 7ce503c..a39b155 100644 --- a/essenza/order/tests.py +++ b/essenza/order/tests.py @@ -1,3 +1 @@ -from django.test import TestCase - # Create your tests here. diff --git a/essenza/product/admin.py b/essenza/product/admin.py index 3617350..60f93f5 100644 --- a/essenza/product/admin.py +++ b/essenza/product/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin -from .models import Category, Product + +from .models import Product # Register your models here. admin.site.register(Product) diff --git a/essenza/product/apps.py b/essenza/product/apps.py index 235a333..c86dab3 100644 --- a/essenza/product/apps.py +++ b/essenza/product/apps.py @@ -2,5 +2,5 @@ class ProductConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'product' + default_auto_field = "django.db.models.BigAutoField" + name = "product" diff --git a/essenza/product/forms.py b/essenza/product/forms.py new file mode 100644 index 0000000..c9369a9 --- /dev/null +++ b/essenza/product/forms.py @@ -0,0 +1,18 @@ +from django import forms + +from .models import Product + + +class ProductForm(forms.ModelForm): + class Meta: + model = Product + fields = [ + "name", + "description", + "category", + "brand", + "price", + "photo", + "stock", + "is_active", + ] diff --git a/essenza/product/models.py b/essenza/product/models.py index b2cfaaf..cb526a4 100644 --- a/essenza/product/models.py +++ b/essenza/product/models.py @@ -1,22 +1,23 @@ from django.db import models + # Create your models here. class Category(models.TextChoices): - MAQUILLAJE = 'maquillaje', 'Maquillaje' - TRATAMIENTO = 'tratamiento', 'Tratamiento' - CABELLO = 'cabello', 'Cabello' - PERFUME = 'perfume', 'Perfume' + MAQUILLAJE = "maquillaje", "Maquillaje" + TRATAMIENTO = "tratamiento", "Tratamiento" + CABELLO = "cabello", "Cabello" + PERFUME = "perfume", "Perfume" + class Product(models.Model): name = models.CharField(max_length=255) description = models.TextField() - categoria = models.CharField(max_length=20, choices=Category.choices) + category = models.CharField(max_length=20, choices=Category.choices) brand = models.CharField(max_length=255) price = models.DecimalField(max_digits=10, decimal_places=2) - foto = models.ImageField(upload_to='profile_pics/', null=True, blank=True) + photo = models.ImageField(upload_to="products/", null=True, blank=True) stock = models.IntegerField() is_active = models.BooleanField(default=False) def __str__(self): return self.name - diff --git a/essenza/product/sample/sample.json b/essenza/product/sample/sample.json new file mode 100644 index 0000000..260c486 --- /dev/null +++ b/essenza/product/sample/sample.json @@ -0,0 +1,282 @@ +[ + { + "model": "product.product", + "pk": 1, + "fields": { + "name": "Maquillaje Base", + "description": "Base líquida para rostro, de acabado mate, 30ml.", + "category": "maquillaje", + "brand": "L'Oréal", + "price": 19.99, + "photo": "products/maquillaje_base.jpg", + "stock": 50, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 2, + "fields": { + "name": "Shampoo Reconstructivo", + "description": "Shampoo nutritivo para cabellos dañados, 500ml.", + "category": "tratamiento", + "brand": "Pantene", + "price": 6.99, + "photo": "products/shampoo_reconstructivo.jpg", + "stock": 100, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 3, + "fields": { + "name": "Secador de Pelo", + "description": "Secador de pelo con 3 niveles de temperatura, 2000W.", + "category": "cabello", + "brand": "Braun", + "price": 45.99, + "photo": "products/secador_pelo.jpg", + "stock": 30, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 4, + "fields": { + "name": "Perfume Floral", + "description": "Perfume con notas de jazmín y rosa, 100ml.", + "category": "perfume", + "brand": "Chanel", + "price": 79.99, + "photo": "products/perfume_floral.jpg", + "stock": 20, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 5, + "fields": { + "name": "Crema Hidratante", + "description": "Crema hidratante para piel seca, 50ml.", + "category": "tratamiento", + "brand": "Nivea", + "price": 12.99, + "photo": "products/crema_hidratante.jpg", + "stock": 150, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 6, + "fields": { + "name": "Rizador de Pelo", + "description": "Rizador de pelo con control de temperatura, 25mm.", + "category": "cabello", + "brand": "Remington", + "price": 29.99, + "photo": "products/rizador_pelo.jpg", + "stock": 40, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 7, + "fields": { + "name": "Gel Antibacterial", + "description": "Gel antibacterial para manos, 250ml.", + "category": "tratamiento", + "brand": "Dettol", + "price": 4.99, + "photo": "products/gel_antibacterial.jpg", + "stock": 200, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 8, + "fields": { + "name": "Shampoo Anticaspa", + "description": "Shampoo anticaspa para cuero cabelludo sensible, 400ml.", + "category": "tratamiento", + "brand": "Head & Shoulders", + "price": 7.99, + "photo": "products/shampoo_anticaspa.jpg", + "stock": 90, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 9, + "fields": { + "name": "Aceite Capilar", + "description": "Aceite nutritivo para el cabello, 150ml.", + "category": "tratamiento", + "brand": "Argan Oil", + "price": 15.99, + "photo": "products/aceite_capilar.jpg", + "stock": 60, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 10, + "fields": { + "name": "Tinte de Cabello", + "description": "Tinte permanente para cabello, color castaño claro.", + "category": "cabello", + "brand": "Garnier", + "price": 8.99, + "photo": "products/tinte_cabello.jpg", + "stock": 110, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 11, + "fields": { + "name": "Mascarilla Facial", + "description": "Mascarilla hidratante para todo tipo de piel, 100ml.", + "category": "tratamiento", + "brand": "L'Oréal", + "price": 18.99, + "photo": "products/mascarilla_facial.jpg", + "stock": 80, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 12, + "fields": { + "name": "Shampoo Voluminizador", + "description": "Shampoo voluminizador para cabellos finos, 300ml.", + "category": "tratamiento", + "brand": "TRESemmé", + "price": 5.99, + "photo": "products/shampoo_voluminizador.jpg", + "stock": 120, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 13, + "fields": { + "name": "Laca de Pelo", + "description": "Laca fijadora para todo el día, 400ml.", + "category": "cabello", + "brand": "Schwarzkopf", + "price": 10.99, + "photo": "products/laca_pelo.jpg", + "stock": 70, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 14, + "fields": { + "name": "Crema Solar", + "description": "Protección solar SPF 50+, 200ml.", + "category": "tratamiento", + "brand": "Hawaiian Tropic", + "price": 14.99, + "photo": "products/crema_solar.jpg", + "stock": 40, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 15, + "fields": { + "name": "Crema Antiedad", + "description": "Crema antiarrugas para el rostro, 50ml.", + "category": "tratamiento", + "brand": "Olay", + "price": 25.99, + "photo": "products/crema_antiedad.jpg", + "stock": 30, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 16, + "fields": { + "name": "Desodorante", + "description": "Desodorante en barra, 75g.", + "category": "tratamiento", + "brand": "Dove", + "price": 3.99, + "photo": "products/desodorante.jpg", + "stock": 150, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 17, + "fields": { + "name": "Toallitas Desmaquillantes", + "description": "Toallitas para desmaquillar, 25 unidades.", + "category": "tratamiento", + "brand": "Neutrogena", + "price": 4.49, + "photo": "products/toallitas_desmaquillantes.jpg", + "stock": 90, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 18, + "fields": { + "name": "Pincel de Maquillaje", + "description": "Pincel para base líquida, cerdas suaves.", + "category": "maquillaje", + "brand": "Real Techniques", + "price": 12.99, + "photo": "products/pincel_maquillaje.jpg", + "stock": 110, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 19, + "fields": { + "name": "Crema para Pies", + "description": "Crema reparadora para pies agrietados, 100ml.", + "category": "tratamiento", + "brand": "Eucerin", + "price": 9.99, + "photo": "products/crema_pies.jpg", + "stock": 80, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 20, + "fields": { + "name": "Limpieza Facial", + "description": "Gel limpiador facial suave, 200ml.", + "category": "tratamiento", + "brand": "Neutrogena", + "price": 7.49, + "photo": "products/limpieza_facial.jpg", + "stock": 130, + "is_active": true + } + } +] diff --git a/essenza/product/tests.py b/essenza/product/tests.py index 7ce503c..6bf1658 100644 --- a/essenza/product/tests.py +++ b/essenza/product/tests.py @@ -1,3 +1,634 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.contrib.messages import get_messages # Para probar mensajes from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from order.models import Order, OrderProduct + +from .models import Category, Product + +User = get_user_model() + + +class DashboardViewLogicTests(TestCase): + def setUp(self): + self.dashboard_url = reverse("dashboard") + self.login_url = reverse("login") + self.now = timezone.now() + + # --- Usuarios (Usando tus roles 'admin' y 'user') --- + self.admin_user = User.objects.create_user( + username="admin", email="admin@test.com", password="pass", role="admin" + ) + self.regular_user = User.objects.create_user( + username="user", email="user@test.com", password="pass", role="user" + ) + + # --- Productos --- + self.p_30_day = Product.objects.create( + name="Producto 30 Días", + description="Test Desc", + category=Category.MAQUILLAJE, + brand="TestBrand", + price=10.00, + stock=10, + is_active=True, + ) + self.p_1_year = Product.objects.create( + name="Producto 1 Año", + description="Test Desc", + category=Category.CABELLO, + brand="TestBrand", + price=20.00, + stock=20, + is_active=True, + ) + self.p_stock = Product.objects.create( + name="Producto Stock Alto", + description="Test Desc", + category=Category.PERFUME, + brand="TestBrand", + price=30.00, + stock=999, + is_active=True, + ) + self.p_stock_low = Product.objects.create( + name="Producto Stock Bajo", + description="Test Desc", + category=Category.TRATAMIENTO, + brand="TestBrand", + price=40.00, + stock=1, + is_active=True, + ) + self.p_inactive = Product.objects.create( + name="Producto Inactivo", + description="Test Desc", + category=Category.MAQUILLAJE, + brand="TestBrand", + price=50.00, + stock=1000, + is_active=False, + ) + + # --- 1. Tests de Permisos (test_func) --- + + def test_anonymous_user_gets_200(self): + """Prueba que los anónimos pueden ver la vista.""" + resp = self.client.get(self.dashboard_url) + self.assertEqual(resp.status_code, 200) + self.assertTemplateUsed(resp, "product/dashboard.html") + + def test_regular_user_gets_200(self): + """Prueba que los usuarios 'user' pueden ver la vista.""" + self.client.login(email="user@test.com", password="pass") + resp = self.client.get(self.dashboard_url) + self.assertEqual(resp.status_code, 200) + self.assertTemplateUsed(resp, "product/dashboard.html") + + def test_admin_user_is_forbidden(self): + """Prueba que los 'admin' son bloqueados.""" + self.client.login(email="admin@test.com", password="pass") + resp = self.client.get(self.dashboard_url) + self.assertEqual(resp.status_code, 403) + + # --- 2. Tests de Lógica de Negocio (método get) --- + + def test_logic_branch_1_shows_30_day_products(self): + """ + Prueba el primer 'if': Muestra productos de 30 días. + Ignora ventas más antiguas aunque sean mayores. + """ + # 1. Crear ventas recientes (hace 10 días) + order_recent = Order.objects.create( + user=self.regular_user, + address="Test Address 1", + placed_at=self.now + - timezone.timedelta(days=10), # <-- Controlamos la fecha + ) + OrderProduct.objects.create( + order=order_recent, product=self.p_30_day, quantity=100 + ) + + # 2. Crear ventas antiguas (hace 100 días) + order_old = Order.objects.create( + user=self.regular_user, + address="Test Address 2", + placed_at=self.now - timezone.timedelta(days=100), + ) + OrderProduct.objects.create( + order=order_old, + product=self.p_1_year, + quantity=500, # Más ventas, pero antiguo + ) + + resp = self.client.get(self.dashboard_url) + products_in_context = list(resp.context["products"]) + + # ASERCIÓN: Solo debe aparecer el producto de 30 días + self.assertEqual(len(products_in_context), 1) + self.assertEqual(products_in_context[0], self.p_30_day) + self.assertEqual(products_in_context[0].total_quantity, 100) + + def test_logic_branch_2_falls_back_to_1_year_products(self): + """ + Prueba el segundo 'if': Falla 30 días, muestra 1 año. + Ignora ventas de hace más de 1 año. + """ + # 1. NO crear ventas recientes + + # 2. Crear ventas antiguas (hace 100 días) + order_old = Order.objects.create( + user=self.regular_user, + address="Test Address 1", + placed_at=self.now - timezone.timedelta(days=100), + ) + OrderProduct.objects.create( + order=order_old, product=self.p_1_year, quantity=500 + ) + + # 3. Crear ventas MUY antiguas (hace 400 días) - debe ignorarse + order_ancient = Order.objects.create( + user=self.regular_user, + address="Test Address 2", + placed_at=self.now - timezone.timedelta(days=400), + ) + OrderProduct.objects.create( + order=order_ancient, product=self.p_30_day, quantity=999 + ) + + resp = self.client.get(self.dashboard_url) + products_in_context = list(resp.context["products"]) + + # ASERCIÓN: Solo debe aparecer el producto de 1 año + self.assertEqual(len(products_in_context), 1) + self.assertEqual(products_in_context[0], self.p_1_year) + self.assertEqual(products_in_context[0].total_quantity, 500) + + def test_logic_branch_3_falls_back_to_stock_products(self): + """ + Prueba el tercer 'if': Falla 1 año, muestra por stock. + Ignora ventas de productos inactivos. + """ + # 1. NO crear ventas recientes + # 2. NO crear ventas en el último año + + # 3. Crear ventas MUY antiguas (hace 400 días) - para forzar el fallback + order_ancient = Order.objects.create( + user=self.regular_user, + address="Test Address 1", + placed_at=self.now - timezone.timedelta(days=400), + ) + OrderProduct.objects.create( + order=order_ancient, product=self.p_30_day, quantity=999 + ) + + # 4. Crear ventas de productos INACTIVOS (deben ignorarse siempre) + order_inactive = Order.objects.create( + user=self.regular_user, + address="Test Address 2", + placed_at=self.now - timezone.timedelta(days=10), # Reciente, pero inactivo + ) + OrderProduct.objects.create( + order=order_inactive, product=self.p_inactive, quantity=5000 + ) + + resp = self.client.get(self.dashboard_url) + products_in_context = list(resp.context["products"]) + + # ASERCIÓN: Debe mostrar los productos activos por stock descendente + # p_stock (999) > p_1_year (20) > p_30_day (10) > p_stock_low (1) + # p_inactive (1000) debe ser ignorado. + + self.assertIn(self.p_stock, products_in_context) + self.assertIn(self.p_1_year, products_in_context) + self.assertNotIn(self.p_inactive, products_in_context) # Clave + + # Comprobar el orden por stock + self.assertEqual(products_in_context[0], self.p_stock) # stock 999 + self.assertEqual(products_in_context[1], self.p_1_year) # stock 20 + self.assertEqual(products_in_context[2], self.p_30_day) # stock 10 + self.assertEqual(products_in_context[3], self.p_stock_low) # stock 1 + + # Comprobar que no hay 'total_quantity' (viene de la consulta de stock) + self.assertFalse(hasattr(products_in_context[0], "total_quantity")) + + def test_logic_branch_4_handles_empty_database(self): + """ + Prueba el caso límite final: No hay productos activos en la BBDD. + La vista debe devolver una lista vacía, no romperse. + """ + + # 1. Borrar TODOS los productos creados en el setUp + # (Esto deja la BBDD sin productos activos) + Product.objects.all().delete() + + # Cargar la vista + resp = self.client.get(self.dashboard_url) + self.assertEqual(resp.status_code, 200) + + # ASERCIÓN: + # El contexto 'products' debe existir, pero estar vacío. + self.assertIn("products", resp.context) + products_in_context = list(resp.context["products"]) + + self.assertEqual(len(products_in_context), 0) + + +class ProductCRUDTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="user", + email="user@example.com", + password="pass1234", + role="user", + ) + # Crear usuario admin + self.admin = User.objects.create_user( + username="admin", + email="admin@example.com", + password="pass1234", + role="admin", + ) + # Crear producto inicial + self.product = Product.objects.create( + name="Producto Test", + description="Descripción", + brand="Marca X", + price=10, + photo=None, + stock=5, + category="maquillaje", + is_active=True, + ) + # URLs + self.list_url = reverse("product_list") + self.detail_url = reverse("product_detail", args=[self.product.pk]) + self.create_url = reverse("product_create") + self.update_url = reverse("product_update", args=[self.product.pk]) + self.delete_url = reverse("product_delete", args=[self.product.pk]) + + # --------------------------------------------------- + # TESTS PARA USUARIO NO AUTENTICADO + # --------------------------------------------------- + + def test_list_requires_login(self): + resp = self.client.get(self.list_url) + self.assertEqual(resp.status_code, 302) + self.assertIn("/login", resp.url) + + def test_detail_requires_login(self): + resp = self.client.get(self.detail_url) + self.assertEqual(resp.status_code, 302) + self.assertIn("/login", resp.url) + + def test_create_requires_login(self): + resp = self.client.get(self.create_url) + self.assertEqual(resp.status_code, 302) + + # --------------------------------------------------- + # TESTS PARA USUARIO AUTENTICADO PERO NO ADMIN + # --------------------------------------------------- + + def test_user_cannot_access_list(self): + self.client.login(username="user", password="pass1234") + resp = self.client.get(self.list_url) + self.assertEqual(resp.status_code, 302) # Redirigido por permisos + + def test_user_cannot_access_detail(self): + self.client.login(username="user", password="pass1234") + resp = self.client.get(self.detail_url) + self.assertEqual(resp.status_code, 302) + + def test_user_cannot_access_create(self): + self.client.login(username="user", password="pass1234") + resp = self.client.get(self.create_url) + self.assertEqual(resp.status_code, 302) + + # --------------------------------------------------- + # TESTS PARA USUARIO ADMIN (PERMISO TOTAL) + # --------------------------------------------------- + + def test_admin_can_access_list(self): + self.client.force_login(self.admin) + url = reverse("product_list") + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Producto Test") + + def test_admin_can_access_detail(self): + self.client.force_login(self.admin) + url = reverse("product_detail", args=[self.product.pk]) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, self.product.name) + + def test_admin_can_create_product(self): + """Prueba que un admin puede crear un nuevo producto (POST).""" + self.client.force_login(self.admin) + + initial_count = Product.objects.count() + + data = { + "name": "Nuevo Producto Creado", + "description": "Creado por el test de admin", + "category": "perfume", + "brand": "NewBrand", + "price": "99.99", + "stock": 100, + "is_active": True, + } + resp = self.client.post(self.create_url, data) + self.assertEqual(resp.status_code, 302) + self.assertEqual(Product.objects.count(), initial_count + 1) + self.assertTrue(Product.objects.filter(name="Nuevo Producto Creado").exists()) + + def test_admin_can_update_product(self): + """Prueba que un admin puede actualizar un producto existente (POST).""" + self.client.force_login(self.admin) + + updated_name = "Nombre Actualizado Admin" + updated_price = "15.50" + + data = { + "name": updated_name, + "description": "Descripción actualizada", + "category": "tratamiento", + "brand": self.product.brand, + "price": updated_price, + "stock": 50, + "is_active": False, + } + resp = self.client.post(self.update_url, data) + self.assertEqual(resp.status_code, 302) + + self.assertRedirects(resp, reverse("product_list")) + + self.product.refresh_from_db() + self.assertEqual(self.product.name, updated_name) + self.assertEqual(self.product.price, Decimal(updated_price)) + self.assertFalse(self.product.is_active) + self.assertEqual(self.product.stock, 50) + + def test_admin_can_delete_product(self): + self.client.force_login(self.admin) + url = reverse("product_delete", args=[self.product.pk]) + + # GET renderiza el confirm delete + resp_get = self.client.get(url) + self.assertEqual(resp_get.status_code, 200) + + # POST borra el producto + resp_post = self.client.post(url) + self.assertEqual(resp_post.status_code, 302) + + self.assertFalse(Product.objects.filter(pk=self.product.pk).exists()) + + +class CatalogViewTests(TestCase): + @classmethod + def setUpTestData(cls): + # Producto visible en el catálogo (is_active = True) + cls.active_product = Product.objects.create( + name="Producto Activo", + description="Descripción producto activo", + category=Category.MAQUILLAJE, + brand="Marca A", + price=Decimal("19.99"), + stock=10, + is_active=True, + ) + + # Producto NO visible en el catálogo (is_active = False) + cls.inactive_product = Product.objects.create( + name="Producto Inactivo", + description="Descripción producto inactivo", + category=Category.TRATAMIENTO, + brand="Marca B", + price=Decimal("9.99"), + stock=5, + is_active=False, + ) + + def test_catalog_url_status_code(self): + """La URL del catálogo responde con 200.""" + url = reverse("catalog") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_catalog_uses_correct_template(self): + """El catálogo usa la plantilla correcta.""" + url = reverse("catalog") + response = self.client.get(url) + self.assertTemplateUsed(response, "product/catalog.html") + + def test_catalog_shows_only_active_products(self): + """ + En el catálogo solo aparecen productos activos + (is_active=True). + """ + url = reverse("catalog") + response = self.client.get(url) + + products = response.context["products"] + + self.assertIn(self.active_product, products) + self.assertNotIn(self.inactive_product, products) + + +class CatalogDetailViewTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.active_product = Product.objects.create( + name="Detalle Activo", + description="Descripción detalle activo", + category=Category.CABELLO, + brand="Marca C", + price=Decimal("29.99"), + stock=20, + is_active=True, + ) + + cls.inactive_product = Product.objects.create( + name="Detalle Inactivo", + description="Descripción detalle inactivo", + category=Category.PERFUME, + brand="Marca D", + price=Decimal("39.99"), + stock=0, + is_active=False, + ) + + def test_catalog_detail_status_code_and_template(self): + """ + El detalle de un producto activo devuelve 200 y usa + la plantilla de detalle para usuario. + """ + url = reverse("catalog_detail", args=[self.active_product.pk]) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "product/detail_user.html") + self.assertContains(response, self.active_product.name) + + def test_catalog_detail_returns_404_for_inactive_product(self): + """ + Si el producto está inactivo, el detalle del catálogo + debe devolver 404. + """ + url = reverse("catalog_detail", args=[self.inactive_product.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_catalog_detail_returns_404_for_nonexistent_product(self): + """Si el producto no existe, también 404.""" + url = reverse("catalog_detail", args=[9999]) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + +class StockTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user( + username="user", + email="user@example.com", + password="pass1234", + role="user", + ) + cls.admin = User.objects.create_user( + username="admin", + email="admin@example.com", + password="pass1234", + role="admin", + ) + + cls.product_high = Product.objects.create( + name="Producto Alto", stock=20, price=10 + ) + cls.product_low = Product.objects.create( + name="Producto Bajo", stock=5, price=10 + ) + cls.product_out = Product.objects.create( + name="Producto Agotado", stock=0, price=10 + ) + + cls.stock_url = reverse("stock") + cls.login_url = reverse("login") + cls.dashboard_url = reverse("dashboard") + + # --- TESTS DE ACCESO --- + + def test_anonymous_user_redirects_to_dashboard(self): + resp = self.client.get(self.stock_url) + self.assertEqual(resp.status_code, 302) + self.assertRedirects(resp, self.dashboard_url) + + def test_non_admin_user_redirects_to_dashboard(self): + self.client.login(email=self.user.email, password="pass1234") + resp = self.client.get(self.stock_url) + self.assertEqual(resp.status_code, 302) + self.assertRedirects(resp, self.dashboard_url) + + def test_admin_user_succeeds_get(self): + self.client.login(email=self.admin.email, password="pass1234") + resp = self.client.get(self.stock_url) + self.assertEqual(resp.status_code, 200) + self.assertTemplateUsed(resp, "product/stock.html") + + # --- TESTS DE FUNCIONALIDAD --- + + def test_stock_page_shows_all_products(self): + self.client.login(email=self.admin.email, password="pass1234") + resp = self.client.get(self.stock_url) + + # Comprobamos que aparecen los 3 productos + self.assertEqual(len(resp.context["products"]), 3) + + # Comprobamos el HTML + self.assertContains(resp, "Producto Alto") + self.assertContains( + resp, 'En Stock: 20', html=True + ) + + self.assertContains(resp, "Producto Bajo") + self.assertContains( + resp, 'Stock Bajo: 5', html=True + ) + + self.assertContains(resp, "Producto Agotado") + self.assertContains( + resp, 'Agotado (0)', html=True + ) + + def test_post_admin_updates_stock_successfully(self): + """5. CORRECTO: Un admin puede actualizar el stock (Test 7 en tu código).""" + self.client.login(email=self.admin.email, password="pass1234") + + self.assertEqual(self.product_high.stock, 20) # Stock inicial + + data = {"product_id": self.product_high.pk, "stock": 15} + resp = self.client.post( + self.stock_url, data, follow=True + ) # Se hace una petición POST para actualizar el stock a 15 + + # Comprobamos que volvemos a la página de stock + self.assertEqual(resp.status_code, 200) + self.assertTemplateUsed(resp, "product/stock.html") + + # Comprobamos que la base de datos se actualizó correctamente + self.product_high.refresh_from_db() + self.assertEqual(self.product_high.stock, 15) + + # Comprobamos el mensaje de éxito + messages = list(get_messages(resp.context["request"])) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "Stock de 'Producto Alto' actualizado a 15.") + + def test_post_admin_invalid_product_returns_404(self): + self.client.login(email=self.admin.email, password="pass1234") + data = {"product_id": 999, "stock": 15} # ID 999 no existe + + resp = self.client.post(self.stock_url, data) + self.assertEqual(resp.status_code, 404) + + def test_post_admin_invalid_stock_value_shows_error(self): + self.client.login(email=self.admin.email, password="pass1234") + + # Enviamos un valor de stock no numérico + data = {"product_id": self.product_high.pk, "stock": "abc"} + resp = self.client.post(self.stock_url, data, follow=True) + + # Comprobamos que volvemos a la página de stock + self.assertEqual(resp.status_code, 200) + + # Comprobamos que el stock NO se actualizó + self.product_high.refresh_from_db() + self.assertEqual(self.product_high.stock, 20) + + # Comprobamos el mensaje de error + messages = list(get_messages(resp.context["request"])) + self.assertEqual(len(messages), 1) + self.assertEqual( + str(messages[0]), "El valor de stock 'abc' no es un número válido." + ) + + def test_post_admin_negative_stock_value_shows_error(self): + self.client.login(email=self.admin.email, password="pass1234") + + # Enviamos un valor de stock negativo y comprobamos el error + data = {"product_id": self.product_high.pk, "stock": "-5"} + resp = self.client.post(self.stock_url, data, follow=True) + + self.assertEqual(resp.status_code, 200) + + self.product_high.refresh_from_db() + self.assertEqual(self.product_high.stock, 20) # No cambia -# Create your tests here. + messages = list(get_messages(resp.context["request"])) + self.assertEqual(len(messages), 1) + self.assertEqual( + str(messages[0]), "El valor de stock '-5' no es un número válido." + ) diff --git a/essenza/product/urls.py b/essenza/product/urls.py new file mode 100644 index 0000000..dfa067d --- /dev/null +++ b/essenza/product/urls.py @@ -0,0 +1,17 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.urls import path + +import product.views as views + +urlpatterns = [ + path("stock/", views.StockView.as_view(), name="stock"), + path("", views.ProductListView.as_view(), name="product_list"), + path("create/", views.ProductCreateView.as_view(), name="product_create"), + path("/", views.ProductDetailView.as_view(), name="product_detail"), + path("/edit/", views.ProductUpdateView.as_view(), name="product_update"), + path("/delete/", views.ProductDeleteView.as_view(), name="product_delete"), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/essenza/product/views.py b/essenza/product/views.py index a99ceba..783a03f 100644 --- a/essenza/product/views.py +++ b/essenza/product/views.py @@ -1,6 +1,189 @@ -from django.shortcuts import render +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.db.models import Sum +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone from django.views import View -class EscaparateView(View): +from .forms import ProductForm +from .models import Product + + +class BaseView(View): + def get(self, request): + return render(request, "base.html") + + +class DashboardView(UserPassesTestMixin, View): + template_name = "product/dashboard.html" + + # Todos excepto los administradores pueden acceder a esta vista + def test_func(self): + return ( + not self.request.user.is_authenticated or self.request.user.role != "admin" + ) + + def get(self, request, *args, **kwargs): + month_ago = timezone.now() - timezone.timedelta(days=30) + year_ago = timezone.now() - timezone.timedelta(days=365) + + def get_top_selling_products(since): + # Paso 1: Definir el filtro base. + base_query = Product.objects.filter( + is_active=True, product_orders__order__placed_at__gte=since + ) + # Paso 2: Anotar (calcular) el total vendido para esos productos. + query_with_totals = base_query.annotate( + total_quantity=Sum("product_orders__quantity") + ) + # Paso 3: Filtrar de nuevo, sobre el campo calculado. + filtered_query = query_with_totals.filter(total_quantity__gt=0) + # Paso 4: Ordenar (descendente). + ordered_query = filtered_query.order_by("-total_quantity") + top_products = ordered_query[:10] + return top_products + + products = get_top_selling_products(since=month_ago) + if not products.exists(): + products = get_top_selling_products(since=year_ago) + if not products.exists(): + products = Product.objects.filter(is_active=True).order_by("-stock")[:10] + + return render(request, self.template_name, {"products": products}) + + +class StockView(LoginRequiredMixin, UserPassesTestMixin, View): + # Solo los administradores pueden acceder a esta vista + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + + # Redirige a 'dashboard' si no pasa el test_func + def handle_no_permission(self): + return redirect("dashboard") + + def get(self, request): + # Carga y muestra todos los productos ordenados por nombre + products = Product.objects.all().order_by("name") + return render(request, "product/stock.html", {"products": products}) + + def post(self, request): + # Coge datos del formulario para actualizar stock + product_id = request.POST.get("product_id") + stock_value = request.POST.get("stock") # Renombrado para claridad + + product = get_object_or_404(Product, pk=product_id) + + try: + # Comprobamos si el valor es un número + new_stock = int(stock_value or 0) + if new_stock < 0: + # No permitir stock negativo + raise ValueError("El stock no puede ser negativo") + + product.stock = new_stock + product.save(update_fields=["stock"]) + messages.success( + request, f"Stock de '{product.name}' actualizado a {new_stock}." + ) + + except (ValueError, TypeError): + messages.error( + request, f"El valor de stock '{stock_value}' no es un número válido." + ) + + # Recarga la misma página + return redirect("stock") + + +class ProductListView(LoginRequiredMixin, UserPassesTestMixin, View): + template_name = "product/list.html" + + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + + def get(self, request): + products = Product.objects.all() + return render(request, self.template_name, {"products": products}) + + +class ProductDetailView(LoginRequiredMixin, UserPassesTestMixin, View): + template_name = "product/detail.html" + + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + + def get(self, request, pk): + product = get_object_or_404(Product, pk=pk) + return render(request, self.template_name, {"product": product}) + + +class ProductCreateView(LoginRequiredMixin, UserPassesTestMixin, View): + template_name = "product/form.html" + form_class = ProductForm + + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + + def get(self, request): + form = self.form_class() + return render(request, self.template_name, {"form": form}) + + def post(self, request): + form = self.form_class(request.POST, request.FILES) + if form.is_valid(): + form.save() + return redirect("product_list") + return render(request, self.template_name, {"form": form}) + + +class ProductUpdateView(LoginRequiredMixin, UserPassesTestMixin, View): + template_name = "product/form.html" + form_class = ProductForm + + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + + def get(self, request, pk): + product = get_object_or_404(Product, pk=pk) + form = self.form_class(instance=product) + return render(request, self.template_name, {"form": form, "product": product}) + + def post(self, request, pk): + product = get_object_or_404(Product, pk=pk) + form = self.form_class(request.POST, request.FILES, instance=product) + if form.is_valid(): + form.save() + return redirect("product_list") + return render(request, self.template_name, {"form": form, "product": product}) + + +class ProductDeleteView(LoginRequiredMixin, UserPassesTestMixin, View): + template_name = "product/confirm_delete.html" + + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + + def get(self, request, pk): + product = get_object_or_404(Product, pk=pk) + return render(request, self.template_name, {"product": product}) + + def post(self, request, pk): + product = get_object_or_404(Product, pk=pk) + product.delete() + return redirect("product_list") + + +class CatalogView(View): + template_name = "product/catalog.html" + def get(self, request): - return render(request, 'product/escaparate.html') + products = Product.objects.filter(is_active=True) + return render(request, self.template_name, {"products": products}) + + +class CatalogDetailView(View): + template_name = "product/detail_user.html" + + def get(self, request, pk): + product = get_object_or_404(Product, pk=pk, is_active=True) + return render(request, self.template_name, {"product": product}) diff --git a/essenza/profile_pics/img1.jpg b/essenza/profile_pics/img1.jpg deleted file mode 100644 index 64ca02d..0000000 Binary files a/essenza/profile_pics/img1.jpg and /dev/null differ diff --git a/essenza/requirements.txt b/essenza/requirements.txt new file mode 100644 index 0000000..e81440b --- /dev/null +++ b/essenza/requirements.txt @@ -0,0 +1,5 @@ +asgiref==3.10.0 +Django==5.2.8 +pillow==12.0.0 +sqlparse==0.5.3 +tzdata==2025.2 diff --git a/essenza/static/images/default_product.png b/essenza/static/images/default_product.png new file mode 100644 index 0000000..5f313f0 Binary files /dev/null and b/essenza/static/images/default_product.png differ diff --git a/essenza/static/images/default_user.png b/essenza/static/images/default_user.png new file mode 100644 index 0000000..ada81e2 Binary files /dev/null and b/essenza/static/images/default_user.png differ diff --git a/essenza/static/images/img2.avif b/essenza/static/images/img2.avif deleted file mode 100644 index 5df7da0..0000000 Binary files a/essenza/static/images/img2.avif and /dev/null differ diff --git a/essenza/templates/base.html b/essenza/templates/base.html new file mode 100644 index 0000000..81e3c78 --- /dev/null +++ b/essenza/templates/base.html @@ -0,0 +1,313 @@ +{% load static %} + + + + + {% block title %}Essenza{% endblock %} + + + + + {% block extra_head %}{% endblock %} + + + +
+ + + + + + +
+ + {% block content %}{% endblock %} + + + + {% block extra_js %}{% endblock %} + + diff --git a/essenza/templates/info/info.html b/essenza/templates/info/info.html new file mode 100644 index 0000000..c5c12d5 --- /dev/null +++ b/essenza/templates/info/info.html @@ -0,0 +1,194 @@ + + + + + Condiciones Legales e Información de Essenza + + + + Volver + +
+

+ Información Legal y Condiciones de Venta de Essenza +

+
+ +

1. Identificación y Aviso Legal

+

+ Essenza S.L. es la denominación social y el nombre comercial de la tienda + online dedicada a la venta de productos de cosmética y cuidado personal, + rigiéndose su actividad por la legislación española vigente. En + cumplimiento del deber de información recogido en la Ley 34/2002 de + Servicios de la Sociedad de la Información y Comercio Electrónico + (LSSI-CE) y en el Real Decreto 85/2018 sobre productos cosméticos, a + continuación se detallan los datos de identificación del titular de este + sitio web: +

+
    +
  • Denominación Social: Essenza S.L.
  • +
  • C.I.F.: B87654321
  • +
  • Domicilio Social: C/Mateos Gago, Sevilla (España)
  • +
  • + Correo Electrónico de Contacto: + info@essenza.com +
  • +
  • + Objeto Social: Comercio minorista de productos cosméticos y de + perfumería, enfocado en cosmética natural de alta calidad. +
  • +
+ +

2. Condiciones Generales de Venta (CGV)

+

+ Estas condiciones regulan la relación contractual de compraventa entre + Essenza y usted desde el momento en que realiza un pedido en nuestra web. + La formalización de un pedido implica la lectura, comprensión y aceptación + expresa de estas Condiciones Generales de Venta en su totalidad, siendo de + obligado cumplimiento para ambas partes. +

+ +

2.1. Información, Trazabilidad y Calidad del Producto

+

+ Essenza garantiza que todos sus productos han pasado rigurosos controles + de calidad y cumplen con los requisitos de seguridad establecidos por la + normativa europea (Reglamento CE 1223/2009). La información detallada de + ingredientes, modo de uso, precauciones y el Periodo Después de la + Apertura (PAO) se encuentra de forma clara y accesible en la ficha de cada + producto y en su etiquetado. Mantenemos un estricto control de + trazabilidad para garantizar la seguridad de todos los artículos de + cosmética que comercializamos. +

+ +

2.2. Proceso de Compra y Precio

+

+ El proceso de compra se considera finalizado y vinculante una vez que el + pago ha sido confirmado. Todos los precios mostrados en el sitio web están + expresados en euros (€) e incluyen el Impuesto sobre el Valor Añadido + (I.V.A.) legalmente aplicable. Los gastos de envío serán calculados en + base al peso y la dirección de entrega, siendo detallados y aceptados por + el Cliente antes de la confirmación final de la compra. +

+ +

2.3. Envíos, Plazos y Riesgos

+
    +
  • + Plazo: Los plazos de entrega estimados son de 3-5 días + laborables y se contabilizan desde la confirmación del pago. +
  • +
  • + Riesgo: El riesgo de pérdida o daño de los productos se + transmite al Cliente en el momento en que usted o un tercero indicado + por usted adquiera la posesión material de los bienes. +
  • +
+ +

2.4. Política de Desistimiento y Devoluciones

+

+ De acuerdo con la Ley General para la Defensa de los Consumidores y + Usuarios, el Cliente dispone de 14 días naturales desde la recepción del + pedido para ejercer su derecho de desistimiento. Condición Específica para + Cosmética: Por estrictas razones de higiene, seguridad y protección de la + salud, no se admitirá la devolución de productos cosméticos que hayan sido + abiertos, desprecintados o usados. En caso de desistimiento válido, los + costes directos de la devolución (envío de vuelta) correrán a cargo del + cliente, salvo si la causa es un producto defectuoso o un error de + Essenza. +

+ +

3. Política de Privacidad (RGPD)

+

+ En Essenza, su privacidad es nuestra prioridad. Los datos personales + recabados (nombre, dirección, email, datos de pago) a través de la web son + tratados bajo la legitimación de la ejecución de un contrato (para + gestionar su pedido) o el consentimiento (para el newsletter). Nos + comprometemos a no ceder sus datos a terceros, salvo obligación legal. + Usted puede ejercer en todo momento sus derechos de Acceso, Rectificación, + Supresión, Limitación, Portabilidad y Oposición (ARSLOP) enviando una + solicitud a info@essenza.com. +

+ +
+

+ © 2025 Essenza S.L. Todos los derechos reservados. El uso de este + sitio implica la aceptación de sus Condiciones Legales. +

+
+ + diff --git a/essenza/templates/product/catalog.html b/essenza/templates/product/catalog.html new file mode 100644 index 0000000..5675991 --- /dev/null +++ b/essenza/templates/product/catalog.html @@ -0,0 +1,191 @@ +{% extends "base.html" %} + + + +{% load static %} {% load humanize %} + +{% block title %}Catálogo · Essenza{% endblock %} + +{% block content %} + + + + + + +
+

Catálogo Essenza

+

Explora nuestra selección de productos mejor valorados

+
+ + +
+ + + + + +
+ + +
+ {% for product in products %} +
+ {% if product.photo %} + {{ product.name }} + {% else %} + {{ product.name }} + {% endif %} + +

{{ product.name }}

+

{{ product.price }} €

+ {{ product.get_category_display }} +
+ {% endfor %} +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/essenza/templates/product/confirm_delete.html b/essenza/templates/product/confirm_delete.html new file mode 100644 index 0000000..eba4f9e --- /dev/null +++ b/essenza/templates/product/confirm_delete.html @@ -0,0 +1,151 @@ + + + + + + Confirmar Borrado - Essenza + + + +
+ ⚠️ +

¿Está seguro de que desea borrar este producto?

+ + +
+ Producto: +
{{ product.name }}
+
+ + +

+ Esta acción es irreversible y no se puede deshacer. +

+ + +
+ {% csrf_token %} +
+ + ← No, cancelar +
+
+ + +
+ 💡 Si borras este producto, se eliminará toda la información asociada. +
+
+ + + + diff --git a/essenza/templates/product/dashboard.html b/essenza/templates/product/dashboard.html new file mode 100644 index 0000000..d265d26 --- /dev/null +++ b/essenza/templates/product/dashboard.html @@ -0,0 +1,229 @@ +{% extends "base.html" %} + +{% load static %} {% load humanize %} + +{% block title %}Dashboard · Essenza{% endblock %} + +{% block content %} + + + + + + {% if products|length > 1 %} +
+

Top Bestsellers

+

Una cuidada selección de los productos estrella de la temporada.

+
+ {% endif %} + +
+ {% if products %} {% for p in products %} +
+ + + {% if p.photo %} + + {% else %} + + {% endif %} + +
+
{{ p.name }}
+
{{ p.price|floatformat:2 }} €
+
{{ p.description }}
+
+
+ {% endfor %} {% else %} +

+ No hay productos disponibles en este momento. +

+ {% endif %} +
+ + + + +{% endblock %} diff --git a/essenza/templates/product/detail.html b/essenza/templates/product/detail.html new file mode 100644 index 0000000..5b05ce9 --- /dev/null +++ b/essenza/templates/product/detail.html @@ -0,0 +1,188 @@ +{% extends "base.html" %} + +{% load static %} + + +{% block title %}{{ product.name }} - Essenza{% endblock %} + +{% block content %} + +
+
+
+
+
+ {% if product.photo %} + {{ product.name }} + {% else %} + Foto de producto por defecto + {% endif %} +
+
+

{{ product.name }}

+
{{ product.brand }}
+
€ {{ product.price }}
+ {% if product.get_categoria_display %} + {{ product.get_categoria_display }} + {% endif %} +
+ Stock: {{ product.stock }} unidades +
+
+
+ +
+ Descripción: +

{{ product.description }}

+
+ +
+
+
Estado
+
+ {% if product.is_active %} + ✓ Activo + {% else %} + ✗ Inactivo + {% endif %} +
+
+
+
ID Producto
+
#{{ product.id }}
+
+
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/essenza/templates/product/detail_user.html b/essenza/templates/product/detail_user.html new file mode 100644 index 0000000..1f7b6f2 --- /dev/null +++ b/essenza/templates/product/detail_user.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{{ product.name }} - Essenza{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
+
+ +
+
+ {% if product.photo %} + {{ product.name }} + {% else %} + {{ product.name }} + {% endif %} +
+ +
+

{{ product.name }}

+
{{ product.brand }}
+
€ {{ product.price }}
+ + {% if product.get_categoria_display %} + {{ product.get_categoria_display }} + {% endif %} + +
Stock: {{ product.stock }} unidades
+
+
+ + +
+ Descripción: +

{{ product.description }}

+
+ + +
+
+
Estado
+
+ {% if product.is_active %} + ✓ Activo + {% else %} + ✗ Inactivo + {% endif %} +
+
+
+
ID Producto
+
#{{ product.id }}
+
+
+ + +
+ + + ← Volver +
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/essenza/templates/product/escaparate.html b/essenza/templates/product/escaparate.html deleted file mode 100644 index 5fbbd77..0000000 --- a/essenza/templates/product/escaparate.html +++ /dev/null @@ -1,130 +0,0 @@ -{% load static %} - - - - - Escaparate · Essenza - - - - - -
-
ESSENZA
- - - - {% if user.is_authenticated %} - -
- {% csrf_token %} - -
- - {% else %} - - - Log in - - - {% endif %} -
- \ No newline at end of file diff --git a/essenza/templates/product/form.html b/essenza/templates/product/form.html new file mode 100644 index 0000000..f59cd89 --- /dev/null +++ b/essenza/templates/product/form.html @@ -0,0 +1,309 @@ + + + + + + {% if form.instance.pk %}Editar{% else %}Crear{% endif %} Producto - Essenza + + + +
+
+

{% if form.instance.pk %}Editar producto{% else %} Crear nuevo producto{% endif %}

+ + + {% if form.non_field_errors %} +
+ Errores: +
    + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} + + +
+ {% csrf_token %} + + + {% for field in form %} + {% if field.name == "is_active" %} +
+ + {% if field.errors %} +
+ {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} +
+ {% elif field.name == "photo" %} +
+ + + {% if form.instance.pk and field.value %} +
+ + +
+ {% endif %} + + + {{ field }} + + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} + {% if field.errors %} +
+ {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} +
+ {% else %} +
+ {{ field.label_tag }} + {{ field }} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} + {% if field.errors %} +
+ {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} +
+ {% endif %} + {% endfor %} + + +
+ + ← Cancelar +
+
+
+
+ + \ No newline at end of file diff --git a/essenza/templates/product/list.html b/essenza/templates/product/list.html new file mode 100644 index 0000000..ff3d14b --- /dev/null +++ b/essenza/templates/product/list.html @@ -0,0 +1,227 @@ +{% extends "base.html" %} + +{% block title %}Lista de Productos · Essenza{% endblock %} + +{% block content %} +{% load static %} + +
+
+

🌸 Productos Essenza

+ + + {% if products %} +
+ {% for product in products %} +
+
+ {% if product.photo %} + {{ product.name }} + {% else %} + Sin imagen + {% endif %} +
+
+
{{ product.name }}
+
{{ product.brand }}
+
€ {{ product.price }}
+
Stock: {{ product.stock }}
+
+ Ver + Editar + Borrar +
+
+
+ {% endfor %} +
+ {% else %} +
+

+ No hay productos disponibles. + ¡Crea uno! +

+
+ {% endif %} + +
+
+{% endblock %} \ No newline at end of file diff --git a/essenza/templates/product/stock.html b/essenza/templates/product/stock.html new file mode 100644 index 0000000..70edfee --- /dev/null +++ b/essenza/templates/product/stock.html @@ -0,0 +1,184 @@ +{% extends 'base.html' %} {% load static %} {% block title %}Stock · +Essenza{%endblock %} {% block content %} + + + + +
+ {% for p in products %} +
+ {% if p.photo %} + {{ p.name }} + {% else %} + + {% endif %} + +
+
{{ p.name }}
+ + {% if p.description %} +
{{ p.description }}
+ {% endif %} {% if p.category %} +
{{ p.get_category_display }}
+ {% endif %} + +
+ {% with stock_value=p.stock|default:0 %} {% if stock_value <= 0 %} + Agotado (0) + {% elif stock_value <= 10 %} + Stock Bajo: {{ stock_value }} + {% else %} + En Stock: {{ stock_value }} + {% endif %} {% endwith %} +
+ + {% if user.is_staff or user.is_superuser %} +
+ {% csrf_token %} + + + +
+ {% endif %} +
+
+ {% endfor %} +
+ + {% endblock %} + diff --git a/essenza/templates/user/confirm_delete_profile.html b/essenza/templates/user/confirm_delete_profile.html new file mode 100644 index 0000000..ead1c09 --- /dev/null +++ b/essenza/templates/user/confirm_delete_profile.html @@ -0,0 +1,111 @@ +{% load static %} + + + + + Eliminar Cuenta · Essenza + + + + + +
+

ESSENZA

+
+

+ Eliminar Cuenta +

+

+ ¿Estás seguro de que quieres eliminar tu cuenta permanentemente? +

+

+ Toda tu información será borrada y esta acción no se puede deshacer. +

+ +
+ {% csrf_token %} + + +
+ + +
+
+ + diff --git a/essenza/templates/user/edit_profile.html b/essenza/templates/user/edit_profile.html new file mode 100644 index 0000000..9eee2cc --- /dev/null +++ b/essenza/templates/user/edit_profile.html @@ -0,0 +1,264 @@ +{% load static %} + + + + + Editar Perfil · Essenza + + + + +
+

ESSENZA

+
+
+ {% csrf_token %} + + + {{ form.first_name }} + + + {{ form.last_name }} + + + {{ form.email }} + + + {{ form.photo }} {% if not form.remove_photo.is_hidden %} +
+ {{ form.remove_photo }} + +
+ {% endif %} {% if form.errors %} +
+ {% for field in form %} {% for error in field.errors %} +

{{ error }}

+ {% endfor %} {% endfor %} {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} {% if form.errors %} +
+

+ ¡El formulario tiene errores! +

+ {% for field in form %} {% if field.errors %} +
+ {{ field.label }}: +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} {% endfor %} {% for error in form.non_field_errors %} +
+ Error general: {{ error }} +
+ {% endfor %} +
+ {% endif %} + + + + +
+
+
+ + + + diff --git a/essenza/templates/user/login.html b/essenza/templates/user/login.html index 5afa3d0..799e58e 100644 --- a/essenza/templates/user/login.html +++ b/essenza/templates/user/login.html @@ -1,95 +1,152 @@ {% load static %} - + - - - Iniciar sesión · Essenza - + + + Iniciar sesión · Essenza + - + body.login-bg { + background-color: #faf7f2; + margin: 0; + font-family: "Segoe UI", Arial, sans-serif; + } + + .page { + min-height: 100dvh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 2rem; + } + + .brand { + color: #c06b3e; + font-size: 48px; + margin-bottom: 10px; + } + + .login-card { + width: min(420px, 88vw); + background: #fff; + border: 2px solid #c06b3e; + border-radius: 16px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.06); + padding: 22px 24px; + } + + label { + display: block; + font-size: 13px; + color: #333; + margin: 10px 0 6px; + } + + input[type="text"], + input[type="email"], + input[type="password"] { + width: 90%; + padding: 10px 12px; + border: 1px solid #eee; + border-radius: 10px; + background: #fafafa; + outline: none; + } + + input:focus { + border-color: #c06b3e; + box-shadow: 0 0 0 3px rgba(192, 107, 62, 0.2); + } - + .btn-primary { + width: 100%; + margin-top: 14px; + padding: 10px 12px; + border-radius: 10px; + background: #c06b3e; + color: #fff; + border: none; + cursor: pointer; + transition: background-color 0.3s; /* Añadida transición */ + } + .btn-primary:hover { + background-color: #a35a34; + } -
-

ESSENZA

+ /* Link para "Aún no tienes cuenta?" */ + .login-link { + margin-top: 15px; + text-align: center; + font-size: 13px; + } - +
+ diff --git a/essenza/templates/user/profile.html b/essenza/templates/user/profile.html new file mode 100644 index 0000000..594cb4e --- /dev/null +++ b/essenza/templates/user/profile.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Mi Perfil · Essenza{% endblock %} + +{% block content %} + + + +
+

ESSENZA

+ +
+ {% if user.photo %} + Foto de perfil + {% else %} + Foto de perfil por defecto + {% endif %} + +
+

Nombre: {{ user.first_name }}

+

Apellidos: {{ user.last_name }}

+

Email: {{ user.email }}

+
+ + Editar mis datos + +
+
+ +{% endblock %} \ No newline at end of file diff --git a/essenza/templates/user/register.html b/essenza/templates/user/register.html index 1ff83b2..728ee75 100644 --- a/essenza/templates/user/register.html +++ b/essenza/templates/user/register.html @@ -1,12 +1,12 @@ {% load static %} - + - - - Registro · Essenza - - - - -
-

ESSENZA

-
-
- {% csrf_token %} - - - {{ form.first_name }} - - - {{ form.last_name }} - - - {{ form.username }} - - - {{ form.email }} - - - {{ form.password1 }} - - - {{ form.password2 }} - - - {{ form.foto }} - - {% if form.errors %} -
- {% for field in form %} - {% for error in field.errors %} -

{{ error }}

- {% endfor %} - {% endfor %} - {% for error in form.non_field_errors %} -

{{ error }}

- {% endfor %} -
- {% endif %} - - - - -
+ } + .return-button { + position: fixed; + top: 20px; + left: 20px; + width: 80px; + height: 30px; + background-color: #c06b3e; + border-radius: 15px; + text-align: center; + line-height: 30px; + font-size: 18px; + font-weight: bold; + color: white; + text-decoration: none; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: background-color 0.3s; + } + .return-button:hover { + background-color: #a35a34; + text-decoration: none; + } + + + + Volver +
+

ESSENZA

+
+
+ {% csrf_token %} + + + {{ form.first_name }} + + + {{ form.last_name }} + + + {{ form.email }} + + + {{ form.password1 }} + + + {{ form.password2 }} + + + {{ form.photo }} {% if form.errors %} +
+ {% for field in form %} {% for error in field.errors %} +

{{ error }}

+ {% endfor %} {% endfor %} {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} + + + + +
+
-
- - \ No newline at end of file + + diff --git a/essenza/user/admin.py b/essenza/user/admin.py index 34516eb..a716404 100644 --- a/essenza/user/admin.py +++ b/essenza/user/admin.py @@ -1,5 +1,7 @@ from django.contrib import admin -from .models import Role, Usuario + +from .models import Usuario + # Register your models here. admin.site.register(Usuario) diff --git a/essenza/user/apps.py b/essenza/user/apps.py index 36cce4c..578292c 100644 --- a/essenza/user/apps.py +++ b/essenza/user/apps.py @@ -2,5 +2,5 @@ class UserConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'user' + default_auto_field = "django.db.models.BigAutoField" + name = "user" diff --git a/essenza/user/forms.py b/essenza/user/forms.py index 70b77fa..ed47366 100644 --- a/essenza/user/forms.py +++ b/essenza/user/forms.py @@ -1,38 +1,77 @@ from django import forms from django.contrib.auth.forms import UserCreationForm -from .models import Usuario + +from .models import Usuario + class LoginForm(forms.Form): email = forms.CharField( label="Correo electrónico o usuario", - widget=forms.TextInput(attrs={"placeholder": "Introduce tu correo electrónico"}) + widget=forms.TextInput( + attrs={"placeholder": "Introduce tu correo electrónico"} + ), ) password = forms.CharField( label="Contraseña", - widget=forms.PasswordInput(attrs={"placeholder": "Introduce tu contraseña"}) + widget=forms.PasswordInput(attrs={"placeholder": "Introduce tu contraseña"}), ) + class RegisterForm(UserCreationForm): - - first_name = forms.CharField( - label="Nombre", - required=True - ) - last_name = forms.CharField( - label="Apellidos", - required=True - ) - email = forms.EmailField( - label="Correo electrónico", - required=True + first_name = forms.CharField(label="Nombre", required=True) + last_name = forms.CharField(label="Apellidos", required=True) + email = forms.EmailField(label="Correo electrónico", required=True) + photo = forms.ImageField(label="Foto (Opcional)", required=False) + + class Meta(UserCreationForm.Meta): + model = Usuario + + fields = ("first_name", "last_name", "email", "photo") + + def save(self, commit=True): + user = super().save(commit=False) + user.username = self.cleaned_data["email"] + if commit: + user.save() + return user + + +class ProfileEditForm(forms.ModelForm): + first_name = forms.CharField(label="Nombre", required=True) + last_name = forms.CharField(label="Apellidos", required=True) + email = forms.EmailField(label="Correo electrónico", required=True) + photo = forms.ImageField( + label="Foto (Opcional)", required=False, widget=forms.FileInput ) - foto = forms.ImageField( - label="Foto (Opcional)", - required=False + remove_photo = forms.BooleanField( + required=False, label="Eliminar foto de perfil actual" ) - class Meta(UserCreationForm.Meta): - + class Meta: model = Usuario + fields = ("first_name", "last_name", "email", "photo") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["email"].disabled = True + self.fields["email"].help_text = "El correo electrónico no se puede modificar." + + # Ocultar checkbox si no hay foto + if not self.instance or not self.instance.photo: + self.fields["remove_photo"].widget = forms.HiddenInput() + + def save(self, commit=True): + user = super().save(commit=False) + + if self.cleaned_data.get("remove_photo") and not self.files.get("photo"): + try: + if user.photo: + user.photo.delete(save=False) + except Exception: + pass + user.photo = None - fields = UserCreationForm.Meta.fields + ('first_name', 'last_name', 'email', 'foto') + if commit: + user.save() + return user diff --git a/essenza/user/models.py b/essenza/user/models.py index 7a92bfb..2fc027e 100644 --- a/essenza/user/models.py +++ b/essenza/user/models.py @@ -1,18 +1,20 @@ -from django.db import models from django.contrib.auth.models import AbstractUser +from django.db import models + class Role(models.TextChoices): - ADMIN = 'admin', 'Admin' - USER = 'user', 'User' + ADMIN = "admin", "Admin" + USER = "user", "User" + class Usuario(AbstractUser): - foto = models.ImageField(upload_to='images/', null=True, blank=True) + photo = models.ImageField(upload_to="profile_pics/", null=True, blank=True) role = models.CharField(max_length=10, choices=Role.choices, default=Role.USER) - email = models.EmailField(unique=True) + email = models.EmailField(unique=True) - USERNAME_FIELD = 'email' - REQUIRED_FIELDS = ['username'] + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["username"] def __str__(self): - return self.email \ No newline at end of file + return self.email diff --git a/essenza/user/sample/sample.json b/essenza/user/sample/sample.json new file mode 100644 index 0000000..643cffc --- /dev/null +++ b/essenza/user/sample/sample.json @@ -0,0 +1,57 @@ +[ + { + "model": "user.usuario", + "pk": 1, + "fields": { + "email": "user1@example.com", + "username": "user1@example.com", + "first_name": "first", + "last_name": "user", + "photo": "profile_pics/user1.jpg", + "role": "user", + "password": "pbkdf2_sha256$1000000$qHIC4EeRho2OC2FLmNPo9t$atSjNjIY9vOi1GPqbotF19e8tcd1PBFTNkcYBpZyKmU=" + } + }, + { + "model": "user.usuario", + "pk": 2, + "fields": { + "email": "user2@example.com", + "username": "user2@example.com", + "first_name": "second", + "last_name": "user", + "photo": "profile_pics/user2.jpg", + "role": "user", + "password": "pbkdf2_sha256$1000000$qHIC4EeRho2OC2FLmNPo9t$atSjNjIY9vOi1GPqbotF19e8tcd1PBFTNkcYBpZyKmU=" + } + }, + { + "model": "user.usuario", + "pk": 3, + "fields": { + "email": "user3@example.com", + "username": "user3@example.com", + "first_name": "third", + "last_name": "user", + "photo": "profile_pics/user3.jpg", + "role": "user", + "password": "pbkdf2_sha256$1000000$qHIC4EeRho2OC2FLmNPo9t$atSjNjIY9vOi1GPqbotF19e8tcd1PBFTNkcYBpZyKmU=" + } + }, + { + "model": "user.usuario", + "pk": 4, + "fields": { + "email": "admin@example.com", + "username": "admin@example.com", + "first_name": "admin", + "last_name": "admin", + "is_superuser": true, + "is_staff": true, + "is_active": true, + "photo": "profile_pics/admin.jpg", + "role": "admin", + "password": "pbkdf2_sha256$1000000$sazfnRvfJ4niYZE6ixBVKR$pboaUb8AWvEXwuJZkWoh3xwfYq++7nik9p2e0TxNPws=" + } + } +] diff --git a/essenza/user/tests.py b/essenza/user/tests.py index a65bb48..29aaece 100644 --- a/essenza/user/tests.py +++ b/essenza/user/tests.py @@ -1,15 +1,14 @@ -import tempfile -from django.test import TestCase -from django.urls import reverse -from django.contrib.auth import get_user_model -from django.conf import settings -from django.core.files.uploadedfile import SimpleUploadedFile import io import os +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from django.urls import reverse User = get_user_model() + class LoginViewTests(TestCase): def setUp(self): # usuario de prueba @@ -17,202 +16,202 @@ def setUp(self): self.email = "user1@example.com" self.password = "pass1234" self.user = User.objects.create_user( - username=self.username, - email=self.email, - password=self.password + username=self.username, email=self.email, password=self.password ) - self.login_url = reverse("login") - self.home_url = reverse("home") - self.escaparate_url = reverse("escaparate") + self.login_url = reverse("login") + self.dashboard_url = reverse("dashboard") - #1. comprueba que la pagina de login carga correctamente + # 1. comprueba que la pagina de login carga correctamente def test_get_login_page_returns_200(self): resp = self.client.get(self.login_url) self.assertEqual(resp.status_code, 200) - self.assertContains(resp, "Iniciar sesión") - self.assertContains(resp, "ESSENZA") + self.assertContains(resp, "Iniciar sesión") + self.assertContains(resp, "ESSENZA") - #2. si email y contraseña validas redirige al escaparate - def test_login_with_valid_email_redirects_escaparate(self): + # 2. si email y contraseña validas redirige al dashboard + def test_login_with_valid_email_redirects_dashboard(self): data = {"email": self.email, "password": self.password} resp = self.client.post(self.login_url, data, follow=False) self.assertEqual(resp.status_code, 302, resp.content) - self.assertEqual(resp["Location"], self.escaparate_url) + self.assertEqual(resp["Location"], self.dashboard_url) - #3. si email y contraseña no validas muestra error + # 3. si email y contraseña no validas muestra error def test_login_with_invalid_passwordAndEmail_shows_error(self): data = {"email": "wrong", "password": "wrong"} resp = self.client.post(self.login_url, data) self.assertEqual(resp.status_code, 200) self.assertContains(resp, "Usuario o contraseña incorrectos") - #4. si email no es valido muestra error + # 4. si email no es valido muestra error def test_login_with_invalid_email_shows_error(self): data = {"email": "wrong", "password": self.password} resp = self.client.post(self.login_url, data) self.assertEqual(resp.status_code, 200) self.assertContains(resp, "Usuario o contraseña incorrectos") - #5. si contraseña no es valida muestra error + # 5. si contraseña no es valida muestra error def test_login_with_invalid_password_shows_error(self): data = {"email": self.email, "password": "wrong"} resp = self.client.post(self.login_url, data) self.assertEqual(resp.status_code, 200) self.assertContains(resp, "Usuario o contraseña incorrectos") - + class RegisterViewTests(TestCase): def setUp(self): self.register_url = reverse("register") - self.escaparate_url = reverse("escaparate") + self.dashboard_url = reverse("dashboard") self.initial_user_count = User.objects.count() # Datos para un nuevo usuario de prueba self.valid_data = { - 'first_name': 'Juan', - 'last_name': 'Perez', - 'username': 'nuevo_usuario', - 'email': 'nuevo@ejemplo.com', - 'password1': 'PasswordSeguro123', - 'password2': 'PasswordSeguro123', + "first_name": "Juan", + "last_name": "Perez", + "username": "nuevo_usuario", + "email": "nuevo@ejemplo.com", + "password1": "PasswordSeguro123", + "password2": "PasswordSeguro123", } - - #1. Comprueba que la página de registro carga correctamente + + # 1. Comprueba que la página de registro carga correctamente def test_get_register_page_returns_200(self): resp = self.client.get(self.register_url) self.assertEqual(resp.status_code, 200) self.assertContains(resp, "Crear cuenta") self.assertContains(resp, "ESSENZA") - #2. Registro con datos válidos y redirige al escaparate (302) + # 2. Registro con datos válidos y redirige al dashboard (302) def test_successful_registration_redirects_and_creates_user(self): data = self.valid_data.copy() resp = self.client.post(self.register_url, data, follow=False) - + self.assertEqual(resp.status_code, 302) - self.assertEqual(resp["Location"], self.escaparate_url) + self.assertEqual(resp["Location"], self.dashboard_url) self.assertEqual(User.objects.count(), self.initial_user_count + 1) - - new_user = User.objects.get(email=data['email']) - self.assertTrue(new_user.check_password(data['password1'])) # La contraseña está hasheada + new_user = User.objects.get(email=data["email"]) + self.assertTrue( + new_user.check_password(data["password1"]) + ) # La contraseña está hasheada - #3. Registro con email duplicado muestra error + # 3. Registro con email duplicado muestra error def test_registration_with_duplicate_email_shows_error(self): - User.objects.create_user(username='test', email=self.valid_data['email'], password='test') # Usuario previo creado con mismo email + User.objects.create_user( + username="test", email=self.valid_data["email"], password="test" + ) # Usuario previo creado con mismo email data = self.valid_data.copy() - resp = self.client.post(self.register_url, data) #Intento de registro con el mismo email - - self.assertEqual(User.objects.count(), self.initial_user_count + 1) #Solo se añade el usuario creado antes - self.assertContains(resp, "Ya existe Usuario con este Email.", html=True) + resp = self.client.post( + self.register_url, data + ) # Intento de registro con el mismo email + self.assertEqual( + User.objects.count(), self.initial_user_count + 1 + ) # Solo se añade el usuario creado antes + self.assertContains(resp, "Ya existe Usuario con este Email.", html=True) - #4. Registro con contraseñas que no coinciden muestra error + # 4. Registro con contraseñas que no coinciden muestra error def test_registration_with_mismatched_passwords_shows_error(self): data = self.valid_data.copy() - data['password2'] = 'diferente123' + data["password2"] = "diferente123" resp = self.client.post(self.register_url, data) - + self.assertEqual(User.objects.count(), self.initial_user_count) self.assertContains(resp, "Los dos campos de contraseña no coinciden.") - - #5. Registro con campo 'first_name' vacío muestra error (required=True) + # 5. Registro con campo 'first_name' vacío muestra error (required=True) def test_registration_missing_first_name_shows_error(self): data = self.valid_data.copy() - data['first_name'] = '' + data["first_name"] = "" resp = self.client.post(self.register_url, data) - - self.assertEqual(User.objects.count(), self.initial_user_count) + + self.assertEqual(User.objects.count(), self.initial_user_count) self.assertContains(resp, "Este campo es obligatorio.") - - - #6. Registro con subida de foto válida + + # 6. Registro con subida de foto válida def test_registration_with_valid_photo(self): # Creo una foto JPEG en memoria try: from PIL import Image + buf = io.BytesIO() - img = Image.new('RGB', (1, 1), color=(255, 0, 0)) - img.save(buf, format='JPEG') + img = Image.new("RGB", (1, 1), color=(255, 0, 0)) + img.save(buf, format="JPEG") image_data = buf.getvalue() except Exception: - self.skipTest('Pillow is required to create a test JPEG image') + self.skipTest("Pillow is required to create a test JPEG image") photo = SimpleUploadedFile( - name='test_photo.jpg', - content=image_data, - content_type='image/jpeg' + name="test_photo.jpg", content=image_data, content_type="image/jpeg" ) - + data = self.valid_data.copy() - data['foto'] = photo + data["photo"] = photo resp = self.client.post(self.register_url, data, follow=False) - + self.assertEqual(resp.status_code, 302) - new_user = User.objects.get(email=data['email']) - self.assertTrue(new_user.foto.name.startswith('images/test_photo')) + new_user = User.objects.get(email=data["email"]) + self.assertTrue(new_user.photo.name.startswith("profile_pics/test_photo")) # Elimina la foto creada - if new_user.foto: - if os.path.exists(new_user.foto.path): - os.remove(new_user.foto.path) + if new_user.photo: + if os.path.exists(new_user.photo.path): + os.remove(new_user.photo.path) - - #7. Registro sin campo 'foto' (opcional) es exitoso + # 7. Registro sin campo 'foto' (opcional) es exitoso def test_registration_without_photo_is_successful(self): data = self.valid_data.copy() - if 'foto' in data: - del data['foto'] - + if "photo" in data: + del data["photo"] + resp = self.client.post(self.register_url, data, follow=False) - + self.assertEqual(resp.status_code, 302) - new_user = User.objects.get(email=data['email']) - self.assertFalse(new_user.foto) - + new_user = User.objects.get(email=data["email"]) + self.assertFalse(new_user.photo) + + class LogoutViewTests(TestCase): def setUp(self): self.client = self.client = self.client = self.client_class() self.user = User.objects.create_user( - username='userlogout', - email='logout@example.com', - password='testlogout123' + username="userlogout", email="logout@example.com", password="testlogout123" ) - self.login_url = reverse('login') - self.logout_url = reverse('logout') - self.home_url = reverse('home') + self.login_url = reverse("login") + self.logout_url = reverse("logout") + self.dashboard_url = reverse("dashboard") # 1. Comprobar que un usuario logueado se desloguea y redirige correctamente - def test_logout_redirects_to_home_and_clears_session(self): + def test_logout_redirects_to_dashboard_and_clears_session(self): # Iniciar sesión - self.client.login(username='logout@example.com', password='testlogout123') + self.client.login(username="logout@example.com", password="testlogout123") # Verificar que la sesión está activa - self.assertIn('_auth_user_id', self.client.session) + self.assertIn("_auth_user_id", self.client.session) # Hacer logout response = self.client.get(self.logout_url) - # Verificar redirección al home - self.assertRedirects(response, self.home_url) + # Verificar redirección al dashboard + self.assertRedirects(response, self.dashboard_url) # Verificar que se ha cerrado la sesión - self.assertNotIn('_auth_user_id', self.client.session) + self.assertNotIn("_auth_user_id", self.client.session) # 2. Comprobar que el logout borra la cookie de sesión def test_logout_deletes_session_cookie(self): """El logout deja la cookie de sesión vacía y expirada.""" - self.client.login(username='logout@example.com', password='testlogout123') + self.client.login(username="logout@example.com", password="testlogout123") response = self.client.get(self.logout_url) # Django deja la cookie 'sessionid', pero vacía o marcada para expirar - self.assertIn('sessionid', response.cookies) - cookie = response.cookies['sessionid'] - self.assertTrue(cookie.value == '' or cookie['max-age'] == 0 or cookie['expires']) - self.assertRedirects(response, self.home_url) + self.assertIn("sessionid", response.cookies) + cookie = response.cookies["sessionid"] + self.assertTrue( + cookie.value == "" or cookie["max-age"] == 0 or cookie["expires"] + ) + self.assertRedirects(response, self.dashboard_url) # 3. Comprobar que un usuario no autenticado también redirige correctamente def test_logout_redirects_even_if_not_authenticated(self): response = self.client.get(self.logout_url) - self.assertRedirects(response, self.home_url) + self.assertRedirects(response, self.dashboard_url) diff --git a/essenza/user/urls.py b/essenza/user/urls.py index 26825a4..5333d5b 100644 --- a/essenza/user/urls.py +++ b/essenza/user/urls.py @@ -1,12 +1,12 @@ -from django.contrib import admin -from django.urls import include, path -from django.http import HttpResponse -import user.views as views +from django.urls import path +import user.views as views urlpatterns = [ - path('register/', views.RegisterView.as_view(), name='register'), - path('login/', views.LoginView.as_view(), name='login'), - path('logout/', views.LogoutView.as_view(), name='logout'), + path("register/", views.RegisterView.as_view(), name="register"), + path("login/", views.LoginView.as_view(), name="login"), + path("logout/", views.LogoutView.as_view(), name="logout"), + path("profile/", views.ProfileView.as_view(), name="profile"), + path("profile/edit/", views.ProfileEditView.as_view(), name="profile_edit"), + path("profile/delete/", views.ProfileDeleteView.as_view(), name="profile_delete"), ] - diff --git a/essenza/user/views.py b/essenza/user/views.py index 25774b6..315602d 100644 --- a/essenza/user/views.py +++ b/essenza/user/views.py @@ -1,20 +1,21 @@ -from django.shortcuts import render, redirect -from django.views import View from django.contrib.auth import authenticate, login, logout -from .forms import LoginForm, RegisterForm -from .models import Usuario +from django.contrib.auth.mixins import LoginRequiredMixin # Para proteger vistas +from django.shortcuts import redirect, render +from django.views import View + +from .forms import LoginForm, ProfileEditForm, RegisterForm + class LoginView(View): form_class = LoginForm - template_name = 'user/login.html' + template_name = "user/login.html" def get(self, request, *args, **kwargs): - # Si el usuario ya está autenticado, lo mandamos a escaparate - logout(request) + # Si el usuario ya está autenticado, lo mandamos a dashboard if request.user.is_authenticated: - return redirect('escaparate') + return redirect("dashboard") # Si no está autenticado, renderiza el formulario de login - return render(request, self.template_name, {'form': self.form_class()}) + return render(request, self.template_name, {"form": self.form_class()}) def post(self, request, *args, **kwargs): form = self.form_class(request.POST) @@ -27,40 +28,109 @@ def post(self, request, *args, **kwargs): if user is not None: login(request, user) - # Redirige al escaparate después del login - return redirect('escaparate') + if user.role == "user": + return redirect("dashboard") + else: + return redirect("stock") else: # Si falla el login, muestra error en el formulario form.add_error(None, "Usuario o contraseña incorrectos") - return render(request, self.template_name, {'form': form}) + return render(request, self.template_name, {"form": form}) + class LogoutView(View): def get(self, request): logout(request) - response = redirect('home') - response.delete_cookie('sessionid') + response = redirect("dashboard") + response.delete_cookie("sessionid") return response def post(self, request): logout(request) - response = redirect('home') - response.delete_cookie('sessionid') + response = redirect("dashboard") + response.delete_cookie("sessionid") return response - + + class RegisterView(View): form_class = RegisterForm - template_name = 'user/register.html' + template_name = "user/register.html" def get(self, request, *args, **kwargs): form = self.form_class() - return render(request, self.template_name, {'form': form}) + return render(request, self.template_name, {"form": form}) + + def post(self, request, *args, **kwargs): + form = self.form_class(request.POST, request.FILES) + + if form.is_valid(): + user = form.save() + login(request, user) + return redirect("dashboard") + + return render(request, self.template_name, {"form": form}) + + +class ProfileView(LoginRequiredMixin, View): + template_name = "user/profile.html" + + def get(self, request, *args, **kwargs): + return render(request, self.template_name) + + +class ProfileEditView(LoginRequiredMixin, View): + form_class = ProfileEditForm + template_name = "user/edit_profile.html" + + def get(self, request, *args, **kwargs): + # Rellena el formulario con los datos actuales del usuario + form = self.form_class(instance=request.user) + return render(request, self.template_name, {"form": form}) def post(self, request, *args, **kwargs): - form = self.form_class(request.POST, request.FILES) - + # Guarda la foto antigua para borrarla si se ha cambiado + try: + old_photo = request.user.photo + except AttributeError: + old_photo = None + + # Rellena el formulario con los datos enviados + form = self.form_class(request.POST, request.FILES, instance=request.user) + + # Si el formulario es válido, se redirige a la vista de perfil if form.is_valid(): - user = form.save() - return redirect('escaparate') + new_user = form.save() + # Si había una foto antigua y es distinta a la nueva, la borramos del sistema + if old_photo and old_photo != new_user.photo: + old_photo.delete(save=False) + return redirect("profile") + + # Si el formulario no es válido, se vuelve a mostrar con errores + return render(request, self.template_name, {"form": form}) + + +class ProfileDeleteView(LoginRequiredMixin, View): + template_name = "user/confirm_delete_profile.html" + + def get(self, request, *args, **kwargs): + # Muestra la página de confirmación + return render(request, self.template_name) + + def post(self, request, *args, **kwargs): + # Guarda la foto antigua para borrarla + try: + photo_to_delete = request.user.photo + except AttributeError: + photo_to_delete = None + user = request.user + + # Cierra la sesión ANTES de borrar al usuario para evitar errores + logout(request) + # Borra el usuario de la base de datos + user.delete() + # Borra, si la hay, la foto del sistema de archivos + if photo_to_delete: + photo_to_delete.delete(save=False) - return render(request, self.template_name, {'form': form}) \ No newline at end of file + return redirect("dashboard")