diff --git a/src/.idea/.gitignore b/src/.idea/.gitignore new file mode 100644 index 0000000..10b731c --- /dev/null +++ b/src/.idea/.gitignore @@ -0,0 +1,5 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/src/.idea/inspectionProfiles/profiles_settings.xml b/src/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/src/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/src/.idea/misc.xml b/src/.idea/misc.xml new file mode 100644 index 0000000..3ffd832 --- /dev/null +++ b/src/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/src/.idea/modules.xml b/src/.idea/modules.xml new file mode 100644 index 0000000..f669a0e --- /dev/null +++ b/src/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/.idea/src.iml b/src/.idea/src.iml new file mode 100644 index 0000000..fdbd330 --- /dev/null +++ b/src/.idea/src.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/.idea/vcs.xml b/src/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/src/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/accounts/__init__.py b/src/accounts/__init__.py new file mode 100644 index 0000000..50a55dc --- /dev/null +++ b/src/accounts/__init__.py @@ -0,0 +1,2 @@ +#zh: +#coding:utf-8 \ No newline at end of file diff --git a/src/accounts/__pycache__/__init__.cpython-312.pyc b/src/accounts/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..d5230c9 Binary files /dev/null and b/src/accounts/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/admin.cpython-312.pyc b/src/accounts/__pycache__/admin.cpython-312.pyc new file mode 100644 index 0000000..4b3851b Binary files /dev/null and b/src/accounts/__pycache__/admin.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/apps.cpython-312.pyc b/src/accounts/__pycache__/apps.cpython-312.pyc new file mode 100644 index 0000000..b9855e4 Binary files /dev/null and b/src/accounts/__pycache__/apps.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/forms.cpython-312.pyc b/src/accounts/__pycache__/forms.cpython-312.pyc new file mode 100644 index 0000000..f7ce54c Binary files /dev/null and b/src/accounts/__pycache__/forms.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/models.cpython-312.pyc b/src/accounts/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..5b3441f Binary files /dev/null and b/src/accounts/__pycache__/models.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/urls.cpython-312.pyc b/src/accounts/__pycache__/urls.cpython-312.pyc new file mode 100644 index 0000000..cc14eae Binary files /dev/null and b/src/accounts/__pycache__/urls.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/user_login_backend.cpython-312.pyc b/src/accounts/__pycache__/user_login_backend.cpython-312.pyc new file mode 100644 index 0000000..f81b3a4 Binary files /dev/null and b/src/accounts/__pycache__/user_login_backend.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/utils.cpython-312.pyc b/src/accounts/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000..6c99048 Binary files /dev/null and b/src/accounts/__pycache__/utils.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/views.cpython-312.pyc b/src/accounts/__pycache__/views.cpython-312.pyc new file mode 100644 index 0000000..2d1356a Binary files /dev/null and b/src/accounts/__pycache__/views.cpython-312.pyc differ diff --git a/src/accounts/admin.py b/src/accounts/admin.py new file mode 100644 index 0000000..3686f20 --- /dev/null +++ b/src/accounts/admin.py @@ -0,0 +1,66 @@ +#zh: +#coding:utf-8 +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 _ + +# 注册模型的地方 +from .models import BlogUser + + +class BlogUserCreationForm(forms.ModelForm): + """自定义用户创建表单""" + password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) # 密码字段 + password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) # 确认密码字段 + + class Meta: + model = BlogUser # 指定关联的模型 + fields = ('email',) # 表单中包含的字段,只包含email + + def clean_password2(self): + """验证两次输入的密码是否一致""" + 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): + """保存用户,对密码进行哈希处理""" + user = super().save(commit=False) # 先不提交到数据库 + user.set_password(self.cleaned_data["password1"]) # 设置哈希后的密码 + if commit: # 如果需要提交 + user.source = 'adminsite' # 设置用户来源为管理员站点 + user.save() # 保存用户到数据库 + return user # 返回用户对象 + + +class BlogUserChangeForm(UserChangeForm): + """自定义用户信息修改表单""" + class Meta: + model = BlogUser # 指定关联的模型 + fields = '__all__' # 包含所有字段 + field_classes = {'username': UsernameField} # 指定用户名字段类型 + + def __init__(self, *args, **kwargs): + """初始化表单""" + super().__init__(*args, **kwargs) # 调用父类初始化方法 + + +class BlogUserAdmin(UserAdmin): + """自定义用户管理后台配置""" + form = BlogUserChangeForm # 使用自定义的用户修改表单 + add_form = BlogUserCreationForm # 使用自定义的用户创建表单 + list_display = ( + 'id', # 显示ID + 'nickname', # 显示昵称 + 'username', # 显示用户名 + 'email', # 显示邮箱 + 'last_login', # 显示最后登录时间 + 'date_joined', # 显示注册日期 + 'source' # 显示用户来源 + ) + list_display_links = ('id', 'username') # 设置可点击的字段链接 + ordering = ('-id',) # 按ID倒序排列 \ No newline at end of file diff --git a/src/accounts/apps.py b/src/accounts/apps.py new file mode 100644 index 0000000..b9670e1 --- /dev/null +++ b/src/accounts/apps.py @@ -0,0 +1,18 @@ +#zh: +#coding:utf-8 +from django.apps import AppConfig # 导入Django应用配置基类 + + +class AccountsConfig(AppConfig): + """账户应用的配置类""" + + # 指定应用的Python路径(Django 3.x及以下版本使用) + # 在Django 4.x中,name字段被替换为使用应用标签 + name = 'accounts' + + # 在Django 4.x中,可以添加以下字段: + # default_auto_field = 'django.db.models.BigAutoField' # 默认主键类型 + # verbose_name = '用户账户' # 人类可读的应用名称(中文) + # def ready(self): + # # 导入信号处理器等初始化代码 + # import accounts.signals \ No newline at end of file diff --git a/src/accounts/forms.py b/src/accounts/forms.py new file mode 100644 index 0000000..1159137 --- /dev/null +++ b/src/accounts/forms.py @@ -0,0 +1,141 @@ +#zh: +#coding:utf-8 +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 + + +class LoginForm(AuthenticationForm): + """用户登录表单""" + + def __init__(self, *args, **kwargs): + """初始化表单,设置字段的widget属性""" + super(LoginForm, self).__init__(*args, **kwargs) + # 设置用户名字段的输入框样式和占位符 + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + # 设置密码字段的输入框样式和占位符 + self.fields['password'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + + +class RegisterForm(UserCreationForm): + """用户注册表单""" + + def __init__(self, *args, **kwargs): + """初始化表单,设置所有字段的widget属性""" + super(RegisterForm, self).__init__(*args, **kwargs) + + # 设置用户名字段的输入框样式和占位符 + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + # 设置邮箱字段的输入框样式和占位符 + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) + # 设置密码字段的输入框样式和占位符 + self.fields['password1'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + # 设置确认密码字段的输入框样式和占位符 + 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() # 获取当前用户模型 + fields = ("username", "email") # 表单包含的字段 + + +class ForgetPasswordForm(forms.Form): + """忘记密码重置表单""" + + new_password1 = forms.CharField( + label=_("New password"), # 新密码标签 + widget=forms.PasswordInput( # 密码输入框 + attrs={ + "class": "form-control", # CSS类名 + 'placeholder': _("New password") # 占位符文本 + } + ), + ) + + new_password2 = forms.CharField( + label="确认密码", # 确认密码标签 + widget=forms.PasswordInput( # 密码输入框 + attrs={ + "class": "form-control", # CSS类名 + 'placeholder': _("Confirm password") # 占位符文本 + } + ), + ) + + email = forms.EmailField( + label='邮箱', # 邮箱标签 + widget=forms.TextInput( # 文本输入框 + attrs={ + 'class': 'form-control', # CSS类名 + 'placeholder': _("Email") # 占位符文本 + } + ), + ) + + code = forms.CharField( + label=_('Code'), # 验证码标签 + widget=forms.TextInput( # 文本输入框 + attrs={ + 'class': 'form-control', # CSS类名 + 'placeholder': _("Code") # 占位符文本 + } + ), + ) + + def clean_new_password2(self): + """验证两次输入的新密码是否一致""" + 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")) # 密码不一致报错 + password_validation.validate_password(password2) # 验证密码强度 + + return password2 # 返回确认的密码 + + def clean_email(self): + """验证邮箱是否存在""" + user_email = self.cleaned_data.get("email") # 获取邮箱 + # 检查邮箱是否在系统中注册 + if not BlogUser.objects.filter(email=user_email).exists(): + # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 + raise ValidationError(_("email does not exist")) # 邮箱不存在报错 + return user_email # 返回邮箱 + + def clean_code(self): + """验证验证码是否正确""" + code = self.cleaned_data.get("code") # 获取验证码 + # 调用utils模块验证验证码 + 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'), # 邮箱标签 + ) diff --git a/src/accounts/migrations/0001_initial.py b/src/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..5fe0213 --- /dev/null +++ b/src/accounts/migrations/0001_initial.py @@ -0,0 +1,50 @@ +#zh: +#coding:utf-8 + +import django.contrib.auth.models # 导入Django认证系统的模型类,用于用户管理 +import django.contrib.auth.validators # 导入Django认证系统的验证器,用于用户输入验证 +from django.db import migrations, models # 从Django数据库模块导入迁移工具和模型基类 +import django.utils.timezone # 导入Django的时区工具,用于处理时间相关操作 + + +class Migration(migrations.Migration): # 定义迁移类,继承自Django的迁移基类,用于数据库结构变更 + + initial = True # 标记当前迁移为初始迁移,即该应用的第一个数据库迁移文件 + + dependencies = [ # 定义迁移依赖关系,确保迁移执行顺序正确 + ('auth', '0012_alter_user_first_name_max_length'), # 依赖于auth应用的特定迁移版本 + ] + + operations = [ # 定义当前迁移需要执行的数据库操作列表 + migrations.CreateModel( # 创建新数据模型的迁移操作 + name='BlogUser', # 要创建的模型名称为BlogUser + fields=[ # 定义模型包含的字段列表 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # 自增主键字段,自动创建,作为唯一标识 + ('password', models.CharField(max_length=128, verbose_name='password')), # 密码字段,最大长度128字符 + ('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')), # 是否为超级用户,默认False,拥有所有权限 + ('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')), # 用户名字段,唯一,最长150字符,使用Unicode验证器 + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), # 名,可为空,最长150字符 + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), # 姓,可为空,最长150字符 + ('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')), # 是否为管理员,默认False,决定能否登录admin后台 + ('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')), # 账号是否激活,默认True,用于软删除 + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), # 注册时间,默认当前时区时间 + ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')), # 自定义昵称字段,可为空,最长100字符 + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # 记录用户创建时间,默认当前时区时间 + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # 记录用户信息最后修改时间,默认当前时区时间 + ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')), # 记录用户账号的创建来源,可为空,最长100字符 + ('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')), # 与用户组的多对多关系,关联auth应用的Group模型 + ('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')), # 与权限的多对多关系,关联auth应用的Permission模型 + ], + options={ # 模型的元数据配置 + 'verbose_name': '用户', # 模型的单数显示名称 + 'verbose_name_plural': '用户', # 模型的复数显示名称 + 'ordering': ['-id'], # 默认排序方式:按id降序排列(最新用户在前) + 'get_latest_by': 'id', # 指定通过id字段获取最新记录 + }, + managers=[ # 模型的管理器配置 + ('objects', django.contrib.auth.models.UserManager()), # 使用Django内置的UserManager作为模型管理器,提供用户管理功能 + ], + ), + ] \ No newline at end of file diff --git a/src/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py new file mode 100644 index 0000000..c585ffb --- /dev/null +++ b/src/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py @@ -0,0 +1,46 @@ +#zh: +#coding:utf-8 +from django.db import migrations, models # 导入Django数据库迁移工具和模型字段类 +import django.utils.timezone # 导入Django时区工具,用于处理时间相关操作 + + +class Migration(migrations.Migration): # 定义迁移类,处理数据库结构变更 + + dependencies = [ # 定义当前迁移依赖的其他迁移文件 + ('accounts', '0001_initial'), # 依赖于accounts应用的0001_initial迁移 + ] + + operations = [ # 定义当前迁移需要执行的数据库操作列表 + migrations.AlterModelOptions( # 修改模型的元选项配置 + name='bloguser', # 要修改的模型名称为BlogUser + options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'}, # 更新元选项,将显示名称改为英文 + ), + migrations.RemoveField( # 移除模型中的字段 + model_name='bloguser', # 目标模型为BlogUser + name='created_time', # 要移除的字段名为created_time + ), + migrations.RemoveField( # 移除模型中的另一个字段 + model_name='bloguser', # 目标模型为BlogUser + name='last_mod_time', # 要移除的字段名为last_mod_time + ), + migrations.AddField( # 向模型添加新字段 + model_name='bloguser', # 目标模型为BlogUser + name='creation_time', # 新字段名称为creation_time + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), # 字段类型为DateTimeField,默认值为当前时间,显示名为"creation time" + ), + migrations.AddField( # 向模型添加另一个新字段 + model_name='bloguser', # 目标模型为BlogUser + name='last_modify_time', # 新字段名称为last_modify_time + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), # 字段类型为DateTimeField,默认值为当前时间,显示名为"last modify time" + ), + migrations.AlterField( # 修改模型中已有字段的配置 + model_name='bloguser', # 目标模型为BlogUser + name='nickname', # 要修改的字段名为nickname + field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), # 更新字段显示名为"nick name",其他属性保持不变 + ), + migrations.AlterField( # 修改模型中另一个已有字段的配置 + model_name='bloguser', # 目标模型为BlogUser + name='source', # 要修改的字段名为source + field=models.CharField(blank=True, max_length=100, verbose_name='create source'), # 更新字段显示名为"create source",其他属性保持不变 + ), + ] \ No newline at end of file diff --git a/src/accounts/migrations/__init__.py b/src/accounts/migrations/__init__.py new file mode 100644 index 0000000..50a55dc --- /dev/null +++ b/src/accounts/migrations/__init__.py @@ -0,0 +1,2 @@ +#zh: +#coding:utf-8 \ No newline at end of file diff --git a/src/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc b/src/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc new file mode 100644 index 0000000..7f93519 Binary files /dev/null and b/src/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/src/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-312.pyc b/src/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-312.pyc new file mode 100644 index 0000000..160de14 Binary files /dev/null and b/src/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-312.pyc differ diff --git a/src/accounts/migrations/__pycache__/__init__.cpython-312.pyc b/src/accounts/migrations/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..6592da9 Binary files /dev/null and b/src/accounts/migrations/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/accounts/models.py b/src/accounts/models.py new file mode 100644 index 0000000..15fe1fb --- /dev/null +++ b/src/accounts/models.py @@ -0,0 +1,50 @@ +#zh: +#coding:utf-8 +from django.contrib.auth.models import AbstractUser # 导入Django内置的抽象用户基类 +from django.db import models # 导入Django的模型模块 +from django.urls import reverse # 用于生成URL反向解析 +from django.utils.timezone import now # 获取当前时间 +from django.utils.translation import gettext_lazy as _ # 国际化翻译 +from django.utils import get_current_site # 获取当前站点信息 + + +# 在此处创建模型 + +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) + + # 用户来源字段,记录用户创建来源(如网站注册、管理员创建等) + source = models.CharField(_('create source'), max_length=100, blank=True) + + def get_absolute_url(self): + """获取用户的绝对URL,用于生成作者详情页链接""" + return reverse( + 'blog:author_detail', kwargs={ # 反向解析URL + 'author_name': self.username}) # 使用用户名作为URL参数 + + def __str__(self): + """对象的字符串表示,返回邮箱地址""" + return self.email + + def get_full_url(self): + """获取完整的用户URL(包含域名)""" + site = get_current_site().domain # 获取当前站点的域名 + url = "https://{site}{path}".format(site=site, # 格式化完整URL + path=self.get_absolute_url()) + return url + + class Meta: + """模型的元数据配置""" + ordering = ['-id'] # 默认按ID降序排列 + verbose_name = _('user') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称(与单数相同) + get_latest_by = 'id' # 指定获取最新记录的字段 \ No newline at end of file diff --git a/src/accounts/templatetags/__init__.py b/src/accounts/templatetags/__init__.py new file mode 100644 index 0000000..50a55dc --- /dev/null +++ b/src/accounts/templatetags/__init__.py @@ -0,0 +1,2 @@ +#zh: +#coding:utf-8 \ No newline at end of file diff --git a/src/accounts/templatetags/__pycache__/__init__.cpython-312.pyc b/src/accounts/templatetags/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..6d8cb13 Binary files /dev/null and b/src/accounts/templatetags/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/accounts/tests.py b/src/accounts/tests.py new file mode 100644 index 0000000..a7ae4ff --- /dev/null +++ b/src/accounts/tests.py @@ -0,0 +1,245 @@ +#zh: +#coding:utf-8 +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 + + +# 在此处创建测试 + +class AccountTest(TestCase): + """账户功能测试类""" + + def setUp(self): + """测试初始化方法,在每个测试方法执行前运行""" + self.client = Client() # 创建测试客户端 + self.factory = RequestFactory() # 创建请求工厂 + # 创建测试用户 + self.blog_user = BlogUser.objects.create_user( + username="test", + email="admin@admin.com", + password="12345678" + ) + self.new_test = "xxx123--=" # 新密码用于测试 + + def test_validate_account(self): + """测试账户验证功能""" + 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) # 断言登录成功 + # 测试访问管理员页面 + 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.status = 'p' # 发布状态 + article.save() + + # 测试访问文章管理页面 + response = self.client.get(article.get_admin_url()) + self.assertEqual(response.status_code, 200) # 断言可以访问文章管理页面 + + def test_validate_register(self): + """测试用户注册功能""" + # 验证注册前用户不存在 + self.assertEquals( + 0, len( + BlogUser.objects.filter( + email='user123@user.com'))) + # 发送注册请求 + 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', + }) + # 验证注册后用户存在 + self.assertEquals( + 1, len( + BlogUser.objects.filter( + email='user123@user.com'))) + + # 获取新注册的用户 + user = BlogUser.objects.filter(email='user123@user.com')[0] + # 生成验证签名 + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + path = reverse('accounts:result') + url = '{path}?type=validation&id={id}&sign={sign}'.format( + path=path, id=user.id, sign=sign) + # 测试验证页面访问 + 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 + 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')) + 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' # 错误密码 + }) + 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" + code = generate_code() # 生成验证码 + utils.set_code(to_email, code) # 设置验证码 + utils.send_verify_email(to_email, code) # 发送验证邮件 + + # 测试正确验证码验证 + 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): + """测试成功发送忘记密码验证码""" + 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") # 断言返回成功信息 + + def test_forget_password_email_code_fail(self): + """测试发送忘记密码验证码失败情况""" + # 测试空邮箱 + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict() + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + # 测试无效邮箱格式 + 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, # 验证码 + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + self.assertEqual(resp.status_code, 302) # 断言重定向(成功) + + # 验证用户密码是否修改成功 + blog_user = BlogUser.objects.filter( + email=self.blog_user.email, + ).first() # type: BlogUser + self.assertNotEqual(blog_user, None) # 断言用户存在 + self.assertEqual(blog_user.check_password(data["new_password1"]), True) # 断言密码修改成功 + + def test_forget_password_email_not_user(self): + """测试不存在的用户重置密码""" + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email="123@123.com", # 不存在的邮箱 + code="123456", + ) + 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): + """测试验证码错误的重置密码""" + 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", # 错误的验证码 + ) + 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/urls.py b/src/accounts/urls.py new file mode 100644 index 0000000..d4ba77f --- /dev/null +++ b/src/accounts/urls.py @@ -0,0 +1,42 @@ +#zh: +#coding:utf-8 +from django.urls import path # 导入路径路由 +from django.urls import re_path # 导入正则表达式路由 + +from . import views # 导入当前应用的视图模块 +from .forms import LoginForm # 导入自定义登录表单 + +app_name = "accounts" # 定义应用命名空间,用于URL反向解析 + +urlpatterns = [ + # 用户登录路由 + re_path(r'^login/$', # 匹配以/login/结尾的URL + views.LoginView.as_view(success_url='/'), # 使用类视图,登录成功后跳转到首页 + name='login', # URL名称,用于反向解析 + kwargs={'authentication_form': LoginForm}), # 传递自定义登录表单 + + # 用户注册路由 + re_path(r'^register/$', # 匹配以/register/结尾的URL + views.RegisterView.as_view(success_url="/"), # 使用类视图,注册成功后跳转到首页 + name='register'), # URL名称,用于反向解析 + + # 用户登出路由 + re_path(r'^logout/$', # 匹配以/logout/结尾的URL + views.LogoutView.as_view(), # 使用类视图处理登出 + name='logout'), # URL名称,用于反向解析 + + # 账户操作结果页面路由 + path(r'account/result.html', # 精确匹配/account/result.html路径 + views.account_result, # 使用函数视图 + name='result'), # URL名称,用于反向解析 + + # 忘记密码页面路由(表单提交) + re_path(r'^forget_password/$', # 匹配以/forget_password/结尾的URL + views.ForgetPasswordView.as_view(), # 使用类视图处理忘记密码逻辑 + name='forget_password'), # URL名称,用于反向解析 + + # 获取忘记密码验证码路由 + re_path(r'^forget_password_code/$', # 匹配以/forget_password_code/结尾的URL + views.ForgetPasswordEmailCode.as_view(), # 使用类视图发送验证码 + name='forget_password_code'), # URL名称,用于反向解析 +] \ No newline at end of file diff --git a/src/accounts/user_login_backend.py b/src/accounts/user_login_backend.py new file mode 100644 index 0000000..b0e8ebf --- /dev/null +++ b/src/accounts/user_login_backend.py @@ -0,0 +1,59 @@ +#zh: +#coding:utf-8 +from django.contrib.auth import get_user_model # 导入获取用户模型的方法 +from django.contrib.auth.backends import ModelBackend # 导入Django认证后端基类 + + +class EmailOrUsernameModelBackend(ModelBackend): + """ + 自定义认证后端:允许使用用户名或邮箱登录 + 扩展了Django的默认认证系统,支持更灵活的登录方式 + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + """ + 用户认证方法 + 重写父类方法,支持用户名和邮箱两种登录方式 + + Args: + request: HTTP请求对象 + username: 用户输入的用户名或邮箱 + password: 用户输入的密码 + **kwargs: 其他参数 + + Returns: + User: 认证成功的用户对象 + None: 认证失败 + """ + # 判断输入的是邮箱还是用户名 + if '@' in username: + kwargs = {'email': username} # 如果包含@符号,按邮箱处理 + else: + kwargs = {'username': username} # 否则按用户名处理 + + try: + # 根据用户名或邮箱查找用户 + user = get_user_model().objects.get(**kwargs) + # 验证密码是否正确 + if user.check_password(password): + return user # 认证成功,返回用户对象 + except get_user_model().DoesNotExist: + # 用户不存在,返回None表示认证失败 + return None + + def get_user(self, user_id): + """ + 根据用户ID获取用户对象 + 用于会话管理,保持用户登录状态 + + Args: + user_id: 用户ID + + Returns: + User: 用户对象 + None: 用户不存在 + """ + try: + return get_user_model().objects.get(pk=user_id) # 根据主键查找用户 + except get_user_model().DoesNotExist: + return None # 用户不存在 \ No newline at end of file diff --git a/src/accounts/utils.py b/src/accounts/utils.py new file mode 100644 index 0000000..c180317 --- /dev/null +++ b/src/accounts/utils.py @@ -0,0 +1,76 @@ +#zh: +#coding:utf-8 +import typing # 导入类型提示模块 +from datetime import timedelta # 导入时间间隔模块 + +from django.core.cache import cache # 导入Django缓存框架 +from django.utils.translation import gettext # 导入翻译函数 +from django.utils.translation import gettext_lazy as _ # 导入惰性翻译 + +from djangoblog.utils import send_email # 导入自定义邮件发送工具 + +# 定义验证码的有效期:5分钟 +_code_ttl = timedelta(minutes=5) + + +def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): + """ + 发送验证邮件(用于密码重置等场景) + + 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} + # 调用邮件发送函数发送邮件 + send_email([to_mail], subject, html_content) + + +def verify(email: str, code: str) -> typing.Optional[str]: + """ + 验证邮箱验证码是否正确 + + Args: + email: 邮箱地址 + code: 用户输入的验证码 + + Returns: + str: 如果验证失败返回错误信息,验证成功返回None + + Note: + 这里的错误处理不太合理,应该采用raise抛出异常 + 否则调用方也需要对error进行处理 + """ + cache_code = get_code(email) # 从缓存中获取该邮箱对应的验证码 + if cache_code != code: # 比较缓存中的验证码和用户输入的验证码 + return gettext("Verification code error") # 验证码错误,返回错误信息 + # 验证成功返回None + + +def set_code(email: str, code: str): + """ + 将验证码存储到缓存中 + + Args: + email: 邮箱地址(作为缓存的key) + code: 验证码(作为缓存的value) + """ + # 使用邮箱作为key,验证码作为value,设置过期时间为5分钟 + cache.set(email, code, _code_ttl.seconds) + + +def get_code(email: str) -> typing.Optional[str]: + """ + 从缓存中获取验证码 + + Args: + email: 邮箱地址(缓存的key) + + Returns: + str: 如果存在返回验证码,不存在返回None + """ + return cache.get(email) # 从缓存中获取指定邮箱的验证码 \ No newline at end of file diff --git a/src/accounts/views.py b/src/accounts/views.py new file mode 100644 index 0000000..bad2752 --- /dev/null +++ b/src/accounts/views.py @@ -0,0 +1,249 @@ +#zh: +#coding:utf-8 +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 +from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm +from .models import BlogUser + +# 获取当前模块的日志记录器 +logger = logging.getLogger(__name__) + + +# 在此处创建视图 + +class RegisterView(FormView): + """用户注册视图""" + form_class = RegisterForm # 使用自定义注册表单 + template_name = 'account/registration_form.html' # 注册模板路径 + + @method_decorator(csrf_protect) # CSRF保护装饰器 + def dispatch(self, *args, **kwargs): + """请求分发方法""" + return super(RegisterView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + """表单验证通过后的处理""" + if form.is_valid(): + # 保存用户但不提交到数据库 + user = form.save(False) + user.is_active = False # 设置用户为未激活状态 + user.source = 'Register' # 记录用户来源 + user.save(True) # 保存用户到数据库 + + # 获取当前站点信息 + site = get_current_site().domain + # 生成邮箱验证签名 + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + + # 调试模式下使用本地地址 + if settings.DEBUG: + site = '127.0.0.1:8000' + + # 构建验证URL + path = reverse('account:result') + url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( + site=site, path=path, id=user.id, sign=sign) + + # 构建邮件内容 + content = """ +

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

+ + {url} + + 再次感谢您! +
+ 如果上面链接无法打开,请将此链接复制至浏览器。 + {url} + """.format(url=url) + # 发送验证邮件 + send_email( + emailto=[user.email], + title='验证您的电子邮箱', + content=content) + + # 重定向到结果页面 + url = reverse('accounts:result') + '?type=register&id=' + str(user.id) + return HttpResponseRedirect(url) + else: + # 表单无效,重新渲染表单页 + return self.render_to_response({'form': form}) + + +class LogoutView(RedirectView): + """用户登出视图""" + url = '/login/' # 登出后重定向的URL + + @method_decorator(never_cache) # 禁止缓存装饰器 + def dispatch(self, request, *args, **kwargs): + """请求分发方法""" + return super(LogoutView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + """处理GET请求""" + logout(request) # 执行登出操作 + delete_sidebar_cache() # 删除侧边栏缓存 + return super(LogoutView, self).get(request, *args, **kwargs) + + +class LoginView(FormView): + """用户登录视图""" + form_class = LoginForm # 使用自定义登录表单 + template_name = 'account/login.html' # 登录模板路径 + success_url = '/' # 登录成功默认重定向URL + redirect_field_name = REDIRECT_FIELD_NAME # 重定向字段名 + login_ttl = 2626560 # 会话有效期:一个月(以秒为单位) + + # 方法装饰器:保护敏感数据、CSRF防护、禁止缓存 + @method_decorator(sensitive_post_parameters('password')) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + """请求分发方法""" + return super(LoginView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + """获取模板上下文数据""" + # 获取重定向URL + 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内置的认证表单 + 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()) + + # 处理"记住我"功能 + if self.request.POST.get("remember"): + self.request.session.set_expiry(self.login_ttl) # 设置会话有效期 + + return super(LoginView, self).form_valid(form) + else: + # 表单无效,重新渲染表单页 + return self.render_to_response({'form': form}) + + def get_success_url(self): + """获取登录成功后的重定向URL""" + redirect_to = self.request.POST.get(self.redirect_field_name) + # 验证URL安全性 + if not url_has_allowed_host_and_scheme( + url=redirect_to, allowed_hosts=[self.request.get_host()]): + redirect_to = self.success_url # 不安全的URL使用默认URL + return redirect_to + + +def account_result(request): + """账户操作结果页面视图函数""" + 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': + # 注册成功页面内容 + content = ''' + 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 + ''' + title = '注册成功' + else: + # 邮箱验证处理 + c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + sign = request.GET.get('sign') + # 验证签名 + if sign != c_sign: + return HttpResponseForbidden() # 签名不匹配,拒绝访问 + # 激活用户 + user.is_active = True + user.save() + content = ''' + 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 + ''' + title = '验证成功' + # 渲染结果页面 + return render(request, 'account/result.html', { + 'title': title, + 'content': content + }) + else: + # 无效类型,重定向到首页 + return HttpResponseRedirect('/') + + +class ForgetPasswordView(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() + # 对密码进行哈希处理 + 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): + """忘记密码验证码发送视图""" + + 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() + utils.send_verify_email(to_email, code) + utils.set_code(to_email, code) + + return HttpResponse("ok") # 返回成功响应 \ No newline at end of file