fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# accounts app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('phone', models.CharField(blank=True, max_length=32)),
|
||||
('odoo_partner_id', models.CharField(blank=True, db_index=True, max_length=64)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['username'],
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Customer',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('customer_code', models.CharField(blank=True, max_length=64, null=True, unique=True)),
|
||||
('company_name', models.CharField(blank=True, max_length=200)),
|
||||
('category', models.CharField(blank=True, db_index=True, max_length=64)),
|
||||
('odoo_partner_id', models.CharField(blank=True, db_index=True, max_length=64)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='customer', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class User(AbstractUser, UUIDModel):
|
||||
"""Application user. Identity may be backed by Odoo via `odoo_partner_id`."""
|
||||
|
||||
phone = models.CharField(max_length=32, blank=True)
|
||||
odoo_partner_id = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["username"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.username or self.email or str(self.id)
|
||||
|
||||
|
||||
class Customer(UUIDModel, TimeStampedModel):
|
||||
"""Customer profile keyed to an external (Odoo) identity where available."""
|
||||
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="customer")
|
||||
customer_code = models.CharField(max_length=64, unique=True, blank=True, null=True)
|
||||
company_name = models.CharField(max_length=200, blank=True)
|
||||
category = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
odoo_partner_id = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.company_name or str(self.user)
|
||||
@@ -0,0 +1,55 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.models import Customer, User
|
||||
from apps.rbac.models import Role
|
||||
|
||||
|
||||
class RoleBriefSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ["id", "name", "slug"]
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
roles = serializers.SerializerMethodField()
|
||||
permissions = serializers.SerializerMethodField()
|
||||
role_ids = serializers.ListField(child=serializers.UUIDField(), write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id", "username", "email", "first_name", "last_name", "phone",
|
||||
"is_active", "is_superuser", "roles", "permissions", "role_ids",
|
||||
]
|
||||
read_only_fields = ["is_superuser"]
|
||||
|
||||
def get_roles(self, obj):
|
||||
return RoleBriefSerializer(
|
||||
[ur.role for ur in obj.user_roles.select_related("role").all()], many=True
|
||||
).data
|
||||
|
||||
def get_permissions(self, obj):
|
||||
if obj.is_superuser:
|
||||
return ["*"]
|
||||
from apps.rbac.models import Permission
|
||||
|
||||
return list(
|
||||
Permission.objects.filter(roles__user_roles__user=obj).values_list("code", flat=True)
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
# role_ids is a write-only helper; roles are synced by the viewset.
|
||||
validated_data.pop("role_ids", None)
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data.pop("role_ids", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class CustomerSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = ["id", "user", "customer_code", "company_name", "category", "odoo_partner_id"]
|
||||
@@ -0,0 +1,56 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from apps.rbac.models import Permission, Role
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserManagementApiTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_superuser(username="root", password="***")
|
||||
self.client.force_authenticate(self.admin)
|
||||
self.role = Role.objects.create(name="Operator", slug="operator-t")
|
||||
self.role.permissions.add(Permission.objects.create(code="processing.start"))
|
||||
|
||||
def test_create_user_with_role_ids(self):
|
||||
response = self.client.post(
|
||||
"/api/v1/users/",
|
||||
{
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"phone": "09120000000",
|
||||
"password": "secret123",
|
||||
"role_ids": [str(self.role.id)],
|
||||
"is_active": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
user = User.objects.get(username="newuser")
|
||||
self.assertTrue(user.check_password("secret123"))
|
||||
self.assertTrue(user.user_roles.filter(role=self.role).exists())
|
||||
|
||||
def test_update_user_role_ids(self):
|
||||
user = User.objects.create_user(username="someone", password="***")
|
||||
response = self.client.patch(
|
||||
f"/api/v1/users/{user.id}/", {"role_ids": [str(self.role.id)]}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertTrue(user.user_roles.filter(role=self.role).exists())
|
||||
|
||||
def test_ban_and_unban(self):
|
||||
user = User.objects.create_user(username="toban", password="***")
|
||||
response = self.client.patch(f"/api/v1/users/{user.id}/", {"is_active": False}, format="json")
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
|
||||
def test_reset_password(self):
|
||||
user = User.objects.create_user(username="pwuser", password="***")
|
||||
response = self.client.post(
|
||||
f"/api/v1/users/{user.id}/set-password/", {"new_password": "brandnew1"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
user.refresh_from_db()
|
||||
self.assertTrue(user.check_password("brandnew1"))
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
|
||||
from apps.accounts.views import ChangePasswordView, CustomerProfileView, MeView
|
||||
|
||||
urlpatterns = [
|
||||
path("login/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||
path("refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||
path("me/", MeView.as_view(), name="me"),
|
||||
path("me/change-password/", ChangePasswordView.as_view(), name="change_password"),
|
||||
path("me/customer/", CustomerProfileView.as_view(), name="customer_profile"),
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
from rest_framework import decorators, generics, permissions, response, status, viewsets
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.accounts.models import Customer, User
|
||||
from apps.accounts.serializers import CustomerSerializer, UserSerializer
|
||||
from apps.core.errors import error_response
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
queryset = User.objects.prefetch_related("user_roles__role").all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {
|
||||
"list": "user.view",
|
||||
"retrieve": "user.view",
|
||||
"create": "user.manage",
|
||||
"update": "user.manage",
|
||||
"partial_update": "user.manage",
|
||||
"destroy": "user.manage",
|
||||
"set_password": "user.manage",
|
||||
}
|
||||
|
||||
def perform_create(self, serializer):
|
||||
password = self.request.data.get("password") or "changeme123"
|
||||
user = serializer.save()
|
||||
user.set_password(password)
|
||||
user.save(update_fields=["password"])
|
||||
self._sync_roles(user)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
user = serializer.save()
|
||||
self._sync_roles(user)
|
||||
|
||||
def _sync_roles(self, user):
|
||||
role_ids = self.request.data.get("role_ids")
|
||||
if role_ids is None:
|
||||
return
|
||||
from apps.rbac.models import Role, UserRole
|
||||
|
||||
user.user_roles.all().delete()
|
||||
for role_id in role_ids:
|
||||
role = Role.objects.filter(id=role_id).first()
|
||||
if role is not None:
|
||||
UserRole.objects.create(user=user, role=role)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"], url_path="set-password")
|
||||
def set_password(self, request, pk=None):
|
||||
user = self.get_object()
|
||||
new_password = request.data.get("new_password", "")
|
||||
if len(new_password) < 6:
|
||||
return Response(
|
||||
error_response("WEAK_PASSWORD", "Password must be at least 6 characters."),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user.set_password(new_password)
|
||||
user.save(update_fields=["password"])
|
||||
return Response({"success": True})
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
"""View and update the authenticated user's own profile."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
return Response(UserSerializer(request.user).data)
|
||||
|
||||
def patch(self, request):
|
||||
user = request.user
|
||||
for field in ("first_name", "last_name", "email", "phone"):
|
||||
if field in request.data:
|
||||
setattr(user, field, request.data[field])
|
||||
user.save()
|
||||
return Response(UserSerializer(user).data)
|
||||
|
||||
|
||||
class ChangePasswordView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
old_password = request.data.get("old_password", "")
|
||||
new_password = request.data.get("new_password", "")
|
||||
if not request.user.check_password(old_password):
|
||||
return Response(
|
||||
error_response("INVALID_PASSWORD", "Current password is incorrect."),
|
||||
status=400,
|
||||
)
|
||||
if len(new_password) < 6:
|
||||
return Response(
|
||||
error_response("WEAK_PASSWORD", "New password must be at least 6 characters."),
|
||||
status=400,
|
||||
)
|
||||
request.user.set_password(new_password)
|
||||
request.user.save(update_fields=["password"])
|
||||
return Response({"success": True})
|
||||
|
||||
|
||||
class CustomerProfileView(generics.RetrieveAPIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = CustomerSerializer
|
||||
|
||||
def get_object(self):
|
||||
customer, _ = Customer.objects.get_or_create(user=self.request.user)
|
||||
return customer
|
||||
Reference in New Issue
Block a user