fix error in gitignore

This commit is contained in:
2026-09-24 12:32:59 +03:30
commit 86c117ee2a
165 changed files with 5963 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# accounts app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
+63
View File
@@ -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,
},
),
]
+30
View File
@@ -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)
+55
View File
@@ -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"]
+56
View File
@@ -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"))
+12
View File
@@ -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"),
]
+106
View File
@@ -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