📢 阅读提示

感谢你阅读本章。本文所有代码和注释均来自真实项目,为了能让更多认真学习的读者持续获得优质内容,本文将在7天后转为“粉丝可见”。
如果你觉得本文有价值,欢迎收藏并关注我,本系列博客的模型设计、权限函数、导入导出以及层级自动计算的实现等章节将继续保持同样详细的注释风格。
感谢理解与支持!

一、业务场景

第5章第6章我们完成了部门模型。现在,各层级系统管理员(包括保卫部领导、各科室干事、各级管理员等)按企业管理逻辑,都需要且必须关联到一个唯一部门,即人事关系所在部门,并记录其职位、电话、业务角色等信息。

Django 自带的 User 模型只包含 usernamepasswordemailfirst_namelast_name 等基础字段,无法直接存储:

  • 所属部门(department
  • 职位(position
  • 联系电话(phoneoffice_phone
  • 业务角色(business_role,如“主管领导”、“业务负责人”)
  • 负责业务类别(business_categories,如“综治保卫”、“武装”)

重要提示:本章节所讨论的 UserUserProfile 内容,仅针对系统管理用户(即需要登录系统进行项目内容维护和查询浏览工作的管理人员)。企业全体员工的基础信息(姓名、身份证号、入职日期等)会在“员工信息管理”(EmployeeBasicInfo)模块中独立维护。二者分工明确:UserProfile 负责系统管理员的登录和权限,EmployeeBasicInfo 负责员工基本信息存储。

二、业务要求

  1. 每个系统用户(User)有且仅有一个 UserProfile(一对一关系)。
  2. UserProfile 必须能关联到 Department(部门),且当部门被删除时,UserProfile.department 设为 NULL(不清空用户资料,只是解除关联)。
  3. 记录用户的职位、办公电话、移动电话、房间号、员工编号等基本信息。
  4. 支持“业务角色”和“负责业务”的配置,用于后续权限判断和工作流。
  5. 软删除 UserProfile 时,同时禁用对应的 User 账号(is_active=False),让他无法登录。
  6. 通过信号(post_save)自动为每个新创建的 User 生成对应的 UserProfile,避免手动创建遗漏。

三、模型设计(代码 + 逐行注释)

以下代码来自我们实际运行的 organization/models.py。为了便于理解,我们将常量定义、模型字段、方法、信号全部放在一起,并加上详尽的中文注释。

# organization/models.py(续第6章)

from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from django.db.models.signals import post_save
from django.dispatch import receiver

# ============================================================
# 1. 业务角色和业务类别的常量定义(用于下拉选项)
# ============================================================

# 业务角色选择列表:存储的是代码(如 'supervisor'),显示的是中文。
# 第一个空选项是让用户不选,防止默认值错误。
BUSINESS_ROLE_CHOICES = [
    ('', '-- 请选择角色 --'),          # 空选项
    ('supervisor', '主管领导'),        # 最高决策者
    ('department_head', '部室负责人'), # 部门管理者
    ('business_head', '业务负责人'),   # 具体业务的负责人
    ('staff', '业务干事'),             # 普通业务执行人
]

# 业务类别选择列表:用户可多选的业务范围。
BUSINESS_CATEGORY_CHOICES = [
    ('security', '综治保卫'),
    ('armed', '武装'),
    ('anti_cult', '防范邪教'),
]

# ============================================================
# 2. UserProfile 模型(扩展用户资料)
# ============================================================

class UserProfile(models.Model):
    """
    系统用户资料扩展模型,与 Django 自带的 User 一对一关联。
    用于存储登录使用本系统的业务人员的相关信息。
    注意:不是存储企业全体员工的信息!
    """
    
    # ---------- 与 User 的一对一关联 ----------
    user = models.OneToOneField(
        User,                           # 关联到 Django 自带的 User 模型
        on_delete=models.CASCADE,       # 如果 User 被删除,这个 UserProfile 也一并删除
        related_name='profile',         # 可以通过 user.profile 来访问,比 user.userprofile 更好记
        verbose_name="关联用户"
    )
    # 解释:每个 User 只能有一个 UserProfile,反过来每个 UserProfile 只能属于一个 User。
    #      这就是 OneToOneField 的含义。
    
    # ---------- 部门关联 ----------
    department = models.ForeignKey(
        'Department',                   # 关联到 Department 模型(字符串形式避免循环导入)
        on_delete=models.SET_NULL,      # 如果部门被删了,这里不删用户,只是把 department 字段设为 NULL
        null=True,                      # 允许为空(表示暂无部门)
        blank=True,                     # 表单中可以不填
        verbose_name="所属单位",
        related_name='user_profiles'    # 可以通过 department.user_profiles 获取该部门下的所有系统用户
    )
    # 解释:一个部门可以有多个系统用户,反过来一个系统用户只能属于一个部门(外键)。
    #      on_delete=models.SET_NULL 很关键:部门被删了,用户资料还在,只是部门变成空。
    #      如果部门是软删除(is_deleted=True),那么 department 字段仍然指向它(因为不是物理删除)。
    
    # ---------- 基本资料 ----------
    position = models.CharField(
        max_length=50,
        blank=True,                     # 允许为空,有些人可能没有正式职位
        verbose_name="职位"
    )
    # 例如:“治安科科长”、“内保干事”
    
    phone = models.CharField(
        max_length=20,
        blank=True,
        verbose_name="移动电话"
    )
    # 手机号
    
    office_phone = models.CharField(
        max_length=20,
        blank=True,
        verbose_name="办公电话"
    )
    # 座机号
    
    room_number = models.CharField(
        max_length=20,
        blank=True,
        verbose_name="房间号"
    )
    # 办公室房间号,方便联系
    
    employee_id = models.CharField(
        max_length=20,
        blank=True,
        verbose_name="员工唯一编号"
    )
    # 公司内部员工编号(可选项,与 EmployeeBasicInfo 的 employee_number 应对应,但不强制)
    
    # ---------- 权限标记 ----------
    is_department_admin = models.BooleanField(
        default=False,
        verbose_name="单位管理员"
    )
    # 如果勾选,该用户将成为单位管理员,可以管理本部门及下级部门的用户、数据等。
    # 这是一个业务层面的角色标记,不是 Django 的超级用户。
    
    notes = models.TextField(
        blank=True,
        verbose_name="备注信息"
    )
    # 随便写点什么,比如“该用户已调离,暂留账号”等。
    
    # ---------- 业务角色(核心)----------
    business_role = models.CharField(
        max_length=50,
        choices=BUSINESS_ROLE_CHOICES,   # 上面定义的下拉选项
        blank=True,
        verbose_name="业务角色",
        help_text="用户的主要业务角色"
    )
    # 数据库中存储的是 'supervisor' 这样的代码,不是中文。
    # 显示中文时,调用 get_business_role_display() 方法即可。
    
    business_categories = models.CharField(
        max_length=100,
        blank=True,
        default='',
        verbose_name="负责业务",
        help_text="逗号分隔的业务代码,如:security,armed"
    )
    # 存储的是逗号分隔的业务代码,例如 "security,armed"。
    # 为什么不用 ManyToManyField?因为业务类别只有固定的几个,且查询简单,避免创建中间表。
    # 缺点是不能直接用 SQL 做高效的 IN 查询,但我们可以用 __contains 模糊查询,够用了。
    
    # ---------- 软删除字段(与 Department 保持一致)----------
    is_deleted = models.BooleanField(default=False, verbose_name="是否已删除")
    deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="删除时间")
    deleted_by = models.ForeignKey(
        User,
        on_delete=models.SET_NULL,       # 如果删除者的账号后来被删了,这里保留 NULL 即可
        null=True,
        blank=True,
        related_name='deleted_user_profiles',
        verbose_name="删除操作者"
    )
    delete_reason = models.TextField(blank=True, verbose_name="删除原因")
    
    # ---------- 审计字段 ----------
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
    # auto_now_add=True:第一次保存时自动设为当前时间,之后不再改变。
    updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
    # auto_now=True:每次调用 save() 都会自动更新为当前时间。
    
    # ---------- 管理器(复用 SoftDeleteManager)----------
    objects = SoftDeleteManager()      # 默认查询自动过滤 is_deleted=True 的记录
    all_objects = models.Manager()     # 不过滤,管理员可以看所有记录
    
    class Meta:
        verbose_name = "系统用户资料"
        verbose_name_plural = "系统用户资料"
        ordering = ['department', 'user__username']   # 先按部门排序,再按用户名排序
    
    def __str__(self):
        dept_name = self.department.name if self.department else "无部门"
        status = " (已删除)" if self.is_deleted else ""
        return f"{self.user.username} - {dept_name}{status}"
    
    # ---------- 业务角色的辅助方法 ----------
    def get_business_role_display(self):
        """返回业务角色的中文显示名称,例如将 'supervisor' 转成 '主管领导'"""
        if self.business_role:
            # dict(BUSINESS_ROLE_CHOICES) 把元组列表转成字典,然后用代码取值
            return dict(BUSINESS_ROLE_CHOICES).get(self.business_role, self.business_role)
        return "无"
    
    def get_business_categories_display(self):
        """
        返回负责业务的中文显示字符串,例如 "security,armed" -> "综治保卫、武装"
        """
        if not self.business_categories:
            return "无"
        categories = []
        # 按逗号拆分成代码列表
        for code in self.business_categories.split(','):
            code = code.strip()
            cat_map = dict(BUSINESS_CATEGORY_CHOICES)
            if code in cat_map:
                categories.append(cat_map[code])
        return "、".join(categories) if categories else "无"
    
    def get_business_categories_list(self):
        """
        返回负责业务的代码列表,例如 "security,armed" -> ['security', 'armed']
        用于在表单多选框里回显已选中的选项
        """
        if not self.business_categories:
            return []
        return [code.strip() for code in self.business_categories.split(',') if code.strip()]
    
    def set_business_categories_list(self, categories_list):
        """
        从代码列表设置逗号分隔字符串,例如 ['security', 'armed'] -> "security,armed"
        """
        self.business_categories = ','.join([str(code).strip() for code in categories_list])
    
    def has_business_category(self, category_code):
        """判断用户是否负责某项业务,例如 has_business_category('security')"""
        return category_code in self.get_business_categories_list()
    
    # ---------- 软删除和恢复(重写父类方法,加入禁用/启用 User)----------
    def soft_delete(self, deleted_by=None, reason=""):
        """
        软删除当前用户资料,同时禁用对应的 User 账号(无法登录)
        """
        self.is_deleted = True
        self.deleted_at = timezone.now()
        self.deleted_by = deleted_by
        self.delete_reason = reason
        self.save()
        # 重要:将关联的 User 设为不可用
        self.user.is_active = False
        self.user.save()
    
    def restore(self):
        """
        恢复软删除的用户资料,同时重新启用对应的 User 账号
        """
        self.is_deleted = False
        self.deleted_at = None
        self.deleted_by = None
        self.delete_reason = ""
        self.save()
        self.user.is_active = True
        self.user.save()
    
    # ---------- 删除权限检查(用在 Admin 删除确认时)----------
    def can_be_deleted_by(self, user):
        """
        检查当前操作者(user)是否有权限删除这个 UserProfile。
        返回 (can_delete, error_message) 元组。
        """
        # 超级用户拥有至高权限
        if user.is_superuser:
            return True, ""
        
        # 操作者必须本身有 UserProfile 且是部门管理员
        try:
            operator_profile = user.profile
        except UserProfile.DoesNotExist:
            return False, "您的账户没有关联的用户资料"
        
        if not operator_profile.is_department_admin:
            return False, "您没有删除用户的权限"
        
        if not operator_profile.department:
            return False, "您没有关联的部门,无法删除用户"
        
        # 被删除的用户必须有关联的部门
        if not self.department:
            return False, "无法删除无部门的用户"
        
        # 检查被删除用户是否在操作者的管理范围内(操作者部门及其所有下级部门)
        allowed_dept_ids = operator_profile.department.get_descendant_ids(include_self=True)
        if self.department.id not in allowed_dept_ids:
            return False, "您只能删除本部门及下级部门的用户"
        
        # 不能删除其他管理员或超级用户(权限安全)
        if self.is_department_admin or self.user.is_superuser:
            return False, "您不能删除管理员或超级用户"
        
        return True, ""
    
    # ---------- 部门层级显示(用于 Admin 列表)----------
    def get_department_hierarchy(self):
        """
        返回用户的完整部门路径,例如 “公司 > 保卫部 > 治安科”
        """
        if not self.department:
            return "无部门"
        hierarchy = []
        dept = self.department
        while dept:
            hierarchy.insert(0, dept.name)   # 插入到列表头部,实现从根到当前
            dept = dept.parent
        return " > ".join(hierarchy)


# ============================================================
# 3. 信号:自动创建 UserProfile
# ============================================================

@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    """
    监听 User 模型的保存事件。当 User 被创建(created=True)时,
    自动创建一个空的 UserProfile 并关联它。
    """
    if created:
        UserProfile.objects.create(user=instance)

@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
    """
    监听 User 模型的保存事件。当 User 被更新时,尝试保存关联的 UserProfile。
    如果 UserProfile 不存在(比如被删除了或没创建),则重新创建。
    """
    try:
        instance.profile.save()
    except UserProfile.DoesNotExist:
        UserProfile.objects.create(user=instance)

四、核心方法详解

1. OneToOneField 一对一关联

user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')

· 作用:每个 User 只能关联一个 UserProfile,UserProfile 只能属于一个 User。
· related_name=‘profile’:可以通过 user.profile 访问,比默认的 user.userprofile 更直观。
· on_delete=models.CASCADE:如果 User 被删除,对应的 UserProfile 自动删除。因为用户都没了,资料也没意义。

2. 业务角色和业务类别存储方案

业务角色(单选):使用 CharField + choices,存储代码(如 ‘supervisor’),显示时通过 get_business_role_display() 获取中文。这是 Django 标准做法,数据库占用空间小,且查询效率高。

业务类别(多选):使用 CharField 存储逗号分隔的代码。提供了三个辅助方法:

· get_business_categories_display():转成中文显示。
· get_business_categories_list():转成 Python 列表,方便在表单多选框里回显。
· set_business_categories_list():从列表设置存储字符串。

为什么不使用 ManyToManyField?因为业务类别只有3种,不太会变。用逗号分隔避免了额外创建中间表,代码更简单。缺点是查询包含某类别的用户时,需要用 business_categories__contains=‘security’,不够精确,但项目规模不大时可以接受。

3. 软删除时禁用 User 账号

def soft_delete(self, deleted_by=None, reason=""):
    self.is_deleted = True
    ...
    self.user.is_active = False
    self.user.save()

· 当用户资料被软删除时,系统应该禁止该用户登录。所以调用 self.user.is_active = False 并保存。
· 恢复时再重新启用。

4. 删除权限检查 can_be_deleted_by

这个方法不是 Django 自动调用的,而是在 Admin 自定义删除视图中调用的。它定义了谁可以删除用户资料:

· 超级用户可以删除任何人。
· 部门管理员只能删除自己部门及下级部门的非管理员用户。
· 不能删除其他管理员或超级用户。

这符合企业管理的安全原则。

5. 信号 post_save 自动创建 UserProfile

@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    if created:
        UserProfile.objects.create(user=instance)

· 当通过 Admin 或任何代码创建新 User 时,会自动生成对应的 UserProfile。
· 第二个信号 save_user_profile 是为了保证 User 保存时,其关联的 UserProfile 也能被保存(如果存在)。这是一个兜底措施。

五、注意事项(踩坑经验)

  1. 信号可能重复创建:post_save 信号在 UserProfile 保存时也可能触发 User.save(),但我们的第二个信号会捕获 UserProfile.DoesNotExist,不会无限循环。不过建议在第一个信号中只处理 created=True,第二个信号可以省略或增加更严格的条件。
  2. related_name 冲突:必须保证 Department.user_profiles 和 UserProfile.department 的反向名没有与其他模块冲突。目前唯一。
  3. 软删除与 User.is_active 的同步:如果某天你直接通过 UserProfile.objects.update(is_deleted=True) 批量软删除,不会触发 soft_delete() 方法,也就不会禁用 User。建议始终调用 soft_delete() 方法。
  4. can_be_deleted_by 中的部门范围:get_descendant_ids(include_self=True) 包含了自身部门及所有下级。这是正确的,因为管理员可以管理下级部门人员。
  5. 逗号分隔业务的局限性:如果将来需要查询“同时负责 security 和 armed 的用户”,用 business_categories__contains=‘security’ 和 …__contains=‘armed’ 可以组合,但效率较低。如果业务类别增多到5个以上,建议改为 ManyToManyField。
  6. 区分系统用户和普通员工:再次强调,UserProfile 只服务于能够登录并使用本系统的管理者。全体员工的信息将由 EmployeeBasicInfo 独立管理。两个模型之间没有强制外键,但可以通过 employee_id 字段对应。这样设计符合“账号管理与人事管理分离”的原则。

六、小结-如您对本博客系列内容感兴趣,请点击关注,后续还有多章内容待解锁与您分享和互动

本章完成了系统用户资料扩展模型:

· 通过 OneToOneField 关联 Django 原生 User,添加了业务字段。
· 支持部门外键、职位、联系方式、业务角色和业务类别。
· 实现了与部门相同的软删除、审计字段和管理器。
· 提供了业务角色的辅助方法和删除权限检查。
· 使用信号自动为新建用户创建 UserProfile。

自此,系统管理者与部门的关联已经打通,为后续的权限判断(get_viewable_departments)和 Admin 定制打下了基础。

下一章我们将进入 公共权限函数,讲解如何实现基于部门树的数据隔离和主管部门权限提升。


互动问题:在你的项目中,你是如何区分“系统用户”和“普通员工”的?是像我们这样分开两个模型,还是统一使用 User 再通过 is_staff 区分?欢迎评论区交流。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐