From 506b0cb4480307f016850819c8f6b20915ae53ae Mon Sep 17 00:00:00 2001 From: colaftc Date: Mon, 24 Nov 2025 12:58:29 +0800 Subject: [PATCH] feat: added user profile model & api & admin, it uses by found user with merchant --- api_man/employee_user_binding.md | 300 ++++++++++++++++++ api_man/serializers.py | 21 ++ api_man/tests.py | 153 +++++++++ api_man/urls.py | 6 +- api_man/views.py | 12 + .../0002_alter_uploadedfile_path.py | 19 ++ basic_info/admin.py | 5 + basic_info/migrations/0012_userprofile.py | 31 ++ basic_info/models.py | 24 ++ basic_info/views.py | 3 - ...004_alter_stockchangerecord_source_type.py | 18 ++ 11 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 api_man/employee_user_binding.md create mode 100644 api_v1/migrations/0002_alter_uploadedfile_path.py create mode 100644 basic_info/migrations/0012_userprofile.py delete mode 100644 basic_info/views.py create mode 100644 stock/migrations/0004_alter_stockchangerecord_source_type.py diff --git a/api_man/employee_user_binding.md b/api_man/employee_user_binding.md new file mode 100644 index 0000000..3dd49ad --- /dev/null +++ b/api_man/employee_user_binding.md @@ -0,0 +1,300 @@ +# Employee-User Binding API Documentation + +This document explains how to use the employee API endpoints to bind a Django User object to an Employee object, either during creation or update operations. It also includes information about the UserProfile model which extends the Django User model with merchant association. + +## 1. Model Relationship Overview + +### 1.1 Core Model Relationships +The system has three key models related to user management: +- `django.contrib.auth.models.User`: Django's built-in user model for authentication +- `Employee`: Represents an employee in the system +- `UserProfile`: Extends the User model with additional information (merchant association) + +### 1.2 Field Relationships +- `Employee` has a **one-to-one** field `sys_user` that references `django.contrib.auth.models.User` + - Field name: `sys_user` + - It accepts the User ID as input + - It's a nullable field, so an Employee can exist without being bound to a User initially + +- `UserProfile` has a **one-to-one** field `user` that references `django.contrib.auth.models.User` + - Field name: `user` + - It also has a `merchant` field that links the user to a specific merchant + - A User can have only one UserProfile, and a UserProfile belongs to only one merchant + +### 1.3 Relationship Diagram +``` ++------------------------+ +------------------------+ +| django.contrib.auth.User| | Employee | ++------------------------+ +------------------------+ +| id | | id | +| username |<----->| sys_user | (one-to-one) +| email | | name | +| password | | position | ++------------------------+ +------------------------+ + ++------------------------+ +------------------------+ +| django.contrib.auth.User| | UserProfile | ++------------------------+ +------------------------+ +| id |<----->| user | (one-to-one) +| username | | merchant | +| email | | description | +| password | +------------------------+ ++------------------------+ +``` + +## 2. API Endpoints + +### 2.1 Employee API Endpoints +Base URL: `/api_man/employees/` + +| Method | URL Pattern | Action | +|--------|-------------|--------| +| GET | `/api_man/employees/` | List all employees | +| GET | `/api_man/employees/{id}/` | Retrieve a single employee | +| POST | `/api_man/employees/` | Create a new employee | +| PUT | `/api_man/employees/{id}/` | Update an existing employee | +| PATCH | `/api_man/employees/{id}/` | Partially update an existing employee | +| DELETE | `/api_man/employees/{id}/` | Delete an employee | + +### 2.2 UserProfile API Endpoints +Base URL: `/api_man/user-profiles/` + +| Method | URL Pattern | Action | +|--------|-------------|--------| +| GET | `/api_man/user-profiles/` | List all user profiles | +| GET | `/api_man/user-profiles/{id}/` | Retrieve a single user profile | +| POST | `/api_man/user-profiles/` | Create a new user profile | +| PUT | `/api_man/user-profiles/{id}/` | Update an existing user profile | +| PATCH | `/api_man/user-profiles/{id}/` | Partially update an existing user profile | +| DELETE | `/api_man/user-profiles/{id}/` | Delete a user profile | + +## 3. Binding User to Employee + +### 3.1 During Employee Creation +When creating a new Employee, you can directly include the `sys_user` field with the User ID to bind them immediately. + +#### Request Body Example: +```json +{ + "name": "张三", + "position": 1, // EmployeeType ID + "mobile": "13800138000", + "status": "在职", + "sys_user": 123 // User ID to bind +} +``` + +#### Response Example: +```json +{ + "id": 456, + "name": "张三", + "position": { "id": 1, "name": "仓库管理员" }, + "mobile": "13800138000", + "status": "在职", + "sys_user": 123, // User ID is now bound + // Other fields... +} +``` + +### 3.2 During Employee Update +To bind an existing User to an existing Employee, use the PUT or PATCH method and include the `sys_user` field. + +#### PUT Request Body Example (Full Update): +```json +{ + "name": "张三", + "position": 1, + "mobile": "13800138000", + "status": "在职", + "sys_user": 123 // New User ID to bind +} +``` + +#### PATCH Request Body Example (Partial Update): +```json +{ + "sys_user": 123 // User ID to bind +} +``` + +#### Response Example: +```json +{ + "id": 456, + "name": "张三", + "position": { "id": 1, "name": "仓库管理员" }, + "mobile": "13800138000", + "status": "在职", + "sys_user": 123, // User ID is now bound + // Other fields... +} +``` + +### 3.3 Unbinding User from Employee +To unbind a User from an Employee, set the `sys_user` field to `null` using PUT or PATCH. + +#### PATCH Request Body Example: +```json +{ + "sys_user": null +} +``` + +#### Response Example: +```json +{ + "id": 456, + "name": "张三", + "position": { "id": 1, "name": "仓库管理员" }, + "mobile": "13800138000", + "status": "在职", + "sys_user": null, // User is now unbound + // Other fields... +} +``` + +## 4. UserProfile Management + +### 4.1 Creating a UserProfile +When creating a new UserProfile, you need to provide the User ID and Merchant ID. + +#### Request Body Example: +```json +{ + "user": 123, // User ID + "merchant": 1, // Merchant ID + "description": "User profile for system access" +} +``` + +#### Response Example: +```json +{ + "id": 789, + "user": { + "id": 123, + "username": "system_user", + "email": "user@example.com", + "is_active": true, + "is_superuser": false, + "last_login": "2025-11-24T12:34:56Z" + }, + "merchant": 1, + "description": "User profile for system access", + "created_at": "2025-11-24T13:00:00Z", + "updated_at": "2025-11-24T13:00:00Z" +} +``` + +### 4.2 Updating a UserProfile +To update an existing UserProfile, use the PUT or PATCH method. + +#### PATCH Request Body Example (Partial Update): +```json +{ + "description": "Updated user profile for system access" +} +``` + +#### Response Example: +```json +{ + "id": 789, + "user": { + "id": 123, + "username": "system_user", + "email": "user@example.com", + "is_active": true, + "is_superuser": false, + "last_login": "2025-11-24T12:34:56Z" + }, + "merchant": 1, + "description": "Updated user profile for system access", + "created_at": "2025-11-24T13:00:00Z", + "updated_at": "2025-11-24T13:30:00Z" +} +``` + +## 5. Validation Rules + +### 5.1 Employee-User Binding Rules +1. **One-to-one constraint**: A User can only be bound to one Employee at a time. +2. **Permission check**: Only super users or users with appropriate permissions can perform binding operations. +3. **User existence**: The provided User ID must exist in the system. +4. **Merchant isolation**: Both the User (via UserProfile) and Employee must belong to the same Merchant. + +### 5.2 UserProfile Rules +1. **One-to-one constraint**: A User can only have one UserProfile at a time. +2. **Permission check**: Only users with appropriate permissions can access UserProfiles from the same merchant. +3. **User existence**: The provided User ID must exist in the system. +4. **Merchant existence**: The provided Merchant ID must exist in the system. + +## 6. Error Handling + +### User already bound to another Employee +```json +{ + "sys_user": [ + "User with id 123 is already bound to an existing employee." + ] +} +``` + +### User already has a UserProfile +```json +{ + "user": [ + "User with id 123 already has a UserProfile." + ] +} +``` + +### Invalid User ID +```json +{ + "sys_user": [ + "Invalid pk \"999\" - object does not exist." + ] +} +``` + +### Permission denied +```json +{ + "detail": "无权限创建该对象" +} +``` + +## 7. Front-End Implementation Tips + +1. **User selection flow**: + - First fetch the list of available Users with active UserProfiles + - Then allow users to select from this list when binding to an Employee + +2. **State management**: + - After binding, update the UI to reflect the bound status + - Ensure consistency between User, UserProfile, and Employee data + +3. **Error handling**: + - Implement proper error messages based on the API responses + - Distinguish between binding errors and UserProfile management errors + +4. **Data consistency**: + - Ensure that the User and Employee belong to the same Merchant + - Verify that a User has a UserProfile before attempting to bind to an Employee + +## 8. Example Workflow + +### Full Workflow (Creating User, UserProfile, and Bound Employee) +1. **Create User**: First create a User via Django's authentication system. +2. **Fetch User ID**: Retrieve the User ID from the response. +3. **Create UserProfile**: Create a UserProfile and include the User ID and Merchant ID. +4. **Create Employee**: Create an Employee and include the User ID in the `sys_user` field to bind them. +5. **Verify binding**: Retrieve the Employee to confirm the User is bound. + +### Partial Workflow (Binding Existing User to Existing Employee) +1. **Fetch existing User**: Retrieve User IDs that have active UserProfiles. +2. **Fetch existing Employee**: Retrieve the Employee to bind to. +3. **Update Employee**: Update the Employee with the User ID to bind them. +4. **Verify binding**: Retrieve the Employee to confirm the User is bound. diff --git a/api_man/serializers.py b/api_man/serializers.py index 357df63..0aeb6fd 100644 --- a/api_man/serializers.py +++ b/api_man/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers from basic_info import models as basic_models +from django.contrib.auth.models import User import logging @@ -159,3 +160,23 @@ class VehicleTransportRecordSerializer(BaseSerializer): model = basic_models.VehicleTransportRecord fields = '__all__' depth = 1 # 展开外键关系 + + +class UserSerializerSimple(serializers.ModelSerializer): + class Meta: + model = basic_models.User + fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'last_login'] + + +class UserProfileSerializer(BaseSerializer): + user = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all() + ) + user_detail = UserSerializerSimple(source='user', read_only=True) + + class Meta: + model = basic_models.UserProfile + fields = ['id', 'user', 'user_detail', 'merchant', 'description', 'created_at', 'updated_at'] + extra_kwargs = { + 'merchant': {'required': False} # 创建时由视图层设置 + } diff --git a/api_man/tests.py b/api_man/tests.py index 4cc539f..7983e0f 100644 --- a/api_man/tests.py +++ b/api_man/tests.py @@ -303,3 +303,156 @@ class EmployeeAPITestCase(TestCase): response = self.client.patch(f'/api/backend/employees/{employee.id}/', data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['job_type'], '打纸工') # 应该保持不变 + + +class UserProfileAPITestCase(TestCase): + """测试用户资料 API""" + + def setUp(self): + """设置测试数据""" + # 创建商户 + self.merchant = Merchant.objects.create( + name='测试商户', + type=MerchantTypeEnum.STORE + ) + + # 创建用户和员工 + self.user = User.objects.create_user( + username='testuser', + password='testpass123', + email='test@example.com' + ) + self.employee = Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='测试员工' + ) + + # 创建另一个用户用于测试 + self.user2 = User.objects.create_user( + username='testuser2', + password='testpass123', + email='test2@example.com' + ) + + # 设置 API 客户端 + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_create_user_profile(self): + """测试创建用户资料""" + data = { + 'user': self.user2.id, + 'description': '测试用户资料描述' + } + response = self.client.post('/api/backend/user-profiles/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['user'], self.user2.id) + self.assertEqual(response.data['merchant'], self.merchant.id) # 应该由BaseViewSet自动设置 + self.assertEqual(response.data['description'], '测试用户资料描述') + + def test_list_user_profiles(self): + """测试列出所有用户资料""" + from basic_info.models import UserProfile + UserProfile.objects.create( + user=self.user, + merchant=self.merchant, + description='用户1资料' + ) + UserProfile.objects.create( + user=self.user2, + merchant=self.merchant, + description='用户2资料' + ) + + response = self.client.get('/api/backend/user-profiles/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.data['results'] if isinstance(response.data, dict) else response.data + self.assertEqual(len(results), 2) # 应该只返回当前商户的用户资料 + + def test_retrieve_user_profile(self): + """测试检索单个用户资料""" + from basic_info.models import UserProfile + profile = UserProfile.objects.create( + user=self.user, + merchant=self.merchant, + description='测试资料' + ) + + response = self.client.get(f'/api/backend/user-profiles/{profile.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['id'], profile.id) + self.assertEqual(response.data['description'], '测试资料') + self.assertEqual(response.data['user_detail']['username'], 'testuser') # 检查嵌套用户对象 + + def test_update_user_profile(self): + """测试更新用户资料""" + from basic_info.models import UserProfile + profile = UserProfile.objects.create( + user=self.user, + merchant=self.merchant, + description='原始描述' + ) + + data = {'description': '更新后的描述'} + response = self.client.patch(f'/api/backend/user-profiles/{profile.id}/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['description'], '更新后的描述') + + # 验证数据库 + profile.refresh_from_db() + self.assertEqual(profile.description, '更新后的描述') + + def test_delete_user_profile(self): + """测试删除用户资料""" + from basic_info.models import UserProfile + profile = UserProfile.objects.create( + user=self.user, + merchant=self.merchant, + description='待删除资料' + ) + + response = self.client.delete(f'/api/backend/user-profiles/{profile.id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(UserProfile.objects.filter(id=profile.id).exists()) + + def test_user_profile_filter_by_merchant(self): + """测试用户资料按商户过滤""" + # 创建另一个商户 + other_merchant = Merchant.objects.create( + name='其他商户', + type=MerchantTypeEnum.FACTORY + ) + + # 创建另一个商户的员工 + other_user = User.objects.create_user( + username='otheruser', + password='testpass123' + ) + other_employee = Employee.objects.create( + merchant=other_merchant, + sys_user=other_user, + name='其他员工' + ) + + # 为两个商户分别创建用户资料 + from basic_info.models import UserProfile + UserProfile.objects.create( + user=self.user, + merchant=self.merchant, + description='本商户用户资料' + ) + UserProfile.objects.create( + user=other_user, + merchant=other_merchant, + description='其他商户用户资料' + ) + + # 使用当前用户(属于self.merchant)访问API + response = self.client.get('/api/backend/user-profiles/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.data['results'] if isinstance(response.data, dict) else response.data + self.assertEqual(len(results), 1) # 只应该返回当前商户的用户资料 + self.assertEqual(results[0]['description'], '本商户用户资料') diff --git a/api_man/urls.py b/api_man/urls.py index a890a8f..b579219 100644 --- a/api_man/urls.py +++ b/api_man/urls.py @@ -38,6 +38,9 @@ device_info_router.register(prefix='', viewset=views.DeviceInfoViewSet) vehicle_transport_record_router = routers.DefaultRouter() vehicle_transport_record_router.register(prefix='', viewset=views.VehicleTransportRecordViewSet) +# UserProfile router +user_profile_router = routers.DefaultRouter() +user_profile_router.register(prefix='', viewset=views.UserProfileViewSet) urlpatterns = [ path('quick-inputs/', include(quick_input_router.urls)), @@ -52,4 +55,5 @@ urlpatterns = [ path('bank-accounts/', include(bank_account_router.urls)), path('device-info/', include(device_info_router.urls)), path('vehicle-transport-records/', include(vehicle_transport_record_router.urls)), -] + path('user-profiles/', include(user_profile_router.urls)), +] \ No newline at end of file diff --git a/api_man/views.py b/api_man/views.py index e27ea1e..db96570 100644 --- a/api_man/views.py +++ b/api_man/views.py @@ -99,3 +99,15 @@ class DeviceInfoViewSet(BaseViewSet): class VehicleTransportRecordViewSet(BaseViewSet): queryset = serializers.basic_models.VehicleTransportRecord.objects serializer_class = serializers.VehicleTransportRecordSerializer + + +class UserProfileViewSet(BaseViewSet): + queryset = serializers.basic_models.UserProfile.objects + serializer_class = serializers.UserProfileSerializer + + def filter_queryset(self, queryset): + try: + merchant = self.request.user.employee.merchant + return super().filter_queryset(queryset).filter(merchant=merchant) + except AttributeError: + raise PermissionDenied("无权限访问该对象") diff --git a/api_v1/migrations/0002_alter_uploadedfile_path.py b/api_v1/migrations/0002_alter_uploadedfile_path.py new file mode 100644 index 0000000..2540fa5 --- /dev/null +++ b/api_v1/migrations/0002_alter_uploadedfile_path.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2025-11-24 03:07 + +import api_v1.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api_v1', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='uploadedfile', + name='path', + field=models.FileField(help_text='文件存储路径,包含前缀、随机文件名和后缀', max_length=500, upload_to=api_v1.models.upload_file_path, verbose_name='文件路径'), + ), + ] diff --git a/basic_info/admin.py b/basic_info/admin.py index 0b0ebda..1778c81 100644 --- a/basic_info/admin.py +++ b/basic_info/admin.py @@ -38,6 +38,11 @@ class QuickInputAdmin(admin.ModelAdmin): list_filter = ('group',) +@admin.register(models.UserProfile) +class UserProfileAdmin(AdminBase): + list_display = ('user', 'merchant') + + @admin.register(models.Merchant) class MerchantAdmin(admin.ModelAdmin): list_display = ('name', 'type', 'email', 'mobile', 'auto_complete_stock_change') diff --git a/basic_info/migrations/0012_userprofile.py b/basic_info/migrations/0012_userprofile.py new file mode 100644 index 0000000..0a86b07 --- /dev/null +++ b/basic_info/migrations/0012_userprofile.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.7 on 2025-11-24 03:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0011_remove_employee_job_type_employeetype_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('description', models.TextField(blank=True, null=True, verbose_name='备注描述')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='user_profiles', to='basic_info.merchant', verbose_name='所属商户')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='关联用户')), + ], + options={ + 'verbose_name': '用户资料扩展', + 'verbose_name_plural': '用户资料扩展', + }, + ), + ] diff --git a/basic_info/models.py b/basic_info/models.py index f69c45f..d82a179 100644 --- a/basic_info/models.py +++ b/basic_info/models.py @@ -376,6 +376,30 @@ class Employee(ModelBase): verbose_name_plural = '员工资料' +class UserProfile(ModelBase): + id = models.BigAutoField(primary_key=True) + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name='profile', + verbose_name='关联用户', + ) + merchant = models.ForeignKey( + 'Merchant', + on_delete=models.PROTECT, + related_name='user_profiles', + verbose_name='所属商户', + ) + description = models.TextField(blank=True, null=True, verbose_name='备注描述') + + def __str__(self): + return f"{self.user.username}'s Profile" + + class Meta: + verbose_name = '用户资料扩展' + verbose_name_plural = '用户资料扩展' + + class DeviceInfo(ModelBase): id = models.BigAutoField(primary_key=True) merchant = models.ForeignKey('Merchant', on_delete=models.PROTECT, related_name='devices', verbose_name='所属商户') diff --git a/basic_info/views.py b/basic_info/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/basic_info/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/stock/migrations/0004_alter_stockchangerecord_source_type.py b/stock/migrations/0004_alter_stockchangerecord_source_type.py new file mode 100644 index 0000000..d624fa4 --- /dev/null +++ b/stock/migrations/0004_alter_stockchangerecord_source_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-24 03:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0003_remove_inventory_minimum_quantity'), + ] + + operations = [ + migrations.AlterField( + model_name='stockchangerecord', + name='source_type', + field=models.IntegerField(choices=[(1, '采购'), (2, '销退'), (3, '调入'), (4, '盘盈'), (5, '合并'), (6, '销售'), (7, '采购退货'), (8, '调出'), (9, '盘亏'), (10, '拆卷')], verbose_name='变动来源'), + ), + ]