diff --git a/essenza/templates/base.html b/essenza/templates/base.html index b15bfc6..d68aa8a 100644 --- a/essenza/templates/base.html +++ b/essenza/templates/base.html @@ -513,21 +513,20 @@ i ESSENZA diff --git a/essenza/templates/order/order_list_admin.html b/essenza/templates/order/order_list_admin.html index 48c343c..4e88f88 100644 --- a/essenza/templates/order/order_list_admin.html +++ b/essenza/templates/order/order_list_admin.html @@ -2,7 +2,7 @@ {% load humanize %} {% load static %} -{% block title %}Mis pedidos · Essenza{% endblock %} +{% block title %}Pedidos · Essenza{% endblock %} {% block extra_head %} @@ -284,8 +284,8 @@
diff --git a/essenza/templates/product/stock.html b/essenza/templates/product/stock.html index 972694c..cc98cca 100644 --- a/essenza/templates/product/stock.html +++ b/essenza/templates/product/stock.html @@ -223,7 +223,7 @@

Stock Essenza

{% endif %} {% endwith %}
- {% if user.is_staff or user.is_superuser %} + {% if user.is_authenticated and user.role == 'admin' %}
+ + + + Eliminar Usuario · Essenza Admin + + + + +
+ +

ATENCIÓN

+ +
+

Eliminar Usuario

+ +

+ Estás a punto de eliminar permanentemente la siguiente cuenta. +
+ Esta acción borrará todos sus pedidos y datos asociados. +

+ +
+ Usuario: + {{ object.email }} + ({{ object.first_name }} {{ object.last_name }}) +
+ + + {% csrf_token %} + + + + +
+
+ + \ No newline at end of file diff --git a/essenza/templates/user/user_create_admin.html b/essenza/templates/user/user_create_admin.html new file mode 100644 index 0000000..f5ae513 --- /dev/null +++ b/essenza/templates/user/user_create_admin.html @@ -0,0 +1,238 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Crear Usuario · Essenza{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ + Volver + +
+

ESSENZA ADMIN

+
+

Crear Usuario

+ +
+ {% csrf_token %} + + + {{ form.first_name }} + + + {{ form.last_name }} + + + {{ form.email }} + {% if form.email.errors %}

{{ form.email.errors.0 }}

{% endif %} + + {% for field in form %} + {% if 'password' in field.name %} + + {{ field }} + {% if field.errors %} +

{{ field.errors.0 }}

+ {% endif %} + {% endif %} + {% endfor %} + +
+ Configuración Admin + + + {{ form.role }} + +
+ {{ form.is_active }} + +
+ +
+ + {{ form.photo }} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} + + + +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/essenza/templates/user/user_edit_admin.html b/essenza/templates/user/user_edit_admin.html new file mode 100644 index 0000000..3df9bdd --- /dev/null +++ b/essenza/templates/user/user_edit_admin.html @@ -0,0 +1,296 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Administrar Usuario · Essenza{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +

ESSENZA ADMIN

+ +
+ +

+ Gestionando a: {{ form.instance.username }} +

+ +
+ {% csrf_token %} + + + {{ form.first_name }} + + + {{ form.last_name }} + + + {{ form.email }} + +
+ Control de Acceso + + + {{ form.role }} + +
+ Cuenta Activa + +
+
+ + + {{ form.photo }} + + {% if not form.remove_photo.is_hidden and form.instance.photo %} +
+ {{ form.remove_photo }} + +
+ {% 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 %} + + + + +
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/essenza/templates/user/user_list.html b/essenza/templates/user/user_list.html new file mode 100644 index 0000000..3e1b5dd --- /dev/null +++ b/essenza/templates/user/user_list.html @@ -0,0 +1,425 @@ +{% extends "base.html" %} +{% load humanize %} +{% load static %} + +{% block title %}Gestión de Usuarios · Essenza{% endblock %} + +{% block extra_head %} + + + +{% endblock %} + +{% block content %} +
+ + + +
+ + Nuevo Usuario + +
+ +
+ + + +
+ {% if request.GET.role %}{% endif %} + + Ordenar por: + +
+ +
+ + {% if users %} + {% for user in users %} +
+ +
+
+
+ ID: #{{ user.id }} +
+ +
+ {% if user.last_login %} + Última vez hace {{ user.last_login|timesince }} + {% else %} + Sin actividad registrada + {% endif %} +
+
+ +
+ +
+ {% if user.photo %} + + {% else %} +
+ {% endif %} + + +
+ + + +
+
+ {% endfor %} + {% endif %} + +
+{% endblock %} \ No newline at end of file diff --git a/essenza/user/forms.py b/essenza/user/forms.py index ed47366..d9b17f1 100644 --- a/essenza/user/forms.py +++ b/essenza/user/forms.py @@ -1,7 +1,7 @@ from django import forms from django.contrib.auth.forms import UserCreationForm -from .models import Usuario +from .models import Role, Usuario class LoginForm(forms.Form): @@ -75,3 +75,74 @@ def save(self, commit=True): if commit: user.save() return user + + +class UserCreationFormAdmin(RegisterForm): + # Añadimos SOLO lo que le falta al registro normal: Control de Rol y Estado + role = forms.ChoiceField( + choices=Role.choices, + label="Rol Inicial", + required=True, + initial=Role.USER, # Por defecto creamos clientes + ) + + is_active = forms.BooleanField( + label="Cuenta Activa", + required=False, + initial=True, + help_text="Desmarca si quieres crearlo pero bloquearle el acceso.", + ) + + class Meta(RegisterForm.Meta): + # Heredamos el modelo de RegisterForm + model = Usuario + # Añadimos los nuevos campos a los que ya tenía RegisterForm + fields = RegisterForm.Meta.fields + ("role", "is_active") + + +class UserEditFormAdmin(forms.ModelForm): + first_name = forms.CharField(label="Nombre", required=False) + last_name = forms.CharField(label="Apellidos", required=False) + email = forms.EmailField(label="Correo electrónico", required=True) + + role = forms.ChoiceField( + choices=Role.choices, + label="Rol de Usuario", + required=True, + widget=forms.Select(attrs={"class": "form-select"}), + ) + is_active = forms.BooleanField( + label="Cuenta Activa", + required=False, + help_text="Desmarca esto para bloquear el acceso al usuario sin borrarlo.", + ) + + photo = forms.ImageField( + label="Foto de perfil", required=False, widget=forms.FileInput + ) + remove_photo = forms.BooleanField(required=False, label="Eliminar foto actual") + + class Meta: + model = Usuario + fields = ("first_name", "last_name", "email", "role", "is_active", "photo") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + 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 + + if commit: + user.save() + return user diff --git a/essenza/user/tests.py b/essenza/user/tests.py index 29aaece..c052e97 100644 --- a/essenza/user/tests.py +++ b/essenza/user/tests.py @@ -6,7 +6,8 @@ from django.test import TestCase from django.urls import reverse -User = get_user_model() +# UNIFICACIÓN: Usamos 'Usuario' para todo el archivo +Usuario = get_user_model() class LoginViewTests(TestCase): @@ -15,7 +16,7 @@ def setUp(self): self.username = "user1" self.email = "user1@example.com" self.password = "pass1234" - self.user = User.objects.create_user( + self.user = Usuario.objects.create_user( username=self.username, email=self.email, password=self.password ) self.login_url = reverse("login") @@ -61,7 +62,7 @@ class RegisterViewTests(TestCase): def setUp(self): self.register_url = reverse("register") self.dashboard_url = reverse("dashboard") - self.initial_user_count = User.objects.count() + self.initial_user_count = Usuario.objects.count() # Datos para un nuevo usuario de prueba self.valid_data = { @@ -87,16 +88,16 @@ def test_successful_registration_redirects_and_creates_user(self): self.assertEqual(resp.status_code, 302) self.assertEqual(resp["Location"], self.dashboard_url) - self.assertEqual(User.objects.count(), self.initial_user_count + 1) + self.assertEqual(Usuario.objects.count(), self.initial_user_count + 1) - new_user = User.objects.get(email=data["email"]) + new_user = Usuario.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 def test_registration_with_duplicate_email_shows_error(self): - User.objects.create_user( + Usuario.objects.create_user( username="test", email=self.valid_data["email"], password="test" ) # Usuario previo creado con mismo email data = self.valid_data.copy() @@ -105,7 +106,7 @@ def test_registration_with_duplicate_email_shows_error(self): ) # Intento de registro con el mismo email self.assertEqual( - User.objects.count(), self.initial_user_count + 1 + Usuario.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) @@ -115,7 +116,7 @@ def test_registration_with_mismatched_passwords_shows_error(self): data["password2"] = "diferente123" resp = self.client.post(self.register_url, data) - self.assertEqual(User.objects.count(), self.initial_user_count) + self.assertEqual(Usuario.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) @@ -124,7 +125,7 @@ def test_registration_missing_first_name_shows_error(self): data["first_name"] = "" resp = self.client.post(self.register_url, data) - self.assertEqual(User.objects.count(), self.initial_user_count) + self.assertEqual(Usuario.objects.count(), self.initial_user_count) self.assertContains(resp, "Este campo es obligatorio.") # 6. Registro con subida de foto válida @@ -149,7 +150,7 @@ def test_registration_with_valid_photo(self): resp = self.client.post(self.register_url, data, follow=False) self.assertEqual(resp.status_code, 302) - new_user = User.objects.get(email=data["email"]) + new_user = Usuario.objects.get(email=data["email"]) self.assertTrue(new_user.photo.name.startswith("profile_pics/test_photo")) # Elimina la foto creada @@ -166,14 +167,14 @@ def test_registration_without_photo_is_successful(self): resp = self.client.post(self.register_url, data, follow=False) self.assertEqual(resp.status_code, 302) - new_user = User.objects.get(email=data["email"]) + new_user = Usuario.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( + self.client = self.client_class() + self.user = Usuario.objects.create_user( username="userlogout", email="logout@example.com", password="testlogout123" ) self.login_url = reverse("login") @@ -215,3 +216,180 @@ def test_logout_deletes_session_cookie(self): def test_logout_redirects_even_if_not_authenticated(self): response = self.client.get(self.logout_url) self.assertRedirects(response, self.dashboard_url) + + +class UserAdminViewsTests(TestCase): + def setUp(self): + # 1. Creamos un ADMIN + self.admin_user = Usuario.objects.create_user( + email="admin@test.com", + username="admin@test.com", + password="password123", + role="admin", + first_name="Admin", + last_name="Jefe", + ) + + # 2. Creamos un USUARIO NORMAL (Cliente) + self.normal_user = Usuario.objects.create_user( + email="user@test.com", + username="user@test.com", + password="password123", + role="user", + first_name="Cliente", + last_name="Uno", + ) + + # 3. Creamos un USUARIO OBJETIVO (Para editar/borrar) + self.target_user = Usuario.objects.create_user( + email="target@test.com", + username="target@test.com", + password="password123", + role="user", + first_name="Zacarias", + last_name="Target", + ) + + # URLs + self.url_list = reverse("user_list") + self.url_create = reverse("user_create_admin") + self.url_edit = reverse("user_edit_admin", args=[self.target_user.pk]) + self.url_delete = reverse("user_delete_admin", args=[self.target_user.pk]) + self.url_dashboard = reverse("dashboard") + self.url_login = reverse("login") + + # ======================================================== + # 1. PRUEBAS DE SEGURIDAD + # ======================================================== + + def test_anon_user_redirects_to_login(self): + """Si no estás logueado, no entras.""" + endpoints = [self.url_list, self.url_create, self.url_edit, self.url_delete] + for url in endpoints: + resp = self.client.get(url) + # Redirige al login (302) + self.assertNotEqual(resp.status_code, 200) + + def test_normal_user_redirects_to_dashboard(self): + """Si eres cliente, te echa al dashboard.""" + self.client.force_login(self.normal_user) + endpoints = [self.url_list, self.url_create, self.url_edit, self.url_delete] + for url in endpoints: + resp = self.client.get(url) + self.assertRedirects(resp, self.url_dashboard) + + def test_admin_can_access(self): + """El admin entra hasta la cocina.""" + self.client.force_login(self.admin_user) + resp = self.client.get(self.url_list) + self.assertEqual(resp.status_code, 200) + self.assertTemplateUsed(resp, "user/user_list.html") + + # ======================================================== + # 2. PRUEBAS DE LISTADO (Filtros y Orden) + # ======================================================== + + def test_list_filter_role(self): + self.client.force_login(self.admin_user) + # Filtramos solo admins + resp = self.client.get(self.url_list, {"role": "admin"}) + users = resp.context["users"] + self.assertIn(self.admin_user, users) + self.assertNotIn(self.normal_user, users) + + def test_list_order_by_name(self): + self.client.force_login(self.admin_user) + # Orden A-Z: 'Admin' va antes que 'Zacarias' + resp = self.client.get(self.url_list, {"order": "name_asc"}) + users = list(resp.context["users"]) + + # Verificamos posiciones relativas + index_admin = users.index(self.admin_user) + index_target = users.index(self.target_user) + self.assertTrue(index_admin < index_target) + + # ======================================================== + # 3. PRUEBA DE CREAR (Sin foto) + # ======================================================== + + def test_create_user_success(self): + self.client.force_login(self.admin_user) + + # Datos sin archivo de imagen + data = { + "email": "new@test.com", + "first_name": "Nuevo", + "last_name": "Usuario", + "role": "user", + "is_active": True, + "password1": "Pass12345", # Campos requeridos por tu AdminUserCreationForm + "password2": "Pass12345", + } + + resp = self.client.post(self.url_create, data) + + # Debe redirigir al listado + self.assertRedirects(resp, self.url_list) + + # Verificamos que existe en DB + self.assertTrue(Usuario.objects.filter(email="new@test.com").exists()) + + # Verificamos que NO nos ha logueado con el nuevo usuario (el admin sigue siendo admin) + self.assertEqual(int(self.client.session["_auth_user_id"]), self.admin_user.pk) + + # ======================================================== + # 4. PRUEBA DE EDITAR (Sin foto) + # ======================================================== + + def test_update_user_success(self): + self.client.force_login(self.admin_user) + + data = { + "email": "updated@test.com", # Cambiamos email + "first_name": "Editado", + "last_name": "Test", + "role": "admin", # Lo ascendemos a admin + "is_active": False, # Lo baneamos + } + + resp = self.client.post(self.url_edit, data) + + self.assertRedirects(resp, self.url_list) + + # Refrescamos desde DB para comprobar cambios + self.target_user.refresh_from_db() + self.assertEqual(self.target_user.email, "updated@test.com") + self.assertEqual(self.target_user.role, "admin") + self.assertFalse(self.target_user.is_active) + + # ======================================================== + # 5. PRUEBAS DE BORRAR + # ======================================================== + + def test_delete_user_success(self): + self.client.force_login(self.admin_user) + + # 1. GET muestra confirmación + resp_get = self.client.get(self.url_delete) + self.assertEqual(resp_get.status_code, 200) + self.assertTemplateUsed(resp_get, "user/confirm_delete_user_admin.html") + + # 2. POST borra + resp_post = self.client.post(self.url_delete) + self.assertRedirects(resp_post, self.url_list) + + # Verificamos que murió + self.assertFalse(Usuario.objects.filter(pk=self.target_user.pk).exists()) + + def test_admin_cannot_delete_self(self): + """El admin no puede borrarse a sí mismo.""" + self.client.force_login(self.admin_user) + + url_delete_self = reverse("user_delete_admin", args=[self.admin_user.pk]) + + # Intentamos borrar al admin logueado -> Debe redirigir al listado sin borrar + resp = self.client.post(url_delete_self) + self.assertRedirects(resp, self.url_list) + + # Verificamos que sigue vivo + self.assertTrue(Usuario.objects.filter(pk=self.admin_user.pk).exists()) diff --git a/essenza/user/urls.py b/essenza/user/urls.py index b95daf4..8a78ea8 100644 --- a/essenza/user/urls.py +++ b/essenza/user/urls.py @@ -9,4 +9,20 @@ 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"), + path("list/", views.UserListView.as_view(), name="user_list"), + path( + "manage/create/", + views.UserCreateViewAdmin.as_view(), + name="user_create_admin", + ), + path( + "manage/edit//", + views.UserUpdateViewAdmin.as_view(), + name="user_edit_admin", + ), + path( + "manage/delete//", + views.UserDeleteViewAdmin.as_view(), + name="user_delete_admin", + ), ] diff --git a/essenza/user/views.py b/essenza/user/views.py index 315602d..baf9696 100644 --- a/essenza/user/views.py +++ b/essenza/user/views.py @@ -1,9 +1,20 @@ from django.contrib.auth import authenticate, login, logout -from django.contrib.auth.mixins import LoginRequiredMixin # Para proteger vistas -from django.shortcuts import redirect, render +from django.contrib.auth.mixins import ( # Para proteger vistas + LoginRequiredMixin, + UserPassesTestMixin, +) +from django.db.models import F +from django.shortcuts import get_object_or_404, redirect, render from django.views import View -from .forms import LoginForm, ProfileEditForm, RegisterForm +from .forms import ( + LoginForm, + ProfileEditForm, + RegisterForm, + UserCreationFormAdmin, + UserEditFormAdmin, +) +from .models import Usuario class LoginView(View): @@ -134,3 +145,147 @@ def post(self, request, *args, **kwargs): photo_to_delete.delete(save=False) return redirect("dashboard") + + +class UserListView(LoginRequiredMixin, UserPassesTestMixin, View): + template_name = "user/user_list.html" + + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + + def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") + return redirect("dashboard") + + def get(self, request): + role_filter = request.GET.get("role", "all") + order_filter = request.GET.get("order", "newest") + users = Usuario.objects.all() + # Filtrado por rol + if role_filter == "admin": + users = users.filter(role="admin") + elif role_filter == "user": + users = users.filter(role="user") + # Ordenación + if order_filter == "oldest": + users = users.order_by("date_joined") + elif order_filter == "name_asc": + users = users.order_by("first_name", "username") + elif order_filter == "name_desc": + users = users.order_by("-first_name", "-username") + elif order_filter == "email_asc": + users = users.order_by("email") + elif order_filter == "email_desc": + users = users.order_by("-email") + elif order_filter == "login_desc": + users = users.order_by(F("last_login").desc(nulls_last=True)) + elif order_filter == "login_asc": + users = users.order_by(F("last_login").asc(nulls_first=True)) + else: + users = users.order_by("-date_joined") + return render(request, self.template_name, {"users": users}) + + +class UserCreateViewAdmin(LoginRequiredMixin, UserPassesTestMixin, View): + form_class = UserCreationFormAdmin + template_name = "user/user_create_admin.html" + + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + + def handle_no_permission(self): + return redirect("dashboard") + + def get(self, request, *args, **kwargs): + form = self.form_class() + 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(): + form.save() + + return redirect("user_list") + + return render(request, self.template_name, {"form": form}) + + +class UserUpdateViewAdmin(LoginRequiredMixin, UserPassesTestMixin, View): + form_class = UserEditFormAdmin + template_name = "user/user_edit_admin.html" + + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + + def handle_no_permission(self): + return redirect("dashboard") + + def get(self, request, pk, *args, **kwargs): + user_to_edit = get_object_or_404(Usuario, pk=pk) + + form = self.form_class(instance=user_to_edit) + return render(request, self.template_name, {"form": form}) + + def post(self, request, pk, *args, **kwargs): + user_to_edit = get_object_or_404(Usuario, pk=pk) + + try: + old_photo = user_to_edit.photo + except AttributeError: + old_photo = None + + form = self.form_class(request.POST, request.FILES, instance=user_to_edit) + + if form.is_valid(): + saved_user = form.save() + + if old_photo and old_photo != saved_user.photo: + try: + old_photo.delete(save=False) + except Exception: + pass + + return redirect("user_list") + + return render(request, self.template_name, {"form": form}) + + +class UserDeleteViewAdmin(LoginRequiredMixin, UserPassesTestMixin, View): + template_name = "user/confirm_delete_user_admin.html" + + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + + def handle_no_permission(self): + return redirect("dashboard") + + def get(self, request, pk, *args, **kwargs): + user_to_delete = get_object_or_404(Usuario, pk=pk) + + if user_to_delete == request.user: + return redirect("user_list") + + return render(request, self.template_name, {"object": user_to_delete}) + + def post(self, request, pk, *args, **kwargs): + user_to_delete = get_object_or_404(Usuario, pk=pk) + + if user_to_delete == request.user: + return redirect("user_list") + + try: + photo_to_delete = user_to_delete.photo + except AttributeError: + photo_to_delete = None + + user_to_delete.delete() + + if photo_to_delete: + try: + photo_to_delete.delete(save=False) + except Exception: + pass + + return redirect("user_list")