Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
'django.contrib.staticfiles',
'users',
'weather',
'users',
'rest_framework',
'rest_framework_simplejwt', # <-- Dependencia para los tokens (JWT)
'corsheaders',
Expand Down Expand Up @@ -151,6 +150,13 @@
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.AllowAny", # Permite acceso público a weather API
),
"DEFAULT_THROTTLE_CLASSES": [
"users.throttles.PreferencesRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"user": "100/hour",
"anon": "20/hour",
}
}

SIMPLE_JWT = {
Expand Down
33 changes: 33 additions & 0 deletions backend/users/migrations/0002_userpreferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 5.1.14 on 2025-12-18 18:15

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='UserPreferences',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('theme', models.CharField(choices=[('light', 'Claro'), ('dark', 'Oscuro')], default='light', max_length=10, verbose_name='Tema')),
('language', models.CharField(choices=[('es', 'Español'), ('en', 'Inglés'), ('fr', 'Francés'), ('ru', 'Ruso')], default='es', max_length=2, verbose_name='Idioma')),
('favourite_weather_station', models.CharField(blank=True, max_length=100, null=True, verbose_name='Estación Metereológica Favorita')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Creación')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Última actualización')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='preferences', to=settings.AUTH_USER_MODEL, verbose_name='Usuario')),
],
options={
'verbose_name': 'Preferencia de Usuario',
'verbose_name_plural': 'Preferencias de Usuarios',
'ordering': ['-updated_at'],
},
),
]
105 changes: 104 additions & 1 deletion backend/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from django.conf import settings
import uuid
from datetime import timedelta
from django.db.models.signals import post_save
from django.dispatch import receiver

# Create your models here.

Expand Down Expand Up @@ -69,4 +71,105 @@ def invalidate_user_tokens(cls, user):
"""
Invalida todos los tokens activos de un usuario
"""
cls.objects.filter(user=user, is_used=False).update(is_used=True)
cls.objects.filter(user=user, is_used=False).update(is_used=True)

class UserPreferences(models.Model):
"""
Modelo para almacenar las preferencias de usuario.
Relación OneToOne con el modelo User.
"""

# Elecciones para el tema
THEME_CHOICES = [
("light", "Claro"),
("dark", "Oscuro"),
]

# Elecciones para el idioma
LANGUAGE_CHOICES = [
("es", "Español"),
("en", "Inglés"),
("fr", "Francés"),
("ru", "Ruso"),
]

# Relación OneToOne con User
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name="preferences",
verbose_name="Usuario",
)

# Campos de preferencias
theme = models.CharField(
max_length=10,
choices=THEME_CHOICES,
default="light",
verbose_name="Tema",
)

language = models.CharField(
max_length=2,
choices=LANGUAGE_CHOICES,
default="es",
verbose_name="Idioma",
)

favourite_weather_station = models.CharField(
max_length=100,
blank=True,
null=True,
verbose_name="Estación Metereológica Favorita",
)

# Campos de auditoría
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Fecha de Creación",
)

updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Última actualización",
)

class Meta:
verbose_name = 'Preferencia de Usuario'
verbose_name_plural = 'Preferencias de Usuarios'
ordering = ['-updated_at']

def __str__(self):
return f"Preferencias de {self.user.username}"

@classmethod
def get_or_create_for_user(cls, user):
"""
Obtiene o crea las preferencias para un usuario.
"""
preferences, created = cls.objects.get_or_create(
user=user,
defaults={
'theme': 'light',
'language': 'es',
}
)
return preferences

# Signals para crear automáticamente las preferencias al crear un usuario
@receiver(post_save, sender=User)
def create_user_preferences(sender, instance, created, **kwargs):
"""
Signal que crea automáticamente las preferencias cuando se crea un usuario.
"""
if created:
UserPreferences.objects.create(user=instance)


@receiver(post_save, sender=User)
def save_user_preferences(sender, instance, **kwargs):
"""
Signal que guarda las preferencias cuando se guarda el usuario.
"""
if hasattr(instance, 'preferences'):
instance.preferences.save()
27 changes: 27 additions & 0 deletions backend/users/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,30 @@ class IsSuperUser(permissions.BasePermission):

def has_permission(self, request, view):
return request.user and request.user.is_superuser

class IsOwnerOrReadOnly(permissions.BasePermission):
"""
Permiso personalizado para permitir solo al propietario editar sus preferencias.
"""

def has_object_permission(self, request, view, obj):
# Permisos de lectura permitidos para cualquier request
if request.method in permissions.SAFE_METHODS:
return obj.user == request.user

# Permisos de escritura solo para el propietario
return obj.user == request.user

class IsAuthenticatedAndOwner(permissions.BasePermission):
"""
Permiso que verifica que el usuario esté autenticado
y sea el propietario de las preferencias.
"""

def has_permission(self, request, view):
# Usuario debe estar autenticado
return request.user and request.user.is_authenticated

def has_object_permission(self, request, view, obj):
# El objeto debe pertenecer al usuario autenticado
return obj.user == request.user
123 changes: 122 additions & 1 deletion backend/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from .models import PasswordResetToken
from .models import PasswordResetToken, UserPreferences
from .errors import PasswordResetError
from django.utils import timezone

Expand Down Expand Up @@ -395,4 +395,125 @@ def save(self):
PasswordResetToken.invalidate_user_tokens(user)

return user

class UserPreferencesSerializer(serializers.ModelSerializer):
"""
Serializer para las preferencias de usuario.
Incluye validación de choices y campos personalizados.
"""

# Campos de solo lectura
user = serializers.StringRelatedField(read_only=True)
created_at = serializers.DateTimeField(read_only=True, format='%Y-%m-%d %H:%M:%S')
updated_at = serializers.DateTimeField(read_only=True, format='%Y-%m-%d %H:%M:%S')

class Meta:
model = UserPreferences
fields = [
'id',
'user',
'theme',
'language',
'favorite_weather_station',
'created_at',
'updated_at'
]
read_only_fields = ['id', 'user', 'created_at', 'updated_at']

def validate_theme(self, value):
"""
Valida que el tema sea uno de los permitidos.
"""
allowed_themes = ['light', 'dark']

if value not in allowed_themes:
raise serializers.ValidationError(
f'Tema inválido. Valores permitidos: {", ".join(allowed_themes)}'
)

return value

def validate_language(self, value):
"""
Valida que el idioma sea uno de los permitidos.
"""
allowed_languages = ['es', 'en', 'fr']

if value not in allowed_languages:
raise serializers.ValidationError(
f'Idioma inválido. Valores permitidos: {", ".join(allowed_languages)}'
)

return value

def validate_favorite_weather_station(self, value):
"""
Valida la estación meteorológica favorita.
"""
if value is not None and len(value) > 100:
raise serializers.ValidationError(
'El nombre de la estación no puede exceder 100 caracteres'
)

return value

def validate(self, data):
"""
Validación adicional a nivel de objeto.
"""
# Aquí puedes añadir validaciones que involucren múltiples campos
return data

def to_representation(self, instance):
"""
Personaliza la representación de salida.
"""
representation = super().to_representation(instance)

# Añadir nombres legibles para los choices
representation['theme_display'] = instance.get_theme_display()
representation['language_display'] = instance.get_language_display()

return representation

class UserPreferencesUpdateSerializer(serializers.ModelSerializer):
"""
Serializer específico para actualizaciones parciales (PATCH).
Todos los campos son opcionales.
"""

class Meta:
model = UserPreferences
fields = ['theme', 'language', 'favorite_weather_station']
extra_kwargs = {
'theme': {'required': False},
'language': {'required': False},
'favorite_weather_station': {'required': False},
}

def validate_theme(self, value):
"""Validación de tema"""
allowed_themes = ['light', 'dark']
if value not in allowed_themes:
raise serializers.ValidationError(
f'Tema inválido. Valores permitidos: {", ".join(allowed_themes)}'
)
return value

def validate_language(self, value):
"""Validación de idioma"""
allowed_languages = ['es', 'en', 'fr']
if value not in allowed_languages:
raise serializers.ValidationError(
f'Idioma inválido. Valores permitidos: {", ".join(allowed_languages)}'
)
return value

def validate_favorite_weather_station(self, value):
"""Validación de estación meteorológica"""
if value is not None and len(value) > 100:
raise serializers.ValidationError(
'El nombre de la estación no puede exceder 100 caracteres'
)
return value

51 changes: 51 additions & 0 deletions backend/users/tests_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient, APITestCase
from rest_framework import status
from .models import UserPreferences

User = get_user_model()


class UserPreferencesModelTestCase(TestCase):
"""
Tests para el modelo UserPreferences.
"""

def setUp(self):
self.user = User.objects.create_user(
username='testuser',
email='test@ejemplo.com',
password='TestPass123!'
)

def test_preferencias_creadas_automaticamente(self):
"""Test que las preferencias se crean automáticamente con el usuario"""
self.assertTrue(hasattr(self.user, 'preferences'))
self.assertIsNotNone(self.user.preferences)

def test_valores_por_defecto(self):
"""Test que los valores por defecto son correctos"""
preferences = self.user.preferences

self.assertEqual(preferences.theme, 'light')
self.assertEqual(preferences.language, 'es')
self.assertIsNone(preferences.favorite_weather_station)

def test_str_representation(self):
"""Test de la representación en string"""
preferences = self.user.preferences
expected = f"Preferencias de {self.user.username}"

self.assertEqual(str(preferences), expected)

def test_get_or_create_for_user(self):
"""Test del método get_or_create_for_user"""
# Eliminar preferencias existentes
UserPreferences.objects.filter(user=self.user).delete()

# Crear nuevas preferencias
preferences = UserPreferences.get_or_create_for_user(self.user)

self.assertIsNotNone(preferences)
self.assertEqual(preferences.user, self.user)
Loading