feat: agrega rol 'publico' con UserProfile y permisos diferenciados

- Crea modelo UserProfile (OneToOne con User) con user_type: publico/user/administrator
- Señal post_save que crea UserProfile automáticamente al crear User
- UserSerializer.get_role() lee desde profile.user_type
- Nuevos permisos: IsNotPublico, IsPublico y actualiza IsAdministrator
- IsNotPublico restringe acceso a vistas de gestión (productos, clientes, ventas)
- CatalogSaleView.create permite publico; otras acciones requieren IsNotPublico
- UserProfile inline en admin de Usuarios
- Data migration para backfill de usuarios existentes
This commit is contained in:
2026-06-22 12:27:23 -05:00
parent 27b68a9ac2
commit 5b373bd15f
12 changed files with 177 additions and 15 deletions

View File

@@ -5,7 +5,7 @@ from rest_framework.permissions import IsAuthenticated
from ..models.customers import Customer from ..models.customers import Customer
from ..serializers import CustomerSerializer from ..serializers import CustomerSerializer
from ..permissions import IsAdministrator from ..permissions import IsAdministrator, IsNotPublico
from ..services.tryton.customers import CustomerTrytonService from ..services.tryton.customers import CustomerTrytonService
from ..services.tryton.client import get_tryton_client from ..services.tryton.client import get_tryton_client
@@ -13,7 +13,7 @@ from ..services.tryton.client import get_tryton_client
class CustomerView(viewsets.ModelViewSet): class CustomerView(viewsets.ModelViewSet):
queryset = Customer.objects.all() queryset = Customer.objects.all()
serializer_class = CustomerSerializer serializer_class = CustomerSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsNotPublico]
class CustomersFromTrytonView(APIView): class CustomersFromTrytonView(APIView):

View File

@@ -5,7 +5,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from ..models.products import Product from ..models.products import Product
from ..serializers import ProductSerializer from ..serializers import ProductSerializer
from ..permissions import IsAdministrator from ..permissions import IsAdministrator, IsNotPublico
from ..services.tryton.products import ProductTrytonService from ..services.tryton.products import ProductTrytonService
from ..services.tryton.client import get_tryton_client from ..services.tryton.client import get_tryton_client
@@ -17,7 +17,7 @@ class ProductView(viewsets.ModelViewSet):
def get_permissions(self): def get_permissions(self):
if self.action in ("list", "retrieve"): if self.action in ("list", "retrieve"):
return [AllowAny()] return [AllowAny()]
return [IsAuthenticated()] return [IsNotPublico()]
def get_queryset(self): def get_queryset(self):
""" """

View File

@@ -14,7 +14,7 @@ from ..serializers import (
SaleSummarySerializer, SaleSummarySerializer,
CatalogSaleSummarySerializer, CatalogSaleSummarySerializer,
) )
from ..permissions import IsAdministrator from ..permissions import IsAdministrator, IsNotPublico
from ..services.tryton.sales import SaleTrytonService from ..services.tryton.sales import SaleTrytonService
from ..services.tryton.client import get_tryton_client from ..services.tryton.client import get_tryton_client
from ..views import sales_to_tryton_csv from ..views import sales_to_tryton_csv
@@ -23,7 +23,7 @@ from ..views import sales_to_tryton_csv
class SaleView(viewsets.ModelViewSet): class SaleView(viewsets.ModelViewSet):
queryset = Sale.objects.all() queryset = Sale.objects.all()
serializer_class = SaleSerializer serializer_class = SaleSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsNotPublico]
def create(self, request): def create(self, request):
data = request.data data = request.data
@@ -59,11 +59,15 @@ class SaleView(viewsets.ModelViewSet):
class CatalogSaleView(viewsets.ModelViewSet): class CatalogSaleView(viewsets.ModelViewSet):
queryset = CatalogSale.objects.all() queryset = CatalogSale.objects.all()
serializer_class = CatalogSaleSerializer serializer_class = CatalogSaleSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
if self.action == "create":
return [IsAuthenticated()]
return [IsNotPublico()]
class SaleSummary(APIView): class SaleSummary(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsNotPublico]
def get(self, request, id): def get(self, request, id):
sale = Sale.objects.get(pk=id) sale = Sale.objects.get(pk=id)
@@ -72,7 +76,7 @@ class SaleSummary(APIView):
class CatalogSaleSummary(APIView): class CatalogSaleSummary(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsNotPublico]
def get(self, request, id): def get(self, request, id):
catalog_sale = CatalogSale.objects.get(pk=id) catalog_sale = CatalogSale.objects.get(pk=id)

View File

@@ -1,6 +1,26 @@
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
def _get_user_type(user):
if not user or not user.is_authenticated:
return None
profile = getattr(user, "profile", None)
if profile:
return profile.user_type
return "administrator" if user.is_staff else "user"
class IsAdministrator(BasePermission): class IsAdministrator(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
return request.user and request.user.is_staff return _get_user_type(request.user) == "administrator"
class IsNotPublico(BasePermission):
def has_permission(self, request, view):
user_type = _get_user_type(request.user)
return user_type is not None and user_type != "publico"
class IsPublico(BasePermission):
def has_permission(self, request, view):
return _get_user_type(request.user) == "publico"

View File

@@ -1,3 +1,23 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import User
from django.contrib.auth.admin import UserAdmin
# Register your models here. from .models import UserProfile
class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
class CustomUserAdmin(UserAdmin):
inlines = [UserProfileInline]
def get_inline_instances(self, request, obj=None):
if not obj:
return []
return super().get_inline_instances(request, obj)
admin.site.unregister(User)
admin.site.register(User, CustomUserAdmin)

View File

@@ -3,3 +3,6 @@ from django.apps import AppConfig
class UsersConfig(AppConfig): class UsersConfig(AppConfig):
name = 'users' name = 'users'
def ready(self):
import users.signals # noqa

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.0.6 on 2026-06-22 17:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_type', models.CharField(choices=[('publico', 'Público'), ('user', 'Usuario'), ('administrator', 'Administrador')], default='user', max_length=20)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def backfill_user_profiles(apps, schema_editor):
User = apps.get_model("auth", "User")
UserProfile = apps.get_model("users", "UserProfile")
for user in User.objects.all():
UserProfile.objects.get_or_create(
user=user,
defaults={
"user_type": "administrator" if user.is_staff else "user",
},
)
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.RunPython(backfill_user_profiles, migrations.RunPython.noop),
]

View File

@@ -1,3 +1,19 @@
from django.db import models from django.db import models
from django.contrib.auth.models import User
# Create your models here.
class UserProfile(models.Model):
USER_TYPE_CHOICES = [
("publico", "Público"),
("user", "Usuario"),
("administrator", "Administrador"),
]
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name="profile"
)
user_type = models.CharField(
max_length=20, choices=USER_TYPE_CHOICES, default="user"
)
def __str__(self):
return f"{self.user.username} - {self.user_type}"

View File

@@ -1,13 +1,25 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import serializers from rest_framework import serializers
from .models import UserProfile
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
role = serializers.SerializerMethodField() role = serializers.SerializerMethodField()
class Meta: class Meta:
model = User model = User
fields = ('id', 'username', 'email', 'first_name', 'last_name', 'role') fields = (
"id",
"username",
"email",
"first_name",
"last_name",
"role",
)
def get_role(self, obj): def get_role(self, obj):
return 'administrator' if obj.is_staff else 'user' try:
return obj.profile.user_type
except UserProfile.DoesNotExist:
return "administrator" if obj.is_staff else "user"

View File

@@ -0,0 +1,12 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import UserProfile
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
user_type = "administrator" if instance.is_staff else "user"
UserProfile.objects.create(user=instance, user_type=user_type)

View File

@@ -4,6 +4,8 @@ from django.contrib.auth.models import User
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from .models import UserProfile
class MeEndpointTests(TestCase): class MeEndpointTests(TestCase):
def setUp(self): def setUp(self):
@@ -49,7 +51,6 @@ class MeEndpointTests(TestCase):
username='regular', username='regular',
email='regular@example.com', email='regular@example.com',
password='regularpass', password='regularpass',
is_staff=False
) )
refresh = RefreshToken.for_user(regular_user) refresh = RefreshToken.for_user(regular_user)
@@ -87,6 +88,31 @@ class MeEndpointTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['role'], 'administrator') self.assertEqual(response.json()['role'], 'administrator')
def test_publico_user_role_is_publico(self):
"""
Verifica que un usuario con user_type='publico' recibe role 'publico'.
"""
publico_user = User.objects.create_user(
username='publico',
email='publico@example.com',
password='publicopass',
)
profile = UserProfile.objects.get(user=publico_user)
profile.user_type = 'publico'
profile.save()
refresh = RefreshToken.for_user(publico_user)
access_token = str(refresh.access_token)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}')
url = reverse('current-user')
response = client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['role'], 'publico')
def test_me_endpoint_requires_authentication(self): def test_me_endpoint_requires_authentication(self):
""" """
Sin token el endpoint debe devolver 401 Unauthorized. Sin token el endpoint debe devolver 401 Unauthorized.