diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 07bef71e..783800c8 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -38,12 +38,13 @@ "RunOnceActivity.OpenDjangoStructureViewOnStart": "true", "RunOnceActivity.ShowReadmeOnStart": "true", "RunOnceActivity.git.unshallow": "true", - "git-widget-placeholder": "zyl__branch", + "git-widget-placeholder": "master", "ignore.virus.scanning.warn.message": "true", "node.js.detected.package.eslint": "true", "node.js.detected.package.tslint": "true", "node.js.selected.package.eslint": "(autodetect)", "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", "vue.rearranger.settings.migration": "true" } }]]> @@ -90,6 +91,7 @@ 1760256904468 + diff --git a/src/accounts/accounts/__init__.py b/src/accounts/accounts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/accounts/accounts/__pycache__/__init__.cpython-310.pyc b/src/accounts/accounts/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 00000000..b1777587 Binary files /dev/null and b/src/accounts/accounts/__pycache__/__init__.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/admin.cpython-310.pyc b/src/accounts/accounts/__pycache__/admin.cpython-310.pyc new file mode 100644 index 00000000..14e3399a Binary files /dev/null and b/src/accounts/accounts/__pycache__/admin.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/apps.cpython-310.pyc b/src/accounts/accounts/__pycache__/apps.cpython-310.pyc new file mode 100644 index 00000000..f5bfa32b Binary files /dev/null and b/src/accounts/accounts/__pycache__/apps.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/forms.cpython-310.pyc b/src/accounts/accounts/__pycache__/forms.cpython-310.pyc new file mode 100644 index 00000000..1ee6bd7d Binary files /dev/null and b/src/accounts/accounts/__pycache__/forms.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/models.cpython-310.pyc b/src/accounts/accounts/__pycache__/models.cpython-310.pyc new file mode 100644 index 00000000..9420b88f Binary files /dev/null and b/src/accounts/accounts/__pycache__/models.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/urls.cpython-310.pyc b/src/accounts/accounts/__pycache__/urls.cpython-310.pyc new file mode 100644 index 00000000..06f8bc05 Binary files /dev/null and b/src/accounts/accounts/__pycache__/urls.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/user_login_backend.cpython-310.pyc b/src/accounts/accounts/__pycache__/user_login_backend.cpython-310.pyc new file mode 100644 index 00000000..92350098 Binary files /dev/null and b/src/accounts/accounts/__pycache__/user_login_backend.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/utils.cpython-310.pyc b/src/accounts/accounts/__pycache__/utils.cpython-310.pyc new file mode 100644 index 00000000..72874a87 Binary files /dev/null and b/src/accounts/accounts/__pycache__/utils.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/views.cpython-310.pyc b/src/accounts/accounts/__pycache__/views.cpython-310.pyc new file mode 100644 index 00000000..d2002e2b Binary files /dev/null and b/src/accounts/accounts/__pycache__/views.cpython-310.pyc differ diff --git a/src/accounts/accounts/admin.py b/src/accounts/accounts/admin.py new file mode 100644 index 00000000..1a6c0345 --- /dev/null +++ b/src/accounts/accounts/admin.py @@ -0,0 +1,101 @@ +from django import forms +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.forms import UserChangeForm +from django.contrib.auth.forms import UsernameField +from django.utils.translation import gettext_lazy as _ + +# 模块级注释——accounts应用的Admin后台表单配置文件, +# 自定义BlogUser(自定义用户模型)的创建表单、修改表单,以及Admin后台管理配置, +# 适配Django Admin的用户管理逻辑,支持自定义字段(如nickname、source)的后台操作 +from .models import BlogUser + + +class BlogUserCreationForm(forms.ModelForm): + """ + 自定义用户创建表单(用于Django Admin后台添加新用户) + 扩展默认表单,增加密码二次验证逻辑,适配BlogUser模型的字段要求 + """ + # 密码输入字段:label支持国际化,使用密码输入控件(隐藏输入内容) + password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) + # 密码二次确认字段:用于验证两次输入密码一致 + password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) + + class Meta: + model = BlogUser # 关联的模型:accounts应用的BlogUser(自定义用户模型) + fields = ('email',) # 后台创建用户时,必填的核心字段(仅邮箱,用户名可后续补充或自动生成) + + def clean_password2(self): + """ + 密码二次验证的清洁方法(Django表单验证机制) + 检查两次输入的密码是否一致,不一致则抛出验证错误 + """ + # 获取第一次和第二次输入的密码(已通过表单基础验证的清洁数据) + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + # 若两次密码均存在且不一致,抛出验证错误(错误信息支持国际化) + if password1 and password2 and password1 != password2: + raise forms.ValidationError(_("passwords do not match")) + # 验证通过,返回第二次输入的密码(用于后续保存) + return password2 + + def save(self, commit=True): + """ + 重写保存方法,实现密码哈希存储和创建来源标记 + Django默认会对密码进行哈希处理,此处明确调用set_password确保安全性 + """ + # 调用父类save方法,先不提交到数据库(commit=False) + user = super().save(commit=False) + # 对密码进行哈希处理后存储(避免明文存储,符合Django安全规范) + user.set_password(self.cleaned_data["password1"]) + # 若需要提交到数据库(默认commit=True) + if commit: + user.source = 'adminsite' # 标记用户创建来源:Django Admin后台 + user.save() # 最终保存用户数据到数据库 + return user + + +class BlogUserChangeForm(UserChangeForm): + """ + 自定义用户修改表单(用于Django Admin后台编辑用户信息) + 继承Django内置UserChangeForm,适配BlogUser模型的所有字段 + """ + class Meta: + model = BlogUser # 关联的模型:accounts应用的BlogUser + fields = '__all__' # 后台可修改的字段:所有模型字段(支持自定义扩展字段) + field_classes = {'username': UsernameField} # 用户名字段的类:使用Django内置UsernameField(确保符合用户名验证规则) + + def __init__(self, *args, **kwargs): + """ + 初始化表单,调用父类构造方法保持默认逻辑 + 若后续需要扩展表单初始化行为(如隐藏字段、设置默认值),可在此方法中添加 + """ + super().__init__(*args, **kwargs) + + +class BlogUserAdmin(UserAdmin): + """ + 自定义UserAdmin配置(用于Django Admin后台管理BlogUser模型) + 配置后台显示字段、排序规则、搜索字段等,优化用户管理体验 + """ + form = BlogUserChangeForm # 关联用户修改表单:使用自定义的BlogUserChangeForm + add_form = BlogUserCreationForm # 关联用户创建表单:使用自定义的BlogUserCreationForm + + # 后台列表页显示的字段(按业务优先级排序) + list_display = ( + 'id', # 用户ID(唯一标识) + 'nickname', # 用户昵称(自定义扩展字段) + 'username', # 用户名(Django用户模型核心字段) + 'email', # 邮箱(用于登录和通知,核心字段) + 'last_login', # 最后登录时间(安全审计字段) + 'date_joined', # 注册时间(业务统计字段) + 'source' # 创建来源(区分后台创建/前台注册等,自定义扩展字段) + ) + + # 后台列表页可点击跳转的字段(用于快速进入编辑页) + list_display_links = ('id', 'username') + + # 后台列表页默认排序规则:按ID倒序(新创建的用户排在前面) + ordering = ('-id',) + + # 后台搜索支持的字段(支持模糊查询,提升管理效率) + search_fields = ('username', 'nickname', 'email') \ No newline at end of file diff --git a/src/accounts/accounts/apps.py b/src/accounts/accounts/apps.py new file mode 100644 index 00000000..92080f74 --- /dev/null +++ b/src/accounts/accounts/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + +# 模块级注释——accounts应用的核心配置类文件, +# 继承Django内置的AppConfig,用于定义应用的基本元信息, +# 是Django识别和加载accounts应用的关键配置 +class AccountsConfig(AppConfig): + """ + accounts应用的配置类,用于注册应用的核心信息 + 遵循Django应用配置规范,定义应用的唯一标识名称 + """ + name = 'accounts' # 应用的唯一标识名称,与应用目录名一致, + # Django通过该名称识别并加载应用,关联应用内的模型、视图等组件 \ No newline at end of file diff --git a/src/accounts/accounts/forms.py b/src/accounts/accounts/forms.py new file mode 100644 index 00000000..62f5a88f --- /dev/null +++ b/src/accounts/accounts/forms.py @@ -0,0 +1,166 @@ +from django import forms +from django.contrib.auth import get_user_model, password_validation +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.core.exceptions import ValidationError +from django.forms import widgets +from django.utils.translation import gettext_lazy as _ +from . import utils # 导入自定义工具模块(用于验证码验证等功能) +from .models import BlogUser # 导入自定义用户模型 + +# 模块级注释——accounts应用的前台用户交互表单配置文件, +# 包含登录、注册、忘记密码、验证码获取等核心业务表单, +# 负责用户输入数据的验证、前端样式适配(如表单控件class、占位符), +# 确保用户输入合法且符合业务规则(如邮箱唯一性、密码强度、验证码有效性) + + +class LoginForm(AuthenticationForm): + """ + 前台用户登录表单,继承Django内置AuthenticationForm + 重写表单控件样式和占位符,适配前端页面布局,提升用户体验 + """ + def __init__(self, *args, **kwargs): + """ + 初始化登录表单,重写用户名和密码字段的控件配置 + """ + super(LoginForm, self).__init__(*args, **kwargs) + # 用户名输入框:设置占位符和Bootstrap表单样式类,适配前端页面 + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + # 密码输入框:设置占位符和Bootstrap表单样式类,使用密码隐藏控件 + self.fields['password'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + + +class RegisterForm(UserCreationForm): + """ + 前台用户注册表单,继承Django内置UserCreationForm + 扩展邮箱字段验证,重写表单控件样式,确保注册数据合法(用户名、邮箱唯一) + """ + def __init__(self, *args, **kwargs): + """ + 初始化注册表单,重写用户名、邮箱、密码字段的控件配置 + """ + super(RegisterForm, self).__init__(*args, **kwargs) + + # 用户名输入框:占位符+Bootstrap样式 + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + # 邮箱输入框:使用EmailInput控件,占位符+Bootstrap样式 + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) + # 密码输入框1:密码隐藏控件,占位符+Bootstrap样式 + self.fields['password1'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + # 密码确认框:密码隐藏控件,占位符+Bootstrap样式 + self.fields['password2'].widget = widgets.PasswordInput( + attrs={'placeholder': "repeat password", "class": "form-control"}) + + def clean_email(self): + """ + 邮箱字段清洁验证:检查邮箱是否已被注册 + 若已存在则抛出验证错误,确保邮箱唯一性 + """ + email = self.cleaned_data['email'] # 获取经过基础验证的邮箱数据 + # 查询数据库,判断该邮箱是否已关联用户 + if get_user_model().objects.filter(email=email).exists(): + raise ValidationError(_("email already exists")) # 抛出国际化的验证错误 + return email # 验证通过,返回邮箱数据 + + class Meta: + model = get_user_model() # 关联Django当前激活的用户模型(此处为BlogUser) + fields = ("username", "email") # 注册表单需填写的核心字段:用户名、邮箱(密码字段由父类提供) + + +class ForgetPasswordForm(forms.Form): + """ + 前台用户忘记密码重置表单 + 包含新密码、密码确认、邮箱、验证码字段,实现密码重置的全流程验证 + """ + new_password1 = forms.CharField( + label=_("New password"), # 字段标签(支持国际化) + widget=forms.PasswordInput( + attrs={ + "class": "form-control", # Bootstrap表单样式类 + 'placeholder': _("New password") # 占位符(支持国际化) + } + ), + ) + + new_password2 = forms.CharField( + label="确认密码", # 密码确认字段标签 + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("Confirm password") # 占位符(支持国际化) + } + ), + ) + + email = forms.EmailField( + label='邮箱', # 邮箱字段标签 + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Email") # 占位符(支持国际化) + } + ), + ) + + code = forms.CharField( + label=_('Code'), # 验证码字段标签(支持国际化) + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Code") # 占位符(支持国际化) + } + ), + ) + + def clean_new_password2(self): + """ + 密码确认字段清洁验证: + 1. 检查两次输入的新密码是否一致 + 2. 验证密码是否符合Django密码强度规则(如长度、复杂度) + """ + password1 = self.data.get("new_password1") # 获取第一次输入的新密码 + password2 = self.data.get("new_password2") # 获取第二次输入的确认密码 + # 验证两次密码是否一致 + if password1 and password2 and password1 != password2: + raise ValidationError(_("passwords do not match")) # 抛出密码不匹配错误 + # 调用Django内置密码验证器,检查密码强度 + password_validation.validate_password(password2) + return password2 # 验证通过,返回确认密码 + + def clean_email(self): + """ + 邮箱字段清洁验证:检查输入的邮箱是否已注册 + 若未注册则抛出错误,确保只有已注册用户能重置密码 + """ + user_email = self.cleaned_data.get("email") # 获取经过基础验证的邮箱 + # 查询数据库,判断邮箱是否关联BlogUser + if not BlogUser.objects.filter(email=user_email).exists(): + # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改(原代码注释保留,提示后续优化隐私保护) + raise ValidationError(_("email does not exist")) # 抛出邮箱未注册错误 + return user_email # 验证通过,返回邮箱 + + def clean_code(self): + """ + 验证码字段清洁验证:调用utils模块的verify方法验证验证码有效性 + 若验证码无效(如过期、不匹配)则抛出错误 + """ + code = self.cleaned_data.get("code") # 获取用户输入的验证码 + # 调用工具函数验证验证码(传入邮箱和验证码,返回错误信息或None) + error = utils.verify(email=self.cleaned_data.get("email"), code=code) + if error: # 若存在错误信息,抛出验证错误 + raise ValidationError(error) + return code # 验证通过,返回验证码 + + +class ForgetPasswordCodeForm(forms.Form): + """ + 前台用户获取忘记密码验证码的表单 + 仅包含邮箱字段,用于接收用户邮箱并发送验证码 + """ + email = forms.EmailField( + label=_('Email'), # 字段标签(支持国际化) + ) \ No newline at end of file diff --git a/src/accounts/accounts/migrations/0001_initial.py b/src/accounts/accounts/migrations/0001_initial.py new file mode 100644 index 00000000..23ba59f7 --- /dev/null +++ b/src/accounts/accounts/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + +#模块级注释——comments应用的初始数据库迁移文件,用于创建`Comment`模型,实现文章评论功能,支持评论层级、文章关联、用户关联等业务逻辑 +class Migration(migrations.Migration): + + initial = True # xxx: 标记该迁移为应用的初始迁移 + + dependencies = [ + ('blog', '0001_initial'), # xxx: 依赖`blog`应用的`0001_initial`迁移,确保`Article`模型已存在 + migrations.swappable_dependency(settings.AUTH_USER_MODEL), # xxx: 依赖Django可交换的用户模型,支持自定义用户模型场景 + ] + + operations = [ + migrations.CreateModel( + name='Comment', # xxx: 定义`Comment`模型,用于存储文章评论数据 + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # 主键字段,自增大整数类型 + ('body', models.TextField(max_length=300, verbose_name='正文')), # xxx: 评论正文字段,文本类型,最大长度300 + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # 评论创建时间字段,默认当前时间 + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # xxx: 评论修改时间字段,默认当前时间 + ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), # xxx: 控制评论是否显示的布尔字段,默认显示 + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')), # xxx: 外键关联`blog`应用的`Article`模型,文章删除时评论级联删除 + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), # xxx: 外键关联用户模型,用户删除时评论级联删除 + ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')), # xxx: 自外键关联,支持评论层级结构,允许空值,上级评论删除时当前评论级联删除 + ], + options={ + 'verbose_name': '评论', # xxx: 模型单数显示名称 + 'verbose_name_plural': '评论', # xxx: 模型复数显示名称 + 'ordering': ['-id'], # xxx: 数据查询时按ID倒序排列 + 'get_latest_by': 'id', # xxx: 按ID字段获取最新记录 + }, + ), + ] \ No newline at end of file diff --git a/src/accounts/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/accounts/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py new file mode 100644 index 00000000..0c8bccc9 --- /dev/null +++ b/src/accounts/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:13 + +from django.db import migrations, models +import django.utils.timezone + +# 模块级注释——accounts应用的模型更新迁移文件,用于调整`BlogUser`模型的选项、字段名称及属性, +# 优化字段命名规范(如时间字段命名统一)、完善字段配置(如允许空值),确保模型设计更规范 +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), # 依赖`accounts`应用的初始迁移`0001_initial`,确保`BlogUser`模型已创建 + ] + + operations = [ + migrations.AlterModelOptions( + name='bloguser', # 目标模型:`accounts`应用的`BlogUser`(自定义用户模型) + options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'}, + # 调整模型选项: + # 1. get_latest_by: 按`id`字段获取最新记录 + # 2. ordering: 查询时按`id`倒序排列(新用户在前) + # 3. verbose_name/verbose_name_plural: 模型单复数显示名称均为"user" + ), + migrations.RemoveField( + model_name='bloguser', + name='created_time', # 删除原有的"创建时间"字段(字段名称规范调整,后续用`creation_time`替代) + ), + migrations.RemoveField( + model_name='bloguser', + name='last_mod_time', # 删除原有的"修改时间"字段(字段名称规范调整,后续用`last_modify_time`替代) + ), + migrations.AddField( + model_name='bloguser', + name='creation_time', # 新增标准化的"创建时间"字段(替代原`created_time`) + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + # 字段配置:默认值为当前时间,后台显示名称为"creation time" + ), + migrations.AddField( + model_name='bloguser', + name='last_modify_time', # 新增标准化的"修改时间"字段(替代原`last_mod_time`) + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + # 字段配置:默认值为当前时间,后台显示名称为"last modify time" + ), + migrations.AlterField( + model_name='bloguser', + name='nickname', # 调整`nickname`(昵称)字段属性 + field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), + # 调整内容:允许空值(blank=True),最大长度100,后台显示名称为"nick name" + ), + migrations.AlterField( + model_name='bloguser', + name='source', # 调整`source`(创建来源)字段属性 + field=models.CharField(blank=True, max_length=100, verbose_name='create source'), + # 调整内容:允许空值(blank=True),最大长度100,后台显示名称为"create source" + ), + ] \ No newline at end of file diff --git a/src/accounts/accounts/migrations/__init__.py b/src/accounts/accounts/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/accounts/accounts/migrations/__pycache__/0001_initial.cpython-310.pyc b/src/accounts/accounts/migrations/__pycache__/0001_initial.cpython-310.pyc new file mode 100644 index 00000000..7f3ec25b Binary files /dev/null and b/src/accounts/accounts/migrations/__pycache__/0001_initial.cpython-310.pyc differ diff --git a/src/accounts/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-310.pyc b/src/accounts/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-310.pyc new file mode 100644 index 00000000..75303bdb Binary files /dev/null and b/src/accounts/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-310.pyc differ diff --git a/src/accounts/accounts/migrations/__pycache__/__init__.cpython-310.pyc b/src/accounts/accounts/migrations/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 00000000..9dcf2880 Binary files /dev/null and b/src/accounts/accounts/migrations/__pycache__/__init__.cpython-310.pyc differ diff --git a/src/accounts/accounts/models.py b/src/accounts/accounts/models.py new file mode 100644 index 00000000..d194f1b3 --- /dev/null +++ b/src/accounts/accounts/models.py @@ -0,0 +1,60 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.urls import reverse +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from djangoblog.utils import get_current_site # 导入项目公共工具函数(获取当前站点域名) + +# 模块级注释——accounts应用的核心数据模型文件, +# 定义自定义用户模型`BlogUser`,继承Django内置`AbstractUser`, +# 扩展业务所需的自定义字段(如昵称、创建时间、创建来源), +# 并重写核心方法以适配项目业务逻辑(如用户URL生成、字符串表示) +# Create your models here. + + +class BlogUser(AbstractUser): + """ + 自定义用户模型,继承Django内置`AbstractUser`(保留用户名、密码、邮箱等核心字段) + 扩展项目所需的业务字段,适配博客系统的用户管理需求,支持国际化配置 + """ + # 昵称字段:支持国际化标签,最大长度100,允许空值(用户可选择不设置昵称) + nickname = models.CharField(_('nick name'), max_length=100, blank=True) + # 创建时间字段:支持国际化标签,默认值为当前时间,记录用户注册时间 + creation_time = models.DateTimeField(_('creation time'), default=now) + # 最后修改时间字段:支持国际化标签,默认值为当前时间,记录用户信息最后更新时间 + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + # 创建来源字段:支持国际化标签,最大长度100,允许空值,用于标记用户注册渠道(如"adminsite"/"frontend"/"oauth") + source = models.CharField(_('create source'), max_length=100, blank=True) + + def get_absolute_url(self): + """ + 重写Django模型的`get_absolute_url`方法,获取用户的绝对路径URL + 关联博客系统的"作者详情页"路由,用于直接访问用户的个人主页 + """ + return reverse( + 'blog:author_detail', # 路由名称(对应blog应用的作者详情页路由) + kwargs={'author_name': self.username} # 路由参数:用户名(作为作者标识) + ) + + def __str__(self): + """ + 重写模型的字符串表示方法,返回用户邮箱作为标识 + 相比默认的用户名,邮箱更具唯一性,便于后台管理和日志输出时识别用户 + """ + return self.email + + def get_full_url(self): + """ + 扩展方法:获取用户个人主页的完整URL(包含站点域名) + 用于需要分享用户主页的场景(如邮件通知、第三方分享) + """ + site = get_current_site().domain # 通过公共工具函数获取当前站点的域名(如"example.com") + # 拼接域名和用户绝对路径,生成完整URL(支持HTTPS协议) + url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) + return url + + class Meta: + ordering = ['-id'] # 数据查询时默认按ID倒序排列(新注册用户优先展示) + verbose_name = _('user') # 模型单数显示名称(支持国际化) + verbose_name_plural = verbose_name # 模型复数显示名称(与单数一致,简化管理) + get_latest_by = 'id' # 按ID字段获取最新创建的用户记录 \ No newline at end of file diff --git a/src/accounts/accounts/templatetags/__init__.py b/src/accounts/accounts/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/accounts/accounts/templatetags/__pycache__/__init__.cpython-310.pyc b/src/accounts/accounts/templatetags/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 00000000..56ee8dec Binary files /dev/null and b/src/accounts/accounts/templatetags/__pycache__/__init__.cpython-310.pyc differ diff --git a/src/accounts/accounts/tests.py b/src/accounts/accounts/tests.py new file mode 100644 index 00000000..23e437db --- /dev/null +++ b/src/accounts/accounts/tests.py @@ -0,0 +1,296 @@ +from django.test import Client, RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from accounts.models import BlogUser +from blog.models import Article, Category +from djangoblog.utils import * # 导入项目公共工具函数(如加密、缓存操作等) +from . import utils # 导入accounts应用自定义工具函数(如验证码设置/验证、邮件发送) + + +# 模块级注释——accounts应用的单元测试文件, +# 覆盖用户核心业务流程的测试场景:用户登录、注册、邮箱验证码验证、忘记密码重置等, +# 基于Django TestCase框架,通过模拟HTTP请求和数据库操作,验证业务逻辑的正确性, +# 确保用户相关功能稳定可用(如权限控制、数据一致性、错误处理) +# Create your tests here. + + +class AccountTest(TestCase): + """ + 用户相关核心功能测试类,继承Django TestCase + 集中测试用户登录、注册、验证码验证、忘记密码等关键流程, + 每个测试方法对应一个独立的业务场景,确保测试隔离性 + """ + + def setUp(self): + """ + 测试初始化方法(每个测试方法执行前自动调用) + 初始化测试所需的核心对象和测试数据,避免重复代码 + """ + self.client = Client() # 模拟HTTP客户端,用于发送GET/POST请求 + self.factory = RequestFactory() # 请求工厂,用于构建自定义请求对象(本测试未直接使用) + # 创建普通测试用户(用于后续登录、密码重置等测试) + self.blog_user = BlogUser.objects.create_user( + username="test", # 用户名 + email="admin@admin.com", # 邮箱 + password="12345678" # 密码(明文,create_user会自动哈希存储) + ) + self.new_test = "xxx123--=" # 测试用新密码(用于忘记密码重置场景) + + def test_validate_account(self): + """ + 测试用户登录、管理员权限、文章管理访问流程 + 验证点:超级用户登录成功、管理员页面访问权限、文章创建后管理页访问权限 + """ + # 获取当前站点域名(用于URL生成,本测试未直接使用) + site = get_current_site().domain + # 创建超级用户(用于测试管理员权限) + user = BlogUser.objects.create_superuser( + email="liangliangyy1@gmail.com", + username="liangliangyy1", + password="qwer!@#$ggg") # 超级用户密码 + # 根据用户名查询创建的超级用户(验证用户创建成功) + testuser = BlogUser.objects.get(username='liangliangyy1') + + # 模拟超级用户登录,验证登录结果是否成功 + loginresult = self.client.login( + username='liangliangyy1', + password='qwer!@#$ggg') + self.assertEqual(loginresult, True) # 断言登录成功 + + # 访问管理员首页,验证是否有权限(状态码200表示访问成功) + response = self.client.get('/admin/') + self.assertEqual(response.status_code, 200) + + # 创建测试分类(用于后续文章关联) + category = Category() + category.name = "categoryaaa" # 分类名称 + category.creation_time = timezone.now() # 创建时间 + category.last_modify_time = timezone.now() # 最后修改时间 + category.save() # 保存到数据库 + + # 创建测试文章(关联超级用户和分类) + article = Article() + article.title = "nicetitleaaa" # 文章标题 + article.body = "nicecontentaaa" # 文章内容 + article.author = user # 文章作者(超级用户) + article.category = category # 文章分类 + article.type = 'a' # 文章类型(推测为"article"普通文章) + article.status = 'p' # 文章状态(推测为"published"已发布) + article.save() # 保存到数据库 + + # 访问文章的管理员编辑页,验证超级用户是否有权限 + response = self.client.get(article.get_admin_url()) + self.assertEqual(response.status_code, 200) # 断言访问成功 + + def test_validate_register(self): + """ + 测试用户注册完整流程 + 验证点:注册提交、用户创建、验证邮件链接访问、登录后权限升级、文章创建、退出登录、错误密码登录 + """ + # 注册前检查目标邮箱是否存在(预期不存在,计数为0) + self.assertEquals( + 0, len( + BlogUser.objects.filter( + email='user123@user.com'))) + + # 模拟POST请求提交注册表单(调用注册路由) + response = self.client.post(reverse('account:register'), { + 'username': 'user1233', # 注册用户名 + 'email': 'user123@user.com', # 注册邮箱 + 'password1': 'password123!q@wE#R$T', # 注册密码(符合强度要求) + 'password2': 'password123!q@wE#R$T', # 密码确认(与密码一致) + }) + + # 注册后检查目标邮箱是否创建成功(预期计数为1) + self.assertEquals( + 1, len( + BlogUser.objects.filter( + email='user123@user.com'))) + + # 获取刚注册的用户,生成邮箱验证链接(基于用户ID和加密签名) + user = BlogUser.objects.filter(email='user123@user.com')[0] + # 生成验证签名(双重SHA256加密,结合SECRET_KEY和用户ID,确保链接安全性) + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + path = reverse('accounts:result') # 验证结果页路由 + # 拼接完整的验证链接(包含用户ID和签名) + url = '{path}?type=validation&id={id}&sign={sign}'.format( + path=path, id=user.id, sign=sign) + # 访问验证链接,验证页面是否正常响应(状态码200) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # 使用注册的用户名和密码登录 + self.client.login(username='user1233', password='password123!q@wE#R$T') + # 将该用户升级为超级用户(用于测试管理员权限) + user = BlogUser.objects.filter(email='user123@user.com')[0] + user.is_superuser = True # 设为超级用户 + user.is_staff = True # 允许访问admin后台 + user.save() # 保存权限变更 + + delete_sidebar_cache() # 清除侧边栏缓存(项目自定义缓存操作,避免缓存影响测试) + + # 创建测试分类(用于关联文章) + category = Category() + category.name = "categoryaaa" + category.creation_time = timezone.now() + category.last_modify_time = timezone.now() + category.save() + + # 创建测试文章(关联升级后的超级用户) + article = Article() + article.category = category + article.title = "nicetitle333" + article.body = "nicecontentttt" + article.author = user + article.type = 'a' + article.status = 'p' + article.save() + + # 访问文章管理页,验证升级后的用户是否有权限 + response = self.client.get(article.get_admin_url()) + self.assertEqual(response.status_code, 200) + + # 模拟退出登录 + response = self.client.get(reverse('account:logout')) + # 断言退出登录响应状态码(301/302为跳转,200为成功响应,均符合预期) + self.assertIn(response.status_code, [301, 302, 200]) + + # 退出后访问文章管理页(预期无权限,可能跳转登录页) + response = self.client.get(article.get_admin_url()) + self.assertIn(response.status_code, [301, 302, 200]) + + # 使用错误密码尝试登录 + response = self.client.post(reverse('account:login'), { + 'username': 'user1233', + 'password': 'password123' # 错误密码(原密码为password123!q@wE#R$T) + }) + # 断言登录失败后的响应状态码(跳转或重新显示登录页) + self.assertIn(response.status_code, [301, 302, 200]) + + # 错误密码登录后访问文章管理页(预期无权限) + response = self.client.get(article.get_admin_url()) + self.assertIn(response.status_code, [301, 302, 200]) + + def test_verify_email_code(self): + """ + 测试邮箱验证码的验证逻辑 + 验证点:正确邮箱+正确验证码验证成功、错误邮箱+正确验证码验证失败 + """ + to_email = "admin@admin.com" # 测试邮箱(已在setUp中创建关联用户) + code = generate_code() # 生成随机验证码(项目工具函数) + utils.set_code(to_email, code) # 存储验证码(推测基于缓存或数据库存储) + utils.send_verify_email(to_email, code) # 发送验证邮件(模拟邮件发送流程) + + # 验证:正确邮箱+正确验证码 → 预期无错误(返回None) + err = utils.verify("admin@admin.com", code) + self.assertEqual(err, None) + + # 验证:错误邮箱+正确验证码 → 预期返回错误信息(字符串类型) + err = utils.verify("admin@123.com", code) + self.assertEqual(type(err), str) + + def test_forget_password_email_code_success(self): + """ + 测试获取忘记密码验证码的成功场景 + 验证点:输入已注册邮箱,成功获取验证码(响应"ok") + """ + # 模拟POST请求提交邮箱,获取忘记密码验证码 + resp = self.client.post( + path=reverse("account:forget_password_code"), # 忘记密码验证码路由 + data=dict(email="admin@admin.com") # 已注册的测试邮箱 + ) + + self.assertEqual(resp.status_code, 200) # 断言请求成功 + self.assertEqual(resp.content.decode("utf-8"), "ok") # 断言响应内容为"ok"(表示验证码发送成功) + + def test_forget_password_email_code_fail(self): + """ + 测试获取忘记密码验证码的失败场景 + 验证点:无邮箱输入、邮箱格式错误,均返回"错误的邮箱" + """ + # 场景1:不传入邮箱参数 → 预期返回"错误的邮箱" + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict() # 空数据(无邮箱) + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + # 场景2:传入格式错误的邮箱(无@后的域名后缀)→ 预期返回"错误的邮箱" + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict(email="admin@com") # 格式错误的邮箱 + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + def test_forget_password_email_success(self): + """ + 测试忘记密码重置成功的场景 + 验证点:输入正确邮箱、验证码、新密码,密码成功更新 + """ + code = generate_code() # 生成验证码 + utils.set_code(self.blog_user.email, code) # 绑定验证码到测试用户邮箱 + # 构建重置密码表单数据 + data = dict( + new_password1=self.new_test, # 新密码 + new_password2=self.new_test, # 新密码确认(与新密码一致) + email=self.blog_user.email, # 测试用户邮箱 + code=code, # 正确的验证码 + ) + # 模拟POST请求提交重置密码表单 + resp = self.client.post( + path=reverse("account:forget_password"), # 忘记密码重置路由 + data=data + ) + self.assertEqual(resp.status_code, 302) # 断言重置成功后跳转(302为重定向) + + # 验证密码是否真正修改成功 + blog_user = BlogUser.objects.filter( + email=self.blog_user.email, + ).first() # 获取测试用户 + self.assertNotEqual(blog_user, None) # 断言用户存在 + # 验证新密码是否匹配(check_password会自动哈希比对) + self.assertEqual(blog_user.check_password(data["new_password1"]), True) + + def test_forget_password_email_not_user(self): + """ + 测试忘记密码重置的失败场景:输入未注册的邮箱 + 验证点:未注册邮箱提交重置请求,返回200状态码(表单重新显示错误) + """ + # 构建重置密码表单数据(邮箱未注册) + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email="123@123.com", # 未注册的邮箱 + code="123456", # 任意验证码 + ) + # 模拟POST请求提交重置密码表单 + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + + self.assertEqual(resp.status_code, 200) # 断言返回表单页面(显示邮箱不存在错误) + + def test_forget_password_email_code_error(self): + """ + 测试忘记密码重置的失败场景:验证码错误 + 验证点:输入正确邮箱、错误验证码,返回200状态码(表单重新显示错误) + """ + code = generate_code() # 生成正确验证码并绑定到测试用户邮箱 + utils.set_code(self.blog_user.email, code) + # 构建重置密码表单数据(验证码错误) + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email=self.blog_user.email, # 正确邮箱 + code="111111", # 错误验证码(与绑定的code不一致) + ) + # 模拟POST请求提交重置密码表单 + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + + self.assertEqual(resp.status_code, 200) # 断言返回表单页面(显示验证码错误) \ No newline at end of file diff --git a/src/accounts/accounts/urls.py b/src/accounts/accounts/urls.py new file mode 100644 index 00000000..22ec0df8 --- /dev/null +++ b/src/accounts/accounts/urls.py @@ -0,0 +1,36 @@ +from django.urls import path +from django.urls import re_path + +from . import views # 导入accounts应用的视图模块(包含登录、注册等核心视图) +from .forms import LoginForm # 导入自定义登录表单(适配前端样式和验证规则) + +app_name = "accounts" # 定义应用命名空间,用于反向解析URL时区分不同应用的路由(如`reverse('accounts:login')`) + +# URL路由配置:映射用户核心业务的URL路径到对应视图,覆盖登录、注册、退出、密码重置等功能 +urlpatterns = [ + # 登录路由:使用正则表达式匹配`/login/`路径 + re_path(r'^login/$', + views.LoginView.as_view(success_url='/'), # 关联视图类`LoginView`,登录成功后重定向到网站根目录 + name='login', # 路由名称,用于反向解析(如模板中`{% url 'accounts:login' %}`) + kwargs={'authentication_form': LoginForm}), # 传入自定义登录表单`LoginForm`,替代默认表单 + # 注册路由:匹配`/register/`路径 + re_path(r'^register/$', + views.RegisterView.as_view(success_url="/"), # 关联视图类`RegisterView`,注册成功后重定向到网站根目录 + name='register'), # 路由名称,用于反向解析注册页面URL + # 退出登录路由:匹配`/logout/`路径 + re_path(r'^logout/$', + views.LogoutView.as_view(), # 关联视图类`LogoutView`(Django内置或自定义,处理退出登录逻辑) + name='logout'), # 路由名称,用于反向解析退出登录URL + # 账号操作结果页路由:精确匹配`/account/result.html`路径 + path(r'account/result.html', + views.account_result, # 关联函数视图`account_result`,展示账号操作结果(如邮箱验证成功/失败) + name='result'), # 路由名称,用于反向解析结果页URL + # 忘记密码重置路由:匹配`/forget_password/`路径 + re_path(r'^forget_password/$', + views.ForgetPasswordView.as_view(), # 关联视图类`ForgetPasswordView`,处理密码重置表单提交和验证 + name='forget_password'), # 路由名称,用于反向解析忘记密码页面URL + # 忘记密码验证码路由:匹配`/forget_password_code/`路径 + re_path(r'^forget_password_code/$', + views.ForgetPasswordEmailCode.as_view(), # 关联视图类`ForgetPasswordEmailCode`,处理验证码发送逻辑 + name='forget_password_code'), # 路由名称,用于反向解析获取验证码的URL +] \ No newline at end of file diff --git a/src/accounts/accounts/user_login_backend.py b/src/accounts/accounts/user_login_backend.py new file mode 100644 index 00000000..7b6a58ca --- /dev/null +++ b/src/accounts/accounts/user_login_backend.py @@ -0,0 +1,55 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + + +# 模块级注释——accounts应用的自定义认证后端文件, +# 继承Django内置ModelBackend,扩展支持"用户名或邮箱"双登录方式, +# 适配项目中用户可能使用邮箱作为登录凭证的业务需求,保持与Django认证系统的兼容性 +class EmailOrUsernameModelBackend(ModelBackend): + """ + 自定义用户认证后端,继承Django内置ModelBackend + 核心功能:允许用户使用「用户名」或「邮箱」两种方式登录, + 兼容Django默认认证流程,不破坏原有用户模型和权限体系 + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + """ + 重写认证核心方法,实现用户名/邮箱双登录逻辑 + :param request: HTTP请求对象(用于传递请求上下文,本方法未直接使用) + :param username: 登录输入的标识(可能是用户名或邮箱) + :param password: 登录输入的密码(明文,需通过check_password验证) + :param kwargs: 额外关键字参数(兼容Django认证系统的扩展需求) + :return: 认证成功返回BlogUser对象,失败返回None + """ + # 判断输入的标识是否包含@符号:含@则视为邮箱登录,否则视为用户名登录 + if '@' in username: + kwargs = {'email': username} # 构造邮箱查询条件 + else: + kwargs = {'username': username} # 构造用户名查询条件 + + try: + # 根据查询条件(用户名/邮箱)从数据库获取用户对象 + # get_user_model():动态获取当前项目激活的用户模型(此处为accounts.BlogUser) + user = get_user_model().objects.get(**kwargs) + + # 验证输入的密码是否正确(check_password会自动比对明文与数据库中哈希后的密码) + if user.check_password(password): + return user # 密码验证通过,返回用户对象(认证成功) + + # 捕获用户不存在的异常(查询不到时触发) + except get_user_model().DoesNotExist: + return None # 用户不存在,返回None(认证失败) + + def get_user(self, username): + """ + 重写用户获取方法,根据用户主键(pk)查询用户对象 + 是Django认证后端必须实现的方法,用于认证成功后获取完整用户信息 + :param username: 实际为用户的主键ID(Django认证系统默认传递pk作为参数) + :return: 查询成功返回BlogUser对象,失败返回None + """ + try: + # 根据主键ID查询用户(get_user_model()确保兼容自定义用户模型) + return get_user_model().objects.get(pk=username) + # 捕获用户不存在的异常 + except get_user_model().DoesNotExist: + return None # 用户不存在,返回None \ No newline at end of file diff --git a/src/accounts/accounts/utils.py b/src/accounts/accounts/utils.py new file mode 100644 index 00000000..4513ef9e --- /dev/null +++ b/src/accounts/accounts/utils.py @@ -0,0 +1,79 @@ +import typing +from datetime import timedelta + +from django.core.cache import cache # Django缓存框架,用于存储验证码(内存/Redis等,由项目配置决定) +from django.utils.translation import gettext +from django.utils.translation import gettext_lazy as _ # 国际化支持,用于生成多语言提示文本 + +from djangoblog.utils import send_email # 导入项目公共邮件发送工具函数 + +_code_ttl = timedelta(minutes=5) # 验证码有效期:5分钟(全局常量,统一控制验证码过期时间) + + +def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): + """ + 发送密码重置验证邮件(核心功能:向目标邮箱发送含验证码的邮件) + 邮件内容支持国际化,验证码有效期与全局常量`_code_ttl`保持一致(5分钟) + + Args: + to_mail: 接收邮件的目标邮箱地址(字符串类型,需符合邮箱格式) + code: 生成的随机验证码(字符串类型,用于后续密码重置验证) + subject: 邮件主题(可选参数,默认值为国际化的"Verify Email",支持多语言切换) + """ + # 构建邮件HTML内容(国际化模板,通过占位符注入验证码,明确告知有效期) + html_content = _( + "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " + "properly") % {'code': code} + # 调用项目公共邮件发送函数,发送验证码邮件(收件人列表、主题、HTML内容) + send_email([to_mail], subject, html_content) + + +def verify(email: str, code: str) -> typing.Optional[str]: + """ + 验证验证码的有效性(核心功能:校验用户输入的验证码与缓存中存储的是否一致) + + Args: + email: 待验证的邮箱地址(与验证码绑定的唯一标识,确保验证码针对性) + code: 用户输入的验证码(需与缓存中存储的验证码比对) + + Return: + 验证失败返回错误提示字符串(支持国际化),验证成功返回None + + Note: + 1. 原代码注释保留:当前错误处理逻辑不合理,建议改用raise抛出异常(而非返回错误字符串), + 便于调用方统一捕获和处理,减少错误处理冗余; + 2. 验证码校验逻辑:通过邮箱作为缓存key,获取存储的验证码,与输入值直接比对(大小写敏感); + 3. 若缓存中无该邮箱对应的验证码(如过期、未发送),则默认返回验证码错误提示。 + """ + # 从缓存中获取该邮箱对应的验证码(缓存key为邮箱地址,value为之前存储的验证码) + cache_code = get_code(email) + # 比对缓存中的验证码与用户输入的验证码,不一致则返回错误提示 + if cache_code != code: + return gettext("Verification code error") # 国际化的验证码错误提示 + + +def set_code(email: str, code: str): + """ + 将邮箱与验证码绑定并存储到缓存中(核心功能:为后续验证提供数据支持) + 存储时自动设置过期时间(与全局`_code_ttl`一致,5分钟),避免验证码永久有效 + + Args: + email: 验证码绑定的邮箱地址(作为缓存的唯一key,确保一对一关联) + code: 需存储的随机验证码(字符串类型,建议由`generate_code`等工具函数生成) + """ + # 缓存存储:key=邮箱地址,value=验证码,timeout=有效期(秒数,由_timedelta转换) + cache.set(email, code, _code_ttl.seconds) + + +def get_code(email: str) -> typing.Optional[str]: + """ + 从缓存中获取指定邮箱对应的验证码(核心功能:为验证码校验提供数据源) + + Args: + email: 目标邮箱地址(缓存key,用于精准获取绑定的验证码) + + Return: + 缓存中存在该邮箱对应的验证码则返回字符串类型的验证码,否则返回None(如过期、未存储) + """ + # 从缓存中获取值:key为邮箱地址,不存在或过期时返回None + return cache.get(email) \ No newline at end of file diff --git a/src/accounts/accounts/views.py b/src/accounts/accounts/views.py new file mode 100644 index 00000000..c4e956af --- /dev/null +++ b/src/accounts/accounts/views.py @@ -0,0 +1,306 @@ +import logging +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.contrib import auth +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth import get_user_model +from django.contrib.auth import logout +from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth.hashers import make_password +from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from django.shortcuts import get_object_or_404 +from django.shortcuts import render +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.http import url_has_allowed_host_and_scheme +from django.views import View +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.debug import sensitive_post_parameters +from django.views.generic import FormView, RedirectView + +# 导入项目公共工具函数:邮件发送、加密、站点信息、验证码生成、缓存删除 +from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache +from . import utils # 导入accounts应用自定义工具(验证码发送/存储) +from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm # 导入用户交互表单 +from .models import BlogUser # 导入自定义用户模型 + +logger = logging.getLogger(__name__) # 初始化日志对象,用于记录视图层操作日志 + +# 模块级注释——accounts应用的核心视图文件, +# 包含用户注册、登录、退出、邮箱验证、密码重置等核心业务的视图实现, +# 基于Django类视图(FormView/RedirectView/View)和函数视图, +# 整合表单验证、邮件发送、缓存操作、权限控制等逻辑,处理用户交互的全流程 +# Create your views here. + + +class RegisterView(FormView): + """ + 用户注册视图,继承Django FormView(专门处理表单提交的类视图) + 核心功能:接收注册表单数据、验证合法性、创建未激活用户、发送邮箱验证链接、重定向到结果页 + """ + form_class = RegisterForm # 关联注册表单类(处理用户名、邮箱、密码验证) + template_name = 'account/registration_form.html' # 注册页面模板路径 + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + """ + 重写分发方法,添加CSRF防护装饰器 + 防止跨站请求伪造攻击,确保注册请求来自本网站合法表单 + """ + return super(RegisterView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + """ + 表单验证通过后的核心处理逻辑(FormView的核心方法) + 流程:创建未激活用户 → 生成邮箱验证链接 → 发送验证邮件 → 重定向到注册结果页 + """ + if form.is_valid(): + # 1. 表单验证通过,先不提交到数据库(commit=False),后续补充字段 + user = form.save(False) + user.is_active = False # 默认设置用户为未激活状态(需邮箱验证后激活) + user.source = 'Register' # 标记用户创建来源:前台注册(区别于后台创建/第三方登录) + user.save(True) # 最终保存用户数据到数据库 + + # 2. 生成邮箱验证链接(包含站点域名、用户ID、加密签名,确保链接安全性) + site = get_current_site().domain # 获取当前站点域名(如"example.com") + # 双重SHA256加密:结合SECRET_KEY和用户ID,防止链接被篡改 + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + + # 开发环境适配:DEBUG模式下使用本地测试域名(127.0.0.1:8000) + if settings.DEBUG: + site = '127.0.0.1:8000' + path = reverse('account:result') # 验证结果页路由 + # 拼接完整的验证链接(HTTP协议,开发环境可用;生产环境建议改为HTTPS) + url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( + site=site, path=path, id=user.id, sign=sign) + + # 3. 构建验证邮件内容(HTML格式,包含验证链接) + content = """ +

请点击下面链接验证您的邮箱

+ + {url} + + 再次感谢您! +
+ 如果上面链接无法打开,请将此链接复制至浏览器。 + {url} + """.format(url=url) + # 发送验证邮件到用户注册邮箱 + send_email( + emailto=[user.email], # 收件人(注册时填写的邮箱) + title='验证您的电子邮箱', # 邮件标题 + content=content) # 邮件HTML内容 + + # 4. 重定向到注册结果页(告知用户验证邮件已发送) + url = reverse('accounts:result') + '?type=register&id=' + str(user.id) + return HttpResponseRedirect(url) + else: + # 表单验证失败(如用户名已存在、密码不匹配),重新渲染注册页面并显示错误 + return self.render_to_response({'form': form}) + + +class LogoutView(RedirectView): + """ + 用户退出登录视图,继承Django RedirectView(专门处理重定向的类视图) + 核心功能:执行退出登录逻辑、清理缓存、重定向到登录页 + """ + url = '/login/' # 退出后重定向的目标URL(登录页) + + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + """ + 重写分发方法,添加never_cache装饰器 + 禁止浏览器缓存退出页,避免用户后退到已登录状态 + """ + return super(LogoutView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + """ + 处理GET请求(退出登录请求) + 流程:执行退出登录 → 清理侧边栏缓存 → 重定向到登录页 + """ + logout(request) # Django内置logout函数:清除用户会话,销毁登录状态 + delete_sidebar_cache() # 清除侧边栏缓存(避免缓存中保留用户相关信息) + return super(LogoutView, self).get(request, *args, **kwargs) + + +class LoginView(FormView): + """ + 用户登录视图,继承Django FormView + 核心功能:接收登录表单数据、验证合法性、执行登录逻辑、处理"记住我"、重定向到目标页面 + """ + form_class = LoginForm # 关联自定义登录表单(适配前端样式) + template_name = 'account/login.html' # 登录页面模板路径 + success_url = '/' # 登录成功后的默认重定向URL(网站根目录) + redirect_field_name = REDIRECT_FIELD_NAME # 重定向字段名(默认'redirect_to',用于跳转前的目标页面) + login_ttl = 2626560 # "记住我"的会话有效期:2626560秒 ≈ 1个月 + + @method_decorator(sensitive_post_parameters('password')) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + """ + 重写分发方法,添加3个核心装饰器: + 1. sensitive_post_parameters('password'):保护密码字段,避免在错误报告中泄露 + 2. csrf_protect:防跨站请求伪造攻击 + 3. never_cache:禁止浏览器缓存登录页,确保每次请求都是最新状态 + """ + return super(LoginView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + """ + 补充模板上下文数据(传递重定向地址给模板) + 用于登录成功后跳转到登录前的目标页面(如访问需要登录的页面时,先跳转登录,登录后返回原页面) + """ + # 从GET请求中获取重定向地址(如?redirect_to=/article/1/) + redirect_to = self.request.GET.get(self.redirect_field_name) + if redirect_to is None: + redirect_to = '/' # 无重定向地址时,默认跳转到根目录 + kwargs['redirect_to'] = redirect_to # 将重定向地址添加到上下文 + return super(LoginView, self).get_context_data(** kwargs) + + def form_valid(self, form): + """ + 表单验证通过后的核心登录逻辑 + 流程:验证表单 → 清理缓存 → 执行登录 → 处理"记住我" → 重定向 + """ + # 重新初始化Django内置AuthenticationForm(确保使用正确的认证逻辑) + form = AuthenticationForm(data=self.request.POST, request=self.request) + + if form.is_valid(): + delete_sidebar_cache() # 清理侧边栏缓存(更新用户相关的缓存数据) + logger.info(self.redirect_field_name) # 记录重定向字段名到日志 + + # 执行登录:将用户信息存入会话,创建登录状态 + auth.login(self.request, form.get_user()) + # 处理"记住我"选项:若勾选,则设置会话有效期为1个月 + if self.request.POST.get("remember"): + self.request.session.set_expiry(self.login_ttl) + # 调用父类form_valid方法,执行重定向 + return super(LoginView, self).form_valid(form) + else: + # 表单验证失败(如用户名/密码错误),重新渲染登录页并显示错误 + return self.render_to_response({'form': form}) + + def get_success_url(self): + """ + 自定义登录成功后的重定向URL + 核心:校验重定向地址的合法性,避免恶意重定向攻击 + """ + # 从POST请求中获取目标重定向地址(登录表单中隐藏字段传递) + redirect_to = self.request.POST.get(self.redirect_field_name) + # 校验重定向地址是否合法(仅允许本网站域名下的地址) + if not url_has_allowed_host_and_scheme( + url=redirect_to, allowed_hosts=[self.request.get_host()]): + redirect_to = self.success_url # 非法地址则使用默认重定向地址(根目录) + return redirect_to + + +def account_result(request): + """ + 账号操作结果展示函数视图 + 处理两种场景:1. 注册成功后的提示 2. 邮箱验证后的激活与提示 + 核心:根据URL参数区分场景,验证链接合法性,激活用户,渲染结果页面 + """ + # 从GET请求中获取操作类型(register/validation)和用户ID + type = request.GET.get('type') + id = request.GET.get('id') + + # 根据用户ID查询用户,不存在则返回404页面 + user = get_object_or_404(get_user_model(), id=id) + logger.info(type) # 记录操作类型到日志 + + # 若用户已激活,直接重定向到首页(避免重复验证) + if user.is_active: + return HttpResponseRedirect('/') + + # 仅处理注册和验证两种合法操作类型 + if type and type in ['register', 'validation']: + if type == 'register': + # 场景1:注册成功提示(告知用户验证邮件已发送) + content = ''' + 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 + ''' + title = '注册成功' + else: + # 场景2:邮箱验证(校验链接签名合法性,激活用户) + # 重新计算签名(与注册时的加密规则一致) + c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + sign = request.GET.get('sign') # 从URL中获取传递的签名 + + # 签名不匹配则返回403禁止访问(防止恶意篡改链接) + if sign != c_sign: + return HttpResponseForbidden() + + # 签名匹配,激活用户(设置is_active为True) + user.is_active = True + user.save() + # 验证成功提示 + content = ''' + 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 + ''' + title = '验证成功' + # 渲染结果页面,传递标题和内容 + return render(request, 'account/result.html', { + 'title': title, + 'content': content + }) + else: + # 非法操作类型,重定向到首页 + return HttpResponseRedirect('/') + + +class ForgetPasswordView(FormView): + """ + 忘记密码重置视图,继承Django FormView + 核心功能:接收密码重置表单(新密码、验证码、邮箱)、验证合法性、更新用户密码、重定向到登录页 + """ + form_class = ForgetPasswordForm # 关联忘记密码表单(处理密码强度、验证码、邮箱验证) + template_name = 'account/forget_password.html' # 密码重置页面模板路径 + + def form_valid(self, form): + """ + 表单验证通过后的密码重置逻辑 + 流程:查询用户 → 哈希新密码 → 保存更新 → 重定向到登录页 + """ + if form.is_valid(): + # 根据表单验证后的邮箱查询用户 + blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() + # 哈希处理新密码(make_password自动使用Django配置的哈希算法,避免明文存储) + blog_user.password = make_password(form.cleaned_data["new_password2"]) + blog_user.save() # 保存更新后的密码 + return HttpResponseRedirect('/login/') # 密码重置成功,重定向到登录页 + else: + # 表单验证失败(如验证码错误、密码不匹配),重新渲染页面并显示错误 + return self.render_to_response({'form': form}) + + +class ForgetPasswordEmailCode(View): + """ + 忘记密码验证码发送视图,继承Django View + 核心功能:接收邮箱 → 验证邮箱合法性 → 生成验证码 → 发送验证邮件 → 存储验证码到缓存 + """ + + def post(self, request: HttpRequest): + """ + 处理POST请求(接收邮箱,发送验证码) + """ + # 初始化忘记密码验证码表单,验证请求数据 + form = ForgetPasswordCodeForm(request.POST) + if not form.is_valid(): + return HttpResponse("错误的邮箱") # 表单验证失败(如邮箱格式错误),返回错误提示 + + # 表单验证通过,获取目标邮箱 + to_email = form.cleaned_data["email"] + + # 生成随机验证码(项目工具函数) + code = generate_code() + # 发送验证码邮件(调用accounts应用自定义工具函数) + utils.send_verify_email(to_email, code) + # 存储验证码到缓存(key=邮箱,value=验证码,有效期5分钟,由utils模块的_code_ttl控制) + utils.set_code(to_email, code) + + return HttpResponse("ok") # 验证码发送成功,返回"ok"提示n \ No newline at end of file