Compare commits

...

8 Commits

Binary file not shown.

@ -1,60 +1,120 @@
# ===================== 导入必要的模块 =====================
# Django 的表单模块,用于定义和处理表单
from django import forms from django import forms
# Django 内置的用户管理后台类,提供用户管理的默认后台界面和功能
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
# Django 内置的用于编辑用户的表单类,已经包含对密码等敏感信息的处理
from django.contrib.auth.forms import UserChangeForm from django.contrib.auth.forms import UserChangeForm
# Django 内置的用于处理用户名字段的字段类,带有默认校验规则
from django.contrib.auth.forms import UsernameField from django.contrib.auth.forms import UsernameField
# Django 的翻译工具用于支持多语言i18n_() 是常用的翻译函数别名
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Register your models here. # 从当前目录下的 models 模块中导入自定义的用户模型 BlogUser
from .models import BlogUser from .models import BlogUser
# ===================== 自定义:用户创建表单 =====================
# 用于在 Django Admin 后台创建新用户时使用的表单
class BlogUserCreationForm(forms.ModelForm): class BlogUserCreationForm(forms.ModelForm):
# 定义密码输入字段1用户输入密码使用密码框输入内容不可见
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
# 定义密码输入字段2用户再次输入密码以确认同样使用密码框
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta: class Meta:
# 指定该表单关联的模型是 BlogUser你的自定义用户模型
model = BlogUser model = BlogUser
# 指定表单中只包含 email 字段,即创建用户时只需填写邮箱
fields = ('email',) fields = ('email',)
def clean_password2(self): def clean_password2(self):
# Check that the two password entries match """
校验两次输入的密码是否一致
该方法在表单验证过程中自动调用用于确保 password1 password2 相同
"""
# 从表单清洗后的数据中获取 password1 和 password2
password1 = self.cleaned_data.get("password1") password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2") password2 = self.cleaned_data.get("password2")
# 如果两个密码都不为空,但它们不相同,则抛出验证错误,提示用户“密码不匹配”
if password1 and password2 and password1 != password2: if password1 and password2 and password1 != password2:
raise forms.ValidationError(_("passwords do not match")) raise forms.ValidationError(_("passwords do not match")) # 国际化提示信息
# 验证通过,返回 password2
return password2 return password2
def save(self, commit=True): def save(self, commit=True):
# Save the provided password in hashed format """
保存用户对象到数据库
重写了 ModelForm save 方法在保存之前对密码进行哈希处理并设置用户来源
"""
# 调用父类的 save 方法但先不提交到数据库commit=False
user = super().save(commit=False) user = super().save(commit=False)
# 对用户输入的密码password1进行哈希处理并设置为用户的密码安全存储不可逆
user.set_password(self.cleaned_data["password1"]) user.set_password(self.cleaned_data["password1"])
if commit: if commit:
# 如果 commit 为 True默认即为 True则保存用户到数据库
# 设置用户来源为 'adminsite',表示该用户是通过后台管理界面创建的
user.source = 'adminsite' user.source = 'adminsite'
user.save() user.save() # 将用户对象保存到数据库
# 返回保存好的用户对象
return user return user
# ===================== 自定义:用户编辑表单 =====================
# 用于在 Django Admin 后台编辑已有用户信息时使用的表单
class BlogUserChangeForm(UserChangeForm): class BlogUserChangeForm(UserChangeForm):
class Meta: class Meta:
# 指定该表单关联的模型是 BlogUser
model = BlogUser model = BlogUser
# 表单中显示所有字段,即管理员可以编辑该用户的所有信息
fields = '__all__' fields = '__all__'
# 指定 username 字段使用 Django 提供的 UsernameField以利用其内置校验
field_classes = {'username': UsernameField} field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# 调用父类的构造方法,保持默认行为
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# ===================== 自定义Django Admin 用户管理类 =====================
# 用于自定义 Django Admin 后台中用户BlogUser的展示、搜索、排序、表单等行为
class BlogUserAdmin(UserAdmin): class BlogUserAdmin(UserAdmin):
# 指定用于编辑用户信息的表单为 BlogUserChangeForm我们自定义的编辑表单
form = BlogUserChangeForm form = BlogUserChangeForm
# 指定用于创建新用户的表单为 BlogUserCreationForm我们自定义的创建表单
add_form = BlogUserCreationForm add_form = BlogUserCreationForm
# 定义在 Django Admin 用户列表页面中显示哪些字段
list_display = ( list_display = (
'id', 'id', # 用户的唯一标识 ID
'nickname', 'nickname', # 用户昵称(自定义字段)
'username', 'username', # 用户名
'email', 'email', # 用户邮箱
'last_login', 'last_login', # 上次登录时间
'date_joined', 'date_joined', # 用户注册时间
'source') 'source' # 用户注册来源(如 Web、adminsite 等,自定义字段)
)
# 定义在用户列表页中,哪些字段可以作为链接,点击后跳转到该用户的编辑页面
list_display_links = ('id', 'username') list_display_links = ('id', 'username')
# 定义默认的排序方式:按照 ID 降序排列(即最新创建的用户排在最前面)
ordering = ('-id',) ordering = ('-id',)
search_fields = ('username', 'nickname', 'email')
# 定义在用户列表页中,可以通过哪些字段进行搜索(支持模糊匹配)
search_fields = ('username', 'nickname', 'email')

@ -1,5 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
# 定义 accounts 应用的配置类
class AccountsConfig(AppConfig): class AccountsConfig(AppConfig):
name = 'accounts' # 应用的名称为 'accounts'
name = 'accounts'

@ -1,117 +1,77 @@
from django import forms from django import forms
from django.contrib.auth import get_user_model, password_validation from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.forms import UserChangeForm
from django.core.exceptions import ValidationError from django.contrib.auth.forms import UsernameField
from django.forms import widgets
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from . import utils
from .models import BlogUser
class LoginForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
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): # 从当前目录的 models 导入自定义用户模型 BlogUser
def __init__(self, *args, **kwargs): from .models import BlogUser
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): # 自定义用户创建表单(用于 Django Admin 后台创建普通用户)
email = self.cleaned_data['email'] class BlogUserCreationForm(forms.ModelForm):
if get_user_model().objects.filter(email=email).exists(): # 密码字段1标签为“password”使用密码输入框
raise ValidationError(_("email already exists")) password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
return email # 密码字段2用于确认密码标签为“Enter password again”使用密码输入框
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta: class Meta:
model = get_user_model() # 指定模型为 BlogUser
fields = ("username", "email") model = BlogUser
# 表单只显示 email 字段(用于创建时输入邮箱)
fields = ('email',)
class ForgetPasswordForm(forms.Form):
new_password1 = forms.CharField( def clean_password2(self):
label=_("New password"), # 获取用户输入的两次密码
widget=forms.PasswordInput( password1 = self.cleaned_data.get("password1")
attrs={ password2 = self.cleaned_data.get("password2")
"class": "form-control", # 如果两次密码都填写了但不一致,抛出验证错误
'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):
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
if password1 and password2 and password1 != password2: if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match")) raise forms.ValidationError(_("passwords do not match"))
password_validation.validate_password(password2)
return password2 return password2
def clean_email(self): def save(self, commit=True):
user_email = self.cleaned_data.get("email") # 先调用父类的 save 方法但不立即提交到数据库commit=False
if not BlogUser.objects.filter( user = super().save(commit=False)
email=user_email # 对用户输入的密码进行哈希处理再保存
).exists(): user.set_password(self.cleaned_data["password1"])
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 if commit:
raise ValidationError(_("email does not exist")) # 设置用户来源为 adminsite表示是通过后台创建的
return user_email user.source = 'adminsite'
user.save() # 保存用户到数据库
return user
def clean_code(self):
code = self.cleaned_data.get("code")
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
)
if error:
raise ValidationError(error)
return code
# 自定义用户编辑表单(用于 Django Admin 后台编辑用户信息)
class BlogUserChangeForm(UserChangeForm):
class Meta:
model = BlogUser
# 表单显示所有字段
fields = '__all__'
# 指定 username 字段使用 Django 提供的 UsernameField 类
field_classes = {'username': UsernameField}
class ForgetPasswordCodeForm(forms.Form): def __init__(self, *args, **kwargs):
email = forms.EmailField( super().__init__(*args, **kwargs)
label=_('Email'),
)
# 自定义 Django Admin 中的用户管理类
class BlogUserAdmin(UserAdmin):
# 指定用户信息修改时使用的表单
form = BlogUserChangeForm
# 指定用户创建时使用的表单
add_form = BlogUserCreationForm
# 列表页显示的字段ID、昵称、用户名、邮箱、最后登录时间、注册时间、来源
list_display = (
'id',
'nickname',
'username',
'email',
'last_login',
'date_joined',
'source')
# 列表页中可点击的字段用于跳转到编辑页ID 和 用户名
list_display_links = ('id', 'username')
# 默认排序方式:按 ID 倒序
ordering = ('-id',)
# 支持搜索的字段:用户名、昵称、邮箱
search_fields = ('username', 'nickname', 'email')

@ -1,49 +1,127 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14 # Generated by Django 4.1.7 on 2023-03-02 07:14
# 该文件由 Django 4.1.7 版本在 2023年3月2日 07:14 自动生成,
# 用于记录你对模型Model所做的变更以便同步到数据库。
# 导入 Django 内置的用户管理相关模型和验证器
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
# 导入 Django 的数据库迁移核心模块,用于定义数据库变更操作
from django.db import migrations, models from django.db import migrations, models
# 导入 Django 的时间工具模块,用于获取当前时间(带时区)
import django.utils.timezone import django.utils.timezone
# 定义一个迁移类,继承自 migrations.Migration
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 表示这是该应用(如 blog的第一个迁移文件通常是 0001_initial.py
initial = True initial = True
# 当前迁移所依赖的其他迁移
# 这里依赖 Django 内置的 auth 应用的某个迁移,确保权限、用户组等功能先被创建
dependencies = [ dependencies = [
('auth', '0012_alter_user_first_name_max_length'), ('auth', '0012_alter_user_first_name_max_length'),
] ]
# 定义该迁移要执行的所有数据库操作,这里只有一个:创建 BlogUser 模型(表)
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='BlogUser', name='BlogUser', # 模型名称,对应数据库中的表名通常是 blog_bloguser根据 app_label
fields=[ fields=[
# 主键 ID自增大整数是模型的主键Django 默认会为每个模型添加此字段
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 用户密码字段,存储的是加密后的密码字符串,长度固定为 128 个字符
('password', models.CharField(max_length=128, verbose_name='password')), ('password', models.CharField(max_length=128, verbose_name='password')),
# 记录用户最后一次登录的时间,允许为空(如用户从未登录过)
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), # 是否是超级用户(管理员),默认为 False超级用户拥有所有权限
('is_superuser', models.BooleanField(default=False,
help_text='Designates that this user has all permissions without explicitly assigning them.',
verbose_name='superuser status')),
# 用户名,必须唯一,最大长度 150只允许字母、数字和部分符号如 @ . + - _
# 如果重复会提示错误A user with that username already exists.
('username', models.CharField(
error_messages={'unique': 'A user with that username already exists.'},
help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.',
max_length=150,
unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name='username'
)),
# 用户的名字First Name如“名”可为空
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
# 用户的姓氏Last Name如“姓”可为空
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
# 用户的邮箱地址,使用 EmailField 格式校验,可为空
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), # 是否是员工用户,即是否允许登录 Django Admin 后台,默认为 False
('is_staff', models.BooleanField(default=False,
help_text='Designates whether the user can log into this admin site.',
verbose_name='staff status')),
# 是否是活跃用户True 表示正常False 表示禁用;推荐用此字段禁用账户而非删除
('is_active', models.BooleanField(default=True,
help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.',
verbose_name='active')),
# 用户注册时间,创建用户时默认为当前时间
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
# 【自定义字段】用户昵称,用于前台展示,非必填,最大长度 100
('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')), ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
# 【自定义字段】用户账户的创建时间,通常在创建时自动设置为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 【自定义字段】用户信息的最后修改时间,通常需在代码中手动更新
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 【自定义字段】记录用户是从哪个渠道注册的,如 Web、微信、QQ 等,可为空
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')), ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), # 【关联字段】用户所属的用户组Group一个用户可以属于多个组
# 组可以拥有权限,用户通过组间接获得权限
# blank=True 表示可以不选择任何组
('groups', models.ManyToManyField(
blank=True,
help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.',
related_name='user_set',
related_query_name='user',
to='auth.group',
verbose_name='groups'
)),
# 【关联字段】直接分配给该用户的具体权限,可以为空
('user_permissions', models.ManyToManyField(
blank=True,
help_text='Specific permissions for this user.',
related_name='user_set',
related_query_name='user',
to='auth.permission',
verbose_name='user permissions'
)),
], ],
# 模型的元数据配置选项
options={ options={
'verbose_name': '用户', 'verbose_name': '用户', # 在后台或模型信息中显示的单数名称
'verbose_name_plural': '用户', 'verbose_name_plural': '用户', # 复数名称,通常也是“用户”
'ordering': ['-id'], 'ordering': ['-id'], # 默认按 ID 降序排序,即最新用户排在最前
'get_latest_by': 'id', 'get_latest_by': 'id', # 指定通过 id 字段获取“最新”的对象
}, },
# 模型的管理器,用于创建用户、超级用户等
managers=[ managers=[
('objects', django.contrib.auth.models.UserManager()), ('objects', django.contrib.auth.models.UserManager()), # 使用 Django 内置的 UserManager
], ],
), ),
] ]

@ -1,46 +1,63 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13 # Generated by Django 4.2.5 on 2023-09-06 13:13
# 引入迁移和时区工具模块
from django.db import migrations, models from django.db import migrations, models
import django.utils.timezone import django.utils.timezone
# 定义迁移类
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 该迁移依赖于第一个迁移(即 0001_initial.py表示它是后续的变更
dependencies = [ dependencies = [
('accounts', '0001_initial'), ('accounts', '0001_initial'),
] ]
# 定义该迁移要执行的所有数据库操作
operations = [ operations = [
# 修改 BlogUser 模型的元数据配置,如排序方式、单复数名称等
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='bloguser', name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'}, options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
), ),
# 删除原有的 created_time 字段(用户创建时间)
migrations.RemoveField( migrations.RemoveField(
model_name='bloguser', model_name='bloguser',
name='created_time', name='created_time',
), ),
# 删除原有的 last_mod_time 字段(用户信息最后修改时间)
migrations.RemoveField( migrations.RemoveField(
model_name='bloguser', model_name='bloguser',
name='last_mod_time', name='last_mod_time',
), ),
# 新增 creation_time 字段,替代 created_time表示用户创建时间
migrations.AddField( migrations.AddField(
model_name='bloguser', model_name='bloguser',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 新增 last_modify_time 字段,替代 last_mod_time表示用户信息最后修改时间
migrations.AddField( migrations.AddField(
model_name='bloguser', model_name='bloguser',
name='last_modify_time', name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
), ),
# 修改 nickname 字段的显示名称verbose_name从 "昵称" 改为 "nick name"
migrations.AlterField( migrations.AlterField(
model_name='bloguser', model_name='bloguser',
name='nickname', name='nickname',
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
), ),
# 修改 source 字段的显示名称verbose_name从 "创建来源" 改为 "create source"
migrations.AlterField( migrations.AlterField(
model_name='bloguser', model_name='bloguser',
name='source', name='source',
field=models.CharField(blank=True, max_length=100, verbose_name='create source'), field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
), ),
] ]

@ -5,23 +5,28 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site from djangoblog.utils import get_current_site
# 自定义用户模型,继承自 Django 的 AbstractUser
# Create your models here.
class BlogUser(AbstractUser): class BlogUser(AbstractUser):
# 昵称,最大长度 100允许为空
nickname = models.CharField(_('nick name'), max_length=100, blank=True) nickname = models.CharField(_('nick name'), max_length=100, blank=True)
# 创建时间,默认为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now) creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间,默认为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now) last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 用户创建来源,比如 'adminsite' 或 'register',允许为空
source = models.CharField(_('create source'), max_length=100, blank=True) source = models.CharField(_('create source'), max_length=100, blank=True)
# 获取用户详情页的相对 URL
def get_absolute_url(self): def get_absolute_url(self):
return reverse( return reverse(
'blog:author_detail', kwargs={ 'blog:author_detail', kwargs={
'author_name': self.username}) 'author_name': self.username})
# 返回用户的邮箱(作为对象的字符串表示)
def __str__(self): def __str__(self):
return self.email return self.email
# 获取用户详情页的完整 URL包含域名
def get_full_url(self): def get_full_url(self):
site = get_current_site().domain site = get_current_site().domain
url = "https://{site}{path}".format(site=site, url = "https://{site}{path}".format(site=site,
@ -29,7 +34,10 @@ class BlogUser(AbstractUser):
return url return url
class Meta: class Meta:
# 默认排序:按 ID 倒序
ordering = ['-id'] ordering = ['-id']
# 模型在后台显示的名称(中文和英文都是 'user'
verbose_name = _('user') verbose_name = _('user')
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
get_latest_by = 'id' # 获取最新记录的依据字段
get_latest_by = 'id'

@ -8,200 +8,49 @@ from blog.models import Article, Category
from djangoblog.utils import * from djangoblog.utils import *
from . import utils from . import utils
# 定义账户相关的测试类
# Create your tests here.
class AccountTest(TestCase): class AccountTest(TestCase):
def setUp(self): def setUp(self):
# 初始化测试客户端和请求工厂
self.client = Client() self.client = Client()
self.factory = RequestFactory() self.factory = RequestFactory()
# 创建一个普通测试用户
self.blog_user = BlogUser.objects.create_user( self.blog_user = BlogUser.objects.create_user(
username="test", username="test",
email="admin@admin.com", email="admin@admin.com",
password="12345678" password="12345678"
) )
# 用于后续测试的新密码
self.new_test = "xxx123--=" self.new_test = "xxx123--="
def test_validate_account(self): 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): 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): 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): 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): 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): 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): 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): 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)

@ -1,28 +1,38 @@
from django.urls import path from django.urls import path, re_path
from django.urls import re_path
from . import views from . import views
from .forms import LoginForm from .forms import LoginForm
app_name = "accounts" app_name = "accounts" # 定义该 URLconf 的命名空间为 accounts
urlpatterns = [
# 登录路由:使用自定义的 LoginView指定登录表单为 LoginForm登录成功跳转首页
re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login',
kwargs={'authentication_form': LoginForm}),
# 注册路由:使用自定义的 RegisterView注册成功跳转首页
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"),
name='register'),
# 登出路由:使用自定义的 LogoutView登出后跳转登录页
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
# 验证结果页路由:如邮箱验证后展示结果
path(r'account/result.html',
views.account_result,
name='result'),
# 忘记密码主页面路由
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
urlpatterns = [re_path(r'^login/$', # 忘记密码时请求验证码的路由
views.LoginView.as_view(success_url='/'), re_path(r'^forget_password_code/$',
name='login', views.ForgetPasswordEmailCode.as_view(),
kwargs={'authentication_form': LoginForm}), name='forget_password_code'),
re_path(r'^register/$', ]
views.RegisterView.as_view(success_url="/"),
name='register'),
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
path(r'account/result.html',
views.account_result,
name='result'),
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'),
]

@ -1,26 +1,78 @@
# 从 Django 的 auth 模块中导入 get_user_model 函数
# 该函数用于获取当前项目中使用的用户模型(比如你自定义的 BlogUser
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
# 从 Django 的 auth.backends 模块中导入 ModelBackend
# ModelBackend 是 Django 默认的用户认证后端,提供基础的 authenticate 和 get_user 方法
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
# ===================== 自定义认证后端类 =====================
# 类名EmailOrUsernameModelBackend
# 作用:扩展 Django 默认的用户认证方式,允许用户使用「用户名」或「邮箱」登录
class EmailOrUsernameModelBackend(ModelBackend): class EmailOrUsernameModelBackend(ModelBackend):
""" """
允许使用用户名或邮箱登录 允许使用用户名或邮箱登录
-----------
重写了 authenticate 方法使其支持
- 如果传入的 username 参数中包含 '@' 符号则认为用户想用邮箱登录
- 否则认为用户想用用户名登录
然后尝试根据 username email 查找用户并校验密码是否正确
""" """
def authenticate(self, request, username=None, password=None, **kwargs): def authenticate(self, request, username=None, password=None, **kwargs):
"""
自定义用户认证逻辑
:param request: HttpRequest 对象通常可以忽略但保留以兼容 Django 的调用方式
:param username: 用户输入的登录名可能是用户名也可能是邮箱
:param password: 用户输入的密码
:param kwargs: 其它参数一般用不到
:return: 如果认证成功返回用户对象否则返回 None
"""
# 判断用户输入的 username 是否包含 '@' 符号
# 如果包含,通常意味着用户输入的是邮箱,因此我们将以邮箱进行查询
if '@' in username: if '@' in username:
# 构造查询参数,告诉 Django 我们要根据 email 查找用户
kwargs = {'email': username} kwargs = {'email': username}
# 如果不包含 '@',则认为用户输入的是用户名
else: else:
# 构造查询参数,告诉 Django 我们要根据 username 查找用户
kwargs = {'username': username} kwargs = {'username': username}
try: try:
# 根据上面构造的参数(可能是 email 或 username从数据库中查找用户
# get_user_model() 获取当前项目使用的用户模型(比如 BlogUser
# objects.get(**kwargs) 尝试获取唯一匹配的用户
user = get_user_model().objects.get(**kwargs) user = get_user_model().objects.get(**kwargs)
# 检查用户输入的密码是否与数据库中存储的哈希密码匹配
if user.check_password(password): if user.check_password(password):
# 如果密码正确,返回该用户对象,表示认证成功
return user return user
except get_user_model().DoesNotExist: except get_user_model().DoesNotExist:
# 如果根据 username 或 email 找不到对应的用户,则捕获 DoesNotExist 异常
# 表示没有这个用户,返回 None 表示认证失败
return None return None
# 如果密码不正确,也会走到这里,返回 None 表示认证失败
return None
def get_user(self, username): def get_user(self, username):
"""
根据用户 ID通常是主键 pk获取用户对象
:param username: 这里的参数名虽然是 username但实际上传入的是用户的 PK如用户ID
:return: 返回对应的用户对象如果找不到则返回 None
"""
try: try:
# 根据主键通常是用户ID从数据库中获取用户对象
# get_user_model() 获取当前使用的用户模型
# objects.get(pk=username) 通过主键查找用户
return get_user_model().objects.get(pk=username) return get_user_model().objects.get(pk=username)
except get_user_model().DoesNotExist: except get_user_model().DoesNotExist:
return None # 如果根据主键找不到用户,捕获异常并返回 None
return None

@ -7,43 +7,26 @@ from django.utils.translation import gettext_lazy as _
from djangoblog.utils import send_email from djangoblog.utils import send_email
# 验证码有效期为 5 分钟
_code_ttl = timedelta(minutes=5) _code_ttl = timedelta(minutes=5)
# 发送验证邮件(如邮箱验证或忘记密码验证码)
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""发送重设密码验证码
Args:
to_mail: 接受邮箱
subject: 邮件主题
code: 验证码
"""
html_content = _( html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it " "You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code} "properly") % {'code': code}
send_email([to_mail], subject, html_content) send_email([to_mail], subject, html_content)
# 校验验证码是否正确
def verify(email: str, code: str) -> typing.Optional[str]: def verify(email: str, code: str) -> typing.Optional[str]:
"""验证code是否有效
Args:
email: 请求邮箱
code: 验证码
Return:
如果有错误就返回错误str
Node:
这里的错误处理不太合理应该采用raise抛出
否测调用方也需要对error进行处理
"""
cache_code = get_code(email) cache_code = get_code(email)
if cache_code != code: if cache_code != code:
return gettext("Verification code error") return gettext("Verification code error")
# 将验证码存储到缓存中(如 Redis并设置过期时间
def set_code(email: str, code: str): def set_code(email: str, code: str):
"""设置code"""
cache.set(email, code, _code_ttl.seconds) cache.set(email, code, _code_ttl.seconds)
# 从缓存中获取验证码
def get_code(email: str) -> typing.Optional[str]: def get_code(email: str) -> typing.Optional[str]:
"""获取code""" return cache.get(email)
return cache.get(email)

@ -28,9 +28,7 @@ from .models import BlogUser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 注册视图:处理用户注册请求,注册后发送验证邮件
# Create your views here.
class RegisterView(FormView): class RegisterView(FormView):
form_class = RegisterForm form_class = RegisterForm
template_name = 'account/registration_form.html' template_name = 'account/registration_form.html'
@ -42,7 +40,7 @@ class RegisterView(FormView):
def form_valid(self, form): def form_valid(self, form):
if form.is_valid(): if form.is_valid():
user = form.save(False) user = form.save(False)
user.is_active = False user.is_active = False # 注册后默认不激活,需通过邮箱验证
user.source = 'Register' user.source = 'Register'
user.save(True) user.save(True)
site = get_current_site().domain site = get_current_site().domain
@ -79,7 +77,7 @@ class RegisterView(FormView):
'form': form 'form': form
}) })
# 登出视图:处理用户登出,登出后跳转登录页
class LogoutView(RedirectView): class LogoutView(RedirectView):
url = '/login/' url = '/login/'
@ -92,19 +90,18 @@ class LogoutView(RedirectView):
delete_sidebar_cache() delete_sidebar_cache()
return super(LogoutView, self).get(request, *args, **kwargs) return super(LogoutView, self).get(request, *args, **kwargs)
# 登录视图:处理用户登录请求,支持记住登录状态
class LoginView(FormView): class LoginView(FormView):
form_class = LoginForm form_class = LoginForm
template_name = 'account/login.html' template_name = 'account/login.html'
success_url = '/' success_url = '/'
redirect_field_name = REDIRECT_FIELD_NAME redirect_field_name = REDIRECT_FIELD_NAME
login_ttl = 2626560 # 一个月的时间 login_ttl = 2626560 # 一个月(单位:秒)
@method_decorator(sensitive_post_parameters('password')) @method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect) @method_decorator(csrf_protect)
@method_decorator(never_cache) @method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
return super(LoginView, self).dispatch(request, *args, **kwargs) return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -112,28 +109,23 @@ class LoginView(FormView):
if redirect_to is None: if redirect_to is None:
redirect_to = '/' redirect_to = '/'
kwargs['redirect_to'] = redirect_to kwargs['redirect_to'] = redirect_to
return super(LoginView, self).get_context_data(**kwargs) return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form): def form_valid(self, form):
form = AuthenticationForm(data=self.request.POST, request=self.request) form = AuthenticationForm(data=self.request.POST, request=self.request)
if form.is_valid(): if form.is_valid():
delete_sidebar_cache() delete_sidebar_cache()
logger.info(self.redirect_field_name) logger.info(self.redirect_field_name)
auth.login(self.request, form.get_user()) auth.login(self.request, form.get_user())
if self.request.POST.get("remember"): if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl) self.request.session.set_expiry(self.login_ttl)
return super(LoginView, self).form_valid(form) return super(LoginView, self).form_valid(form)
# return HttpResponseRedirect('/')
else: else:
return self.render_to_response({ return self.render_to_response({
'form': form 'form': form
}) })
def get_success_url(self): def get_success_url(self):
redirect_to = self.request.POST.get(self.redirect_field_name) redirect_to = self.request.POST.get(self.redirect_field_name)
if not url_has_allowed_host_and_scheme( if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[ url=redirect_to, allowed_hosts=[
@ -141,20 +133,16 @@ class LoginView(FormView):
redirect_to = self.success_url redirect_to = self.success_url
return redirect_to return redirect_to
# 验证结果页视图:处理注册验证或邮箱验证结果
def account_result(request): def account_result(request):
type = request.GET.get('type') type = request.GET.get('type')
id = request.GET.get('id') id = request.GET.get('id')
user = get_object_or_404(get_user_model(), id=id) user = get_object_or_404(get_user_model(), id=id)
logger.info(type)
if user.is_active: if user.is_active:
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
if type and type in ['register', 'validation']: if type and type in ['register', 'validation']:
if type == 'register': if type == 'register':
content = ''' content = '恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。'
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
'''
title = '注册成功' title = '注册成功'
else: else:
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
@ -163,9 +151,7 @@ def account_result(request):
return HttpResponseForbidden() return HttpResponseForbidden()
user.is_active = True user.is_active = True
user.save() user.save()
content = ''' content = '恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。'
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功' title = '验证成功'
return render(request, 'account/result.html', { return render(request, 'account/result.html', {
'title': title, 'title': title,
@ -174,7 +160,7 @@ def account_result(request):
else: else:
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
# 忘记密码视图:处理用户提交的新密码
class ForgetPasswordView(FormView): class ForgetPasswordView(FormView):
form_class = ForgetPasswordForm form_class = ForgetPasswordForm
template_name = 'account/forget_password.html' template_name = 'account/forget_password.html'
@ -188,17 +174,14 @@ class ForgetPasswordView(FormView):
else: else:
return self.render_to_response({'form': form}) return self.render_to_response({'form': form})
# 忘记密码验证码请求视图:处理用户请求发送验证码到邮箱
class ForgetPasswordEmailCode(View): class ForgetPasswordEmailCode(View):
def post(self, request: HttpRequest): def post(self, request: HttpRequest):
form = ForgetPasswordCodeForm(request.POST) form = ForgetPasswordCodeForm(request.POST)
if not form.is_valid(): if not form.is_valid():
return HttpResponse("错误的邮箱") return HttpResponse("错误的邮箱")
to_email = form.cleaned_data["email"] to_email = form.cleaned_data["email"]
code = generate_code() code = generate_code()
utils.send_verify_email(to_email, code) utils.send_verify_email(to_email, code)
utils.set_code(to_email, code) utils.set_code(to_email, code)
return HttpResponse("ok")
return HttpResponse("ok")

@ -5,83 +5,64 @@ from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Register your models here. # 引入当前 app 的模型
from .models import Article, Category, Tag, Links, SideBar, BlogSettings from .models import Article, Category, Tag, Links, SideBar, BlogSettings
# 自定义文章表单(可扩展,比如集成富文本编辑器)
class ArticleForm(forms.ModelForm): class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta: class Meta:
model = Article model = Article
fields = '__all__' fields = '__all__' # 表单包含模型的所有字段
# 定义文章管理操作函数
def makr_article_publish(modeladmin, request, queryset): def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p') queryset.update(status='p') # 批量将文章状态设为已发布
makr_article_publish.short_description = _('发布选中的文章')
def draft_article(modeladmin, request, queryset): def draft_article(modeladmin, request, queryset):
queryset.update(status='d') queryset.update(status='d') # 批量设为草稿
draft_article.short_description = _('将选中文章设为草稿')
def close_article_commentstatus(modeladmin, request, queryset): def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c') queryset.update(comment_status='c') # 关闭评论
close_article_commentstatus.short_description = _('关闭文章评论')
def open_article_commentstatus(modeladmin, request, queryset): def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o') queryset.update(comment_status='o') # 开启评论
open_article_commentstatus.short_description = _('开启文章评论')
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
open_article_commentstatus.short_description = _('Open article comments')
class ArticlelAdmin(admin.ModelAdmin): # 文章管理后台类
list_per_page = 20 class ArticleAdmin(admin.ModelAdmin):
search_fields = ('body', 'title') list_per_page = 20 # 每页显示20条
search_fields = ('body', 'title') # 可搜索字段
form = ArticleForm form = ArticleForm
list_display = ( list_display = ( # 列表页显示的字段
'id', 'id', 'title', 'author', 'link_to_category', 'creation_time',
'title', 'views', 'status', 'type', 'article_order'
'author', )
'link_to_category', list_display_links = ('id', 'title') # 哪些字段可点击进入编辑页
'creation_time', list_filter = ('status', 'type', 'category') # 右侧过滤器
'views', date_hierarchy = 'creation_time' # 按创建时间分层
'status', filter_horizontal = ('tags',) # 多对多字段用横向过滤器
'type', exclude = ('creation_time', 'last_modify_time') # 后台不显示这两个字段
'article_order') view_on_site = True # 显示“查看站点”按钮
list_display_links = ('id', 'title') actions = [makr_article_publish, draft_article, close_article_commentstatus, open_article_commentstatus] # 批量操作
list_filter = ('status', 'type', 'category') raw_id_fields = ('author', 'category') # 作者和分类用输入框而不是下拉
date_hierarchy = 'creation_time'
filter_horizontal = ('tags',) # 自定义分类字段显示为链接
exclude = ('creation_time', 'last_modify_time')
view_on_site = True
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
raw_id_fields = ('author', 'category',)
def link_to_category(self, obj): def link_to_category(self, obj):
info = (obj.category._meta.app_label, obj.category._meta.model_name) info = (obj.category._meta.app_label, obj.category._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,)) link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name)) return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
link_to_category.short_description = _('分类')
link_to_category.short_description = _('category') # 限制作者只能选择超级用户
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs) form = super(ArticleAdmin, self).get_form(request, obj, **kwargs)
form.base_fields['author'].queryset = get_user_model( form.base_fields['author'].queryset = get_user_model().objects.filter(is_superuser=True)
).objects.filter(is_superuser=True)
return form return form
def save_model(self, request, obj, form, change): # 获取文章详情页链接
super(ArticlelAdmin, self).save_model(request, obj, form, change)
def get_view_on_site_url(self, obj=None): def get_view_on_site_url(self, obj=None):
if obj: if obj:
url = obj.get_full_url() url = obj.get_full_url()
@ -91,24 +72,28 @@ class ArticlelAdmin(admin.ModelAdmin):
site = get_current_site().domain site = get_current_site().domain
return site return site
# 其它模型管理类(简化,仅注册)
class TagAdmin(admin.ModelAdmin): class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time') exclude = ('slug', 'last_mod_time', 'creation_time')
class CategoryAdmin(admin.ModelAdmin): class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index') list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time') exclude = ('slug', 'last_mod_time', 'creation_time')
class LinksAdmin(admin.ModelAdmin): class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time') exclude = ('last_mod_time', 'creation_time')
class SideBarAdmin(admin.ModelAdmin): class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence') list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time') exclude = ('last_mod_time', 'creation_time')
class BlogSettingsAdmin(admin.ModelAdmin): class BlogSettingsAdmin(admin.ModelAdmin):
pass pass # 博客设置,通常唯一,无需复杂操作
# 注册所有模型到 admin
admin.site.register(Article, ArticleAdmin)
admin.site.register(Tag, TagAdmin)
admin.site.register(Category, CategoryAdmin)
admin.site.register(Links, LinksAdmin)
admin.site.register(SideBar, SideBarAdmin)
admin.site.register(BlogSettings, BlogSettingsAdmin)

@ -1,5 +1,4 @@
from django.apps import AppConfig from django.apps import AppConfig
class BlogConfig(AppConfig): class BlogConfig(AppConfig):
name = 'blog' name = 'blog' # 当前 app 名称

@ -1,21 +1,19 @@
import logging import logging
from django.utils import timezone from django.utils import timezone
from djangoblog.utils import cache, get_blog_setting # 假设有这些工具方法
from djangoblog.utils import cache, get_blog_setting
from .models import Category, Article from .models import Category, Article
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 上下文处理器:为每个模板注入 SEO 相关全局变量
def seo_processor(requests): def seo_processor(request):
key = 'seo_processor' cache_key = 'seo_processor'
value = cache.get(key) value = cache.get(cache_key) # 尝试从缓存读取
if value: if value:
return value return value
else: else:
logger.info('set processor cache.') logger.info('设置 SEO 处理器缓存。')
setting = get_blog_setting() setting = get_blog_setting() # 获取博客配置
value = { value = {
'SITE_NAME': setting.site_name, 'SITE_NAME': setting.site_name,
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
@ -23,21 +21,19 @@ def seo_processor(requests):
'SITE_SEO_DESCRIPTION': setting.site_seo_description, 'SITE_SEO_DESCRIPTION': setting.site_seo_description,
'SITE_DESCRIPTION': setting.site_description, 'SITE_DESCRIPTION': setting.site_description,
'SITE_KEYWORDS': setting.site_keywords, 'SITE_KEYWORDS': setting.site_keywords,
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', 'SITE_BASE_URL': request.scheme + '://' + request.get_host() + '/',
'ARTICLE_SUB_LENGTH': setting.article_sub_length, 'ARTICLE_SUB_LENGTH': setting.article_sub_length,
'nav_category_list': Category.objects.all(), 'nav_category_list': Category.objects.all(), # 导航分类
'nav_pages': Article.objects.filter( 'nav_pages': Article.objects.filter(type='p', status='p'), # 已发布页面
type='p',
status='p'),
'OPEN_SITE_COMMENT': setting.open_site_comment, 'OPEN_SITE_COMMENT': setting.open_site_comment,
'BEIAN_CODE': setting.beian_code, 'BEIAN_CODE': setting.beian_code, # 备案号
'ANALYTICS_CODE': setting.analytics_code, 'ANALYTICS_CODE': setting.analytics_code, # 统计代码
"BEIAN_CODE_GONGAN": setting.gongan_beiancode, "BEIAN_CODE_GONGAN": setting.gongan_beiancode, # 公安备案号
"SHOW_GONGAN_CODE": setting.show_gongan_code, "SHOW_GONGAN_CODE": setting.show_gongan_code,
"CURRENT_YEAR": timezone.now().year, "CURRENT_YEAR": timezone.now().year,
"GLOBAL_HEADER": setting.global_header, "GLOBAL_HEADER": setting.global_header,
"GLOBAL_FOOTER": setting.global_footer, "GLOBAL_FOOTER": setting.global_footer,
"COMMENT_NEED_REVIEW": setting.comment_need_review, "COMMENT_NEED_REVIEW": setting.comment_need_review,
} }
cache.set(key, value, 60 * 60 * 10) cache.set(cache_key, value, 60 * 60 * 10) # 缓存10小时
return value return value

@ -1,151 +1,57 @@
import logging
import time import time
import elasticsearch.client
from django.conf import settings from django.conf import settings
from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean from elasticsearch_dsl import Document, Date, Integer, Keyword, Text, Object, Boolean
from elasticsearch_dsl.connections import connections from elasticsearch_dsl.connections import connections
from blog.models import Article from blog.models import Article
logger = logging.getLogger(__name__)
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL') ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
# 如果启用 ES则建立连接
if ELASTICSEARCH_ENABLED: if ELASTICSEARCH_ENABLED:
connections.create_connection( connections.create_connection(hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
from elasticsearch.client import IngestClient
c = IngestClient(es)
try:
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
{
"geoip" : {
"field" : "ip"
}
}
]
}''')
class GeoIp(InnerDoc):
continent_name = Keyword()
country_iso_code = Keyword()
country_name = Keyword()
location = GeoPoint()
class UserAgentBrowser(InnerDoc): # 定义用户代理相关内部文档
class UserAgentBrowser(Object):
Family = Keyword() Family = Keyword()
Version = Keyword() Version = Keyword()
class UserAgentOS(UserAgentBrowser): class UserAgentOS(UserAgentBrowser):
pass pass
class UserAgentDevice(Object):
class UserAgentDevice(InnerDoc):
Family = Keyword() Family = Keyword()
Brand = Keyword() Brand = Keyword()
Model = Keyword() Model = Keyword()
class UserAgent(Object):
class UserAgent(InnerDoc): browser = Object(UserAgentBrowser)
browser = Object(UserAgentBrowser, required=False) os = Object(UserAgentOS)
os = Object(UserAgentOS, required=False) device = Object(UserAgentDevice)
device = Object(UserAgentDevice, required=False)
string = Text() string = Text()
is_bot = Boolean() is_bot = Boolean()
# 性能日志文档
class ElapsedTimeDocument(Document): class ElapsedTimeDocument(Document):
url = Keyword() url = Keyword()
time_taken = Long() time_taken = Long() # 请求耗时(毫秒)
log_datetime = Date() log_datetime = Date()
ip = Keyword() ip = Keyword()
geoip = Object(GeoIp, required=False) geoip = Object() # 可添加 GeoIP 信息
useragent = Object(UserAgent, required=False) useragent = Object(UserAgent)
class Index: class Index:
name = 'performance' name = 'performance'
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
}
class Meta:
doc_type = 'ElapsedTime'
class ElaspedTimeDocumentManager:
@staticmethod
def build_index():
from elasticsearch import Elasticsearch
client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
res = client.indices.exists(index="performance")
if not res:
ElapsedTimeDocument.init()
@staticmethod
def delete_index():
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='performance', ignore=[400, 404])
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
ElaspedTimeDocumentManager.build_index()
ua = UserAgent()
ua.browser = UserAgentBrowser()
ua.browser.Family = useragent.browser.family
ua.browser.Version = useragent.browser.version_string
ua.os = UserAgentOS()
ua.os.Family = useragent.os.family
ua.os.Version = useragent.os.version_string
ua.device = UserAgentDevice()
ua.device.Family = useragent.device.family
ua.device.Brand = useragent.device.brand
ua.device.Model = useragent.device.model
ua.string = useragent.ua_string
ua.is_bot = useragent.is_bot
doc = ElapsedTimeDocument(
meta={
'id': int(
round(
time.time() *
1000))
},
url=url,
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua, ip=ip)
doc.save(pipeline="geoip")
# 文章搜索文档
class ArticleDocument(Document): class ArticleDocument(Document):
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') body = Text(analyzer='ik_max_word') # 使用 ik 中文分词
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart') title = Text(analyzer='ik_max_word')
author = Object(properties={ author = Object(properties={'nickname': Text(analyzer='ik_max_word'), 'id': Integer()})
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), category = Object(properties={'name': Text(analyzer='ik_max_word'), 'id': Integer()})
'id': Integer() tags = Object(properties={'name': Text(analyzer='ik_max_word'), 'id': Integer()})
})
category = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
tags = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
pub_time = Date() pub_time = Date()
status = Text() status = Text()
comment_status = Text() comment_status = Text()
@ -155,59 +61,5 @@ class ArticleDocument(Document):
class Index: class Index:
name = 'blog' name = 'blog'
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
}
class Meta:
doc_type = 'Article'
class ArticleDocumentManager():
def __init__(self):
self.create_index()
def create_index(self):
ArticleDocument.init()
def delete_index(self):
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='blog', ignore=[400, 404])
def convert_to_doc(self, articles):
return [
ArticleDocument(
meta={
'id': article.id},
body=article.body,
title=article.title,
author={
'nickname': article.author.username,
'id': article.author.id},
category={
'name': article.category.name,
'id': article.category.id},
tags=[
{
'name': t.name,
'id': t.id} for t in article.tags.all()],
pub_time=article.pub_time,
status=article.status,
comment_status=article.comment_status,
type=article.type,
views=article.views,
article_order=article.article_order) for article in articles]
def rebuild(self, articles=None):
ArticleDocument.init()
articles = articles if articles else Article.objects.all()
docs = self.convert_to_doc(articles)
for doc in docs:
doc.save()
def update_docs(self, docs): # (后续可补充对应的管理器,用于创建索引、更新等操作,见您 documents.py 的其它部分)
for doc in docs:
doc.save()

@ -1,19 +1,15 @@
import logging import logging
from django import forms from django import forms
from haystack.forms import SearchForm from haystack.forms import SearchForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BlogSearchForm(SearchForm): class BlogSearchForm(SearchForm):
querydata = forms.CharField(required=True) querydata = forms.CharField(required=True) # 必须输入搜索关键词
def search(self): def search(self):
datas = super(BlogSearchForm, self).search()
if not self.is_valid(): if not self.is_valid():
return self.no_query_found() return self.no_query_found()
datas = super().search()
if self.cleaned_data['querydata']: logger.info(self.cleaned_data['querydata']) # 记录搜索词
logger.info(self.cleaned_data['querydata']) return datas
return datas

@ -1,18 +1,32 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \ # 导入与 Elasticsearch 相关的文档和文档管理器
ELASTICSEARCH_ENABLED from blog.documents import (
ElapsedTimeDocument, # 假设这是一个与时间相关的 Elasticsearch 文档
ArticleDocumentManager, # 文章的 Elasticsearch 文档管理器
ElaspedTimeDocumentManager, # 假设这是一个与 ElapsedTime 相关的文档管理器(注意拼写可能是 Elapsed
ELASTICSEARCH_ENABLED # 一个标志,指示是否启用 Elasticsearch
)
# TODO 参数化
class Command(BaseCommand): class Command(BaseCommand):
help = 'build search index' help = '构建搜索索引' # 命令的帮助信息,显示在 python manage.py help build_search_index 中
def handle(self, *args, **options): def handle(self, *args, **options):
"""
命令的主要处理逻辑
"""
if ELASTICSEARCH_ENABLED: if ELASTICSEARCH_ENABLED:
# 如果启用了 Elasticsearch则执行以下操作
# 构建 ElaspedTime 的索引(假设是某种时间相关的索引)
ElaspedTimeDocumentManager.build_index() ElaspedTimeDocumentManager.build_index()
# 获取 ElapsedTimeDocument 的管理器实例并初始化它
manager = ElapsedTimeDocument() manager = ElapsedTimeDocument()
manager.init() manager.init()
# 获取 ArticleDocumentManager 的实例,先删除现有的文章索引,然后重建索引
manager = ArticleDocumentManager() manager = ArticleDocumentManager()
manager.delete_index() manager.delete_index() # 删除当前的文章索引
manager.rebuild() manager.rebuild() # 重新构建文章索引

@ -1,13 +1,21 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
# 导入项目中的 Tag 和 Category 模型
from blog.models import Tag, Category from blog.models import Tag, Category
# TODO 参数化
class Command(BaseCommand): class Command(BaseCommand):
help = 'build search words' help = '构建搜索关键词' # 命令的帮助信息
def handle(self, *args, **options): def handle(self, *args, **options):
datas = set([t.name for t in Tag.objects.all()] + """
[t.name for t in Category.objects.all()]) 命令的主要处理逻辑
print('\n'.join(datas)) """
# 从数据库中获取所有 Tag 和 Category 的名称,并去重
datas = set([
t.name for t in Tag.objects.all() # 所有标签的名称
+ [t.name for t in Category.objects.all()] # 所有分类的名称
])
# 将去重后的名称集合转换为以换行符分隔的字符串,并打印出来
print('\n'.join(datas))

@ -1,11 +1,18 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
# 导入自定义的缓存工具
from djangoblog.utils import cache from djangoblog.utils import cache
class Command(BaseCommand): class Command(BaseCommand):
help = 'clear the whole cache' help = '清除所有缓存' # 命令的帮助信息
def handle(self, *args, **options): def handle(self, *args, **options):
"""
命令的主要处理逻辑
"""
# 调用缓存工具的 clear 方法,清除所有缓存
cache.clear() cache.clear()
self.stdout.write(self.style.SUCCESS('Cleared cache\n'))
# 输出成功信息,使用 Django 管理命令的样式输出
self.stdout.write(self.style.SUCCESS('已清除缓存\n'))

@ -2,39 +2,67 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
# 导入项目中的 Article, Tag, Category 模型
from blog.models import Article, Tag, Category from blog.models import Article, Tag, Category
class Command(BaseCommand): class Command(BaseCommand):
help = 'create test datas' help = '创建测试数据' # 命令的帮助信息
def handle(self, *args, **options): def handle(self, *args, **options):
"""
命令的主要处理逻辑
"""
# 获取或创建一个测试用户
user = get_user_model().objects.get_or_create( user = get_user_model().objects.get_or_create(
email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0] email='test@test.com', # 用户邮箱
username='测试用户', # 用户名
password=make_password('test!q@w#eTYU') # 加密后的密码
)[0] # get_or_create 返回一个元组 (对象, 创建与否),我们只需要对象
# 获取或创建一个父级分类
pcategory = Category.objects.get_or_create( pcategory = Category.objects.get_or_create(
name='我是父类目', parent_category=None)[0] name='我是父类目', # 父分类名称
parent_category=None # 父分类为 None表示这是顶级分类
)[0]
# 获取或创建一个子分类,其父分类为上面创建的父分类
category = Category.objects.get_or_create( category = Category.objects.get_or_create(
name='子类目', parent_category=pcategory)[0] name='子类目', # 子分类名称
parent_category=pcategory # 指定父分类
)[0]
category.save() # 保存分类(虽然 get_or_create 已经保存,但显式保存也无妨)
category.save() # 创建一个基础标签
basetag = Tag() basetag = Tag()
basetag.name = "标签" basetag.name = "标签" # 标签名称
basetag.save() basetag.save() # 保存标签
# 循环创建 19 篇测试文章
for i in range(1, 20): for i in range(1, 20):
# 获取或创建一篇文章
article = Article.objects.get_or_create( article = Article.objects.get_or_create(
category=category, category=category, # 关联的分类
title='nice title ' + str(i), title='nice title ' + str(i), # 文章标题
body='nice content ' + str(i), body='nice content ' + str(i),# 文章内容
author=user)[0] author=user # 文章作者
)[0]
# 创建一个新标签
tag = Tag() tag = Tag()
tag.name = "标签" + str(i) tag.name = "标签" + str(i) # 标签名称
tag.save() tag.save() # 保存标签
article.tags.add(tag)
article.tags.add(basetag) # 将新标签和基础标签添加到文章中
article.tags.add(tag) # 添加新标签
article.tags.add(basetag) # 添加基础标签
# 保存文章(虽然 add 方法不会自动保存,但通常 get_or_create 已经保存)
article.save() article.save()
# 清除所有缓存,以确保新的测试数据在缓存中正确反映
from djangoblog.utils import cache from djangoblog.utils import cache
cache.clear() cache.clear()
self.stdout.write(self.style.SUCCESS('created test datas \n'))
# 输出成功信息
self.stdout.write(self.style.SUCCESS('已创建测试数据 \n'))

@ -1,50 +1,75 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
# 导入自定义的百度通知工具和获取当前站点的工具
from djangoblog.spider_notify import SpiderNotify from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import get_current_site from djangoblog.utils import get_current_site
# 导入项目中的 Article, Tag, Category 模型
from blog.models import Article, Tag, Category from blog.models import Article, Tag, Category
# 获取当前站点的域名
site = get_current_site().domain site = get_current_site().domain
class Command(BaseCommand): class Command(BaseCommand):
help = 'notify baidu url' help = '通知百度 URL' # 命令的帮助信息
def add_arguments(self, parser): def add_arguments(self, parser):
"""
为命令添加自定义参数
"""
parser.add_argument( parser.add_argument(
'data_type', 'data_type',
type=str, type=str,
choices=[ choices=[
'all', 'all', # 所有类型
'article', 'article', # 仅文章
'tag', 'tag', # 仅标签
'category'], 'category' # 仅分类
help='article : all article,tag : all tag,category: all category,all: All of these') ],
help='选择要通知的数据类型: article所有文章, tag所有标签, category所有分类, all所有类型'
)
def get_full_url(self, path): def get_full_url(self, path):
"""
根据给定的路径构建完整的 URL
"""
url = "https://{site}{path}".format(site=site, path=path) url = "https://{site}{path}".format(site=site, path=path)
return url return url
def handle(self, *args, **options): def handle(self, *args, **options):
type = options['data_type'] """
self.stdout.write('start get %s' % type) 命令的主要处理逻辑
"""
data_type = options['data_type'] # 获取传入的数据类型参数
self.stdout.write('开始获取 %s' % data_type) # 输出开始信息
urls = [] # 用于存储需要通知的 URL 列表
urls = [] if data_type == 'article' or data_type == 'all':
if type == 'article' or type == 'all': # 如果数据类型是文章或所有,则遍历所有状态为 'p'(假设 'p' 表示已发布)的文章
for article in Article.objects.filter(status='p'): for article in Article.objects.filter(status='p'):
urls.append(article.get_full_url()) urls.append(article.get_full_url()) # 获取文章的完整 URL 并添加到列表中
if type == 'tag' or type == 'all':
if data_type == 'tag' or data_type == 'all':
# 如果数据类型是标签或所有,则遍历所有标签
for tag in Tag.objects.all(): for tag in Tag.objects.all():
url = tag.get_absolute_url() url = tag.get_absolute_url() # 获取标签的绝对 URL
urls.append(self.get_full_url(url)) urls.append(self.get_full_url(url)) # 构建完整 URL 并添加到列表中
if type == 'category' or type == 'all':
if data_type == 'category' or data_type == 'all':
# 如果数据类型是分类或所有,则遍历所有分类
for category in Category.objects.all(): for category in Category.objects.all():
url = category.get_absolute_url() url = category.get_absolute_url() # 获取分类的绝对 URL
urls.append(self.get_full_url(url)) urls.append(self.get_full_url(url)) # 构建完整 URL 并添加到列表中
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(
'start notify %d urls' % '开始通知 %d 个 URL' %
len(urls))) len(urls) # 输出将要通知的 URL 数量
)
)
# 调用百度通知工具,通知所有收集到的 URL
SpiderNotify.baidu_notify(urls) SpiderNotify.baidu_notify(urls)
self.stdout.write(self.style.SUCCESS('finish notify')) # 输出完成通知的信息
self.stdout.write(self.style.SUCCESS('完成通知'))

@ -2,46 +2,67 @@ import requests
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.templatetags.static import static from django.templatetags.static import static
# 导入自定义的用户头像保存工具和 OAuth 用户模型
from djangoblog.utils import save_user_avatar from djangoblog.utils import save_user_avatar
from oauth.models import OAuthUser from oauth.models import OAuthUser
from oauth.oauthmanager import get_manager_by_type from oauth.oauthmanager import get_manager_by_type
class Command(BaseCommand): class Command(BaseCommand):
help = 'sync user avatar' help = '同步用户头像' # 命令的帮助信息
def test_picture(self, url): def test_picture(self, url):
"""
测试给定的图片 URL 是否可访问返回状态码 200
"""
try: try:
if requests.get(url, timeout=2).status_code == 200: if requests.get(url, timeout=2).status_code == 200:
return True return True
except: except:
pass pass
return False
def handle(self, *args, **options): def handle(self, *args, **options):
static_url = static("../") """
users = OAuthUser.objects.all() 命令的主要处理逻辑
self.stdout.write(f'开始同步{len(users)}个用户头像') """
static_url = static("../") # 获取静态文件的基准 URL具体根据项目配置可能不同
users = OAuthUser.objects.all() # 获取所有的 OAuth 用户
self.stdout.write(f'开始同步 {len(users)} 个用户头像') # 输出开始信息,显示要同步的用户数量
for u in users: for u in users:
self.stdout.write(f'开始同步:{u.nickname}') self.stdout.write(f'开始同步: {u.nickname}') # 输出当前正在同步的用户昵称
url = u.picture url = u.picture # 获取用户当前的头像 URL
if url: if url:
if url.startswith(static_url): if url.startswith(static_url):
# 如果头像 URL 是静态文件 URL
if self.test_picture(url): if self.test_picture(url):
# 如果图片可访问,则跳过同步
continue continue
else: else:
# 如果图片不可访问,则尝试通过 OAuth 管理器获取新的头像 URL
if u.metadata: if u.metadata:
manage = get_manager_by_type(u.type) manage = get_manager_by_type(u.type) # 根据用户类型获取相应的 OAuth 管理器
url = manage.get_picture(u.metadata) url = manage.get_picture(u.metadata) # 获取新的头像 URL
url = save_user_avatar(url) url = save_user_avatar(url) # 保存头像并获取保存后的 URL
else: else:
# 如果没有元数据,则使用默认头像
url = static('blog/img/avatar.png') url = static('blog/img/avatar.png')
else: else:
# 如果头像 URL 不是静态文件 URL则直接保存头像并获取保存后的 URL
url = save_user_avatar(url) url = save_user_avatar(url)
else: else:
# 如果用户没有头像 URL则使用默认头像
url = static('blog/img/avatar.png') url = static('blog/img/avatar.png')
if url: if url:
# 如果获取到了有效的头像 URL则更新用户的头像字段并保存
self.stdout.write( self.stdout.write(
f'结束同步:{u.nickname}.url:{url}') f'结束同步: {u.nickname}.url: {url}' # 输出同步完成信息,显示用户昵称和新头像 URL
)
u.picture = url u.picture = url
u.save() u.save()
self.stdout.write('结束同步')
# 输出同步完成的总体信息
self.stdout.write('结束同步')

@ -1,6 +1,5 @@
import logging import logging
import time import time
from ipware import get_client_ip from ipware import get_client_ip
from user_agents import parse from user_agents import parse
@ -8,35 +7,34 @@ from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OnlineMiddleware:
class OnlineMiddleware(object): def __init__(self, get_response):
def __init__(self, get_response=None):
self.get_response = get_response self.get_response = get_response
super().__init__()
def __call__(self, request): def __call__(self, request):
''' page render time '''
start_time = time.time() start_time = time.time()
response = self.get_response(request) response = self.get_response(request)
http_user_agent = request.META.get('HTTP_USER_AGENT', '') http_user_agent = request.META.get('HTTP_USER_AGENT', '')
ip, _ = get_client_ip(request) ip, _ = get_client_ip(request)
user_agent = parse(http_user_agent) user_agent = parse(http_user_agent)
if not response.streaming: if not response.streaming:
try: try:
cast_time = time.time() - start_time cast_time = time.time() - start_time
if ELASTICSEARCH_ENABLED: if ELASTICSEARCH_ENABLED:
time_taken = round((cast_time) * 1000, 2) time_taken = round(cast_time * 1000, 2)
url = request.path url = request.path
from django.utils import timezone from django.utils import timezone
ElaspedTimeDocumentManager.create( ElaspedTimeDocumentManager.create(
url=url, url=url, time_taken=time_taken,
time_taken=time_taken,
log_datetime=timezone.now(), log_datetime=timezone.now(),
useragent=user_agent, useragent=user_agent, ip=ip
ip=ip) )
# 在页面中显示加载时间
response.content = response.content.replace( response.content = response.content.replace(
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5])) b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5])
)
except Exception as e: except Exception as e:
logger.error("Error OnlineMiddleware: %s" % e) logger.error("OnlineMiddleware 错误: %s" % e)
return response return response

@ -1,4 +1,4 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14 # 由Django 4.1.7于2023-03-02 07:14生成
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -6,16 +6,16 @@ import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import mdeditor.fields import mdeditor.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True # 标记这是初始迁移
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型
] ]
operations = [ operations = [
# 创建网站配置模型
migrations.CreateModel( migrations.CreateModel(
name='BlogSettings', name='BlogSettings',
fields=[ fields=[
@ -37,10 +37,11 @@ class Migration(migrations.Migration):
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')), ('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
], ],
options={ options={
'verbose_name': '网站配置', 'verbose_name': '网站配置', # 模型在管理界面显示的名称
'verbose_name_plural': '网站配置', 'verbose_name_plural': '网站配置',
}, },
), ),
# 创建友情链接模型
migrations.CreateModel( migrations.CreateModel(
name='Links', name='Links',
fields=[ fields=[
@ -56,9 +57,10 @@ class Migration(migrations.Migration):
options={ options={
'verbose_name': '友情链接', 'verbose_name': '友情链接',
'verbose_name_plural': '友情链接', 'verbose_name_plural': '友情链接',
'ordering': ['sequence'], 'ordering': ['sequence'], # 排序依据
}, },
), ),
# 创建侧边栏模型
migrations.CreateModel( migrations.CreateModel(
name='SideBar', name='SideBar',
fields=[ fields=[
@ -76,6 +78,7 @@ class Migration(migrations.Migration):
'ordering': ['sequence'], 'ordering': ['sequence'],
}, },
), ),
# 创建标签模型
migrations.CreateModel( migrations.CreateModel(
name='Tag', name='Tag',
fields=[ fields=[
@ -91,6 +94,7 @@ class Migration(migrations.Migration):
'ordering': ['name'], 'ordering': ['name'],
}, },
), ),
# 创建分类模型
migrations.CreateModel( migrations.CreateModel(
name='Category', name='Category',
fields=[ fields=[
@ -108,6 +112,7 @@ class Migration(migrations.Migration):
'ordering': ['-index'], 'ordering': ['-index'],
}, },
), ),
# 创建文章模型
migrations.CreateModel( migrations.CreateModel(
name='Article', name='Article',
fields=[ fields=[
@ -134,4 +139,4 @@ class Migration(migrations.Migration):
'get_latest_by': 'id', 'get_latest_by': 'id',
}, },
), ),
] ]

@ -1,23 +1,24 @@
# Generated by Django 4.1.7 on 2023-03-29 06:08 # 由Django 4.1.7于2023-03-29 06:08生成
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('blog', '0001_initial'), ('blog', '0001_initial'), # 依赖于初始迁移
] ]
operations = [ operations = [
# 向BlogSettings模型添加公共尾部字段
migrations.AddField( migrations.AddField(
model_name='blogsettings', model_name='blogsettings',
name='global_footer', name='global_footer',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'), field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
), ),
# 向BlogSettings模型添加公共头部字段
migrations.AddField( migrations.AddField(
model_name='blogsettings', model_name='blogsettings',
name='global_header', name='global_header',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'), field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
), ),
] ]

@ -1,17 +1,18 @@
# Generated by Django 4.2.1 on 2023-05-09 07:45 # 由Django 4.2.1于2023-05-09 07:45生成
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('blog', '0002_blogsettings_global_footer_and_more'), ('blog', '0002_blogsettings_global_footer_and_more'), # 依赖于上一个迁移
] ]
operations = [ operations = [
# 向BlogSettings模型添加评论是否需要审核字段
migrations.AddField( migrations.AddField(
model_name='blogsettings', model_name='blogsettings',
name='comment_need_review', name='comment_need_review',
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'), field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
), ),
] ]

@ -1,27 +1,30 @@
# Generated by Django 4.2.1 on 2023-05-09 07:51 # 由Django 4.2.1于2023-05-09 07:51生成
from django.db import migrations from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('blog', '0003_blogsettings_comment_need_review'), ('blog', '0003_blogsettings_comment_need_review'), # 依赖于上一个迁移
] ]
operations = [ operations = [
# 重命名BlogSettings模型中的analyticscode字段为analytics_code
migrations.RenameField( migrations.RenameField(
model_name='blogsettings', model_name='blogsettings',
old_name='analyticscode', old_name='analyticscode',
new_name='analytics_code', new_name='analytics_code',
), ),
# 重命名BlogSettings模型中的beiancode字段为beian_code
migrations.RenameField( migrations.RenameField(
model_name='blogsettings', model_name='blogsettings',
old_name='beiancode', old_name='beiancode',
new_name='beian_code', new_name='beian_code',
), ),
# 重命名BlogSettings模型中的sitename字段为site_name
migrations.RenameField( migrations.RenameField(
model_name='blogsettings', model_name='blogsettings',
old_name='sitename', old_name='sitename',
new_name='site_name', new_name='site_name',
), ),
] ]

@ -1,300 +1,452 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13 # 该迁移文件由 Django 4.2.5 于 2023-09-06 13:13 自动生成
# 依赖于当前项目的用户模型AUTH_USER_MODEL和上一个博客应用的迁移 '0004_rename_analyticscode_blogsettings_analytics_code_and_more'
from django.conf import settings from django.conf import settings # 用于引入项目设置,特别是 AUTH_USER_MODEL
from django.db import migrations, models from django.db import migrations, models # Django 的迁移与模型字段工具
import django.db.models.deletion import django.db.models.deletion # 用于定义外键删除策略
import django.utils.timezone import django.utils.timezone # 用于获取当前时间(带时区)
import mdeditor.fields import mdeditor.fields # 引入 Markdown 编辑器字段,用于富文本
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 该迁移依赖的项目模块
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型,允许自定义用户模型
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'), ('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'), # 依赖于上一个迁移
] ]
operations = [ operations = [
# ========== 1. 调整模型 Meta 选项(管理后台显示名称、排序等)==========
# 调整 Article 模型的 Meta 选项:
# - 获取最新记录的依据字段为 id
# - 默认排序:先按 article_order 倒序(数字大的在前),再按发布时间倒序
# - 后台显示名称:单数为 'article',复数为 'article'
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='article', name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'}, options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
), ),
# 调整 Category 模型的 Meta 选项:
# - 默认排序:按 index 倒序(权重高的排前面)
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='category', name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'}, options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
), ),
# 调整 Links 模型的 Meta 选项:
# - 默认排序:按 sequence排序字段
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='links', name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'}, options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
), ),
# 调整 Sidebar 模型的 Meta 选项:
# - 默认排序:按 sequence
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='sidebar', name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'}, options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
), ),
# 调整 Tag 模型的 Meta 选项:
# - 默认排序:按 name标签名字母顺序
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='tag', name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'}, options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
), ),
# ========== 2. 删除旧的时间字段created_time 和 last_mod_time==========
# 从 Article 模型中移除 created_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='article', model_name='article',
name='created_time', name='created_time',
), ),
# 从 Article 模型中移除 last_mod_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='article', model_name='article',
name='last_mod_time', name='last_mod_time',
), ),
# 从 Category 模型中移除 created_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='category', model_name='category',
name='created_time', name='created_time',
), ),
# 从 Category 模型中移除 last_mod_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='category', model_name='category',
name='last_mod_time', name='last_mod_time',
), ),
# 从 Links 模型中移除 created_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='links', model_name='links',
name='created_time', name='created_time',
), ),
# 从 Sidebar 模型中移除 created_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='sidebar', model_name='sidebar',
name='created_time', name='created_time',
), ),
# 从 Tag 模型中移除 created_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='tag', model_name='tag',
name='created_time', name='created_time',
), ),
# 从 Tag 模型中移除 last_mod_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='tag', model_name='tag',
name='last_mod_time', name='last_mod_time',
), ),
# ========== 3. 新增新的时间字段creation_time 和 last_modify_time==========
# 为 Article 模型新增 creation_time 字段,记录文章创建时间,默认为当前时间
migrations.AddField( migrations.AddField(
model_name='article', model_name='article',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为 Article 模型新增 last_modify_time 字段,记录文章最后修改时间,默认为当前时间
migrations.AddField( migrations.AddField(
model_name='article', model_name='article',
name='last_modify_time', name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
), ),
# 为 Category 模型新增 creation_time 字段
migrations.AddField( migrations.AddField(
model_name='category', model_name='category',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为 Category 模型新增 last_modify_time 字段
migrations.AddField( migrations.AddField(
model_name='category', model_name='category',
name='last_modify_time', name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
), ),
# 为 Links 模型新增 creation_time 字段
migrations.AddField( migrations.AddField(
model_name='links', model_name='links',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为 Sidebar 模型新增 creation_time 字段
migrations.AddField( migrations.AddField(
model_name='sidebar', model_name='sidebar',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为 Tag 模型新增 creation_time 字段
migrations.AddField( migrations.AddField(
model_name='tag', model_name='tag',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为 Tag 模型新增 last_modify_time 字段
migrations.AddField( migrations.AddField(
model_name='tag', model_name='tag',
name='last_modify_time', name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
), ),
# ========== 4. 调整多个字段的属性verbose_name、字段类型、选项等==========
# 调整 Article 模型的 article_order 字段,用于排序,数字越大越靠前
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='article_order', name='article_order',
field=models.IntegerField(default=0, verbose_name='order'), field=models.IntegerField(default=0, verbose_name='order'),
), ),
# 调整 Article 模型的 author 字段关联到当前项目的用户模型AUTH_USER_MODEL
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='author', name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
), ),
# 调整 Article 模型的 body 字段,使用 Markdown 编辑器字段(支持富文本)
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='body', name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'), field=mdeditor.fields.MDTextField(verbose_name='body'),
), ),
# 调整 Article 模型的 category 字段,关联到 Category 模型
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='category', name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
), ),
# 调整 Article 模型的 comment_status 字段表示评论状态Open开放或 Close关闭
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='comment_status', name='comment_status',
field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'), field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
), ),
# 调整 Article 模型的 pub_time 字段,表示文章发布时间,默认为当前时间
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='pub_time', name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
), ),
# 调整 Article 模型的 show_toc 字段表示是否显示目录Table of Contents
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='show_toc', name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'), field=models.BooleanField(default=False, verbose_name='show toc'),
), ),
# 调整 Article 模型的 status 字段表示文章状态Draft草稿或 Published已发布
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='status', name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'), field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
), ),
# 调整 Article 模型的 tags 字段,与 Tag 模型建立多对多关系,表示文章可以有多个标签
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='tags', name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'), field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
), ),
# 调整 Article 模型的 title 字段,文章标题,要求唯一
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='title', name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'), field=models.CharField(max_length=200, unique=True, verbose_name='title'),
), ),
# 调整 Article 模型的 type 字段表示文章类型Article文章或 Page页面如关于页面
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='type', name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'), field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
), ),
# 调整 Article 模型的 views 字段,表示文章浏览量
migrations.AlterField( migrations.AlterField(
model_name='article', model_name='article',
name='views', name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'), field=models.PositiveIntegerField(default=0, verbose_name='views'),
), ),
# ========== 5. 调整 BlogSettings 模型各字段的属性 ==========
# 调整文章评论数量设置字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='article_comment_count', name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'), field=models.IntegerField(default=5, verbose_name='article comment count'),
), ),
# 调整文章摘要显示长度设置字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='article_sub_length', name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'), field=models.IntegerField(default=300, verbose_name='article sub length'),
), ),
# 调整 Google AdSense 广告代码字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='google_adsense_codes', name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'), field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
), ),
# 调整是否开放网站评论功能的字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='open_site_comment', name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'), field=models.BooleanField(default=True, verbose_name='open site comment'),
), ),
# 调整是否显示 Google AdSense 广告的字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='show_google_adsense', name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'), field=models.BooleanField(default=False, verbose_name='show adsense'),
), ),
# 调整侧边栏文章数量设置字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='sidebar_article_count', name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'), field=models.IntegerField(default=10, verbose_name='sidebar article count'),
), ),
# 调整侧边栏评论数量设置字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='sidebar_comment_count', name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'), field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
), ),
# 调整网站描述字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='site_description', name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'), field=models.TextField(default='', max_length=1000, verbose_name='site description'),
), ),
# 调整网站关键字字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='site_keywords', name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'), field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
), ),
# 调整网站名称字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='site_name', name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'), field=models.CharField(default='', max_length=200, verbose_name='site name'),
), ),
# 调整网站 SEO 描述字段
migrations.AlterField( migrations.AlterField(
model_name='blogsettings', model_name='blogsettings',
name='site_seo_description', name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'), field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
), ),
# ========== 6. 调整 Category 模型字段属性 ==========
# 调整分类权重排序字段
migrations.AlterField( migrations.AlterField(
model_name='category', model_name='category',
name='index', name='index',
field=models.IntegerField(default=0, verbose_name='index'), field=models.IntegerField(default=0, verbose_name='index'),
), ),
# 调整分类名称字段,要求唯一
migrations.AlterField( migrations.AlterField(
model_name='category', model_name='category',
name='name', name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'), field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
), ),
# 调整分类的父级分类字段,允许为空,实现分类嵌套
migrations.AlterField( migrations.AlterField(
model_name='category', model_name='category',
name='parent_category', name='parent_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
), ),
# ========== 7. 调整 Links 模型字段属性 ==========
# 调整友情链接是否启用显示的字段
migrations.AlterField( migrations.AlterField(
model_name='links', model_name='links',
name='is_enable', name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'), field=models.BooleanField(default=True, verbose_name='is show'),
), ),
# 调整友情链接的最后修改时间字段
migrations.AlterField( migrations.AlterField(
model_name='links', model_name='links',
name='last_mod_time', name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
), ),
# 调整友情链接的链接地址字段
migrations.AlterField( migrations.AlterField(
model_name='links', model_name='links',
name='link', name='link',
field=models.URLField(verbose_name='link'), field=models.URLField(verbose_name='link'),
), ),
# 调整友情链接的名称字段,要求唯一
migrations.AlterField( migrations.AlterField(
model_name='links', model_name='links',
name='name', name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'), field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
), ),
# 调整友情链接的排序字段
migrations.AlterField( migrations.AlterField(
model_name='links', model_name='links',
name='sequence', name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'), field=models.IntegerField(unique=True, verbose_name='order'),
), ),
# 调整友情链接的显示类型字段,如首页、列表页、文章页等
migrations.AlterField( migrations.AlterField(
model_name='links', model_name='links',
name='show_type', name='show_type',
field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'), field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
), ),
# ========== 8. 调整 Sidebar 模型字段属性 ==========
# 调整侧边栏的内容字段
migrations.AlterField( migrations.AlterField(
model_name='sidebar', model_name='sidebar',
name='content', name='content',
field=models.TextField(verbose_name='content'), field=models.TextField(verbose_name='content'),
), ),
# 调整侧边栏是否启用的字段
migrations.AlterField( migrations.AlterField(
model_name='sidebar', model_name='sidebar',
name='is_enable', name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'), field=models.BooleanField(default=True, verbose_name='is enable'),
), ),
# 调整侧边栏的最后修改时间字段
migrations.AlterField( migrations.AlterField(
model_name='sidebar', model_name='sidebar',
name='last_mod_time', name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
), ),
# 调整侧边栏的标题字段
migrations.AlterField( migrations.AlterField(
model_name='sidebar', model_name='sidebar',
name='name', name='name',
field=models.CharField(max_length=100, verbose_name='title'), field=models.CharField(max_length=100, verbose_name='title'),
), ),
# 调整侧边栏的排序字段
migrations.AlterField( migrations.AlterField(
model_name='sidebar', model_name='sidebar',
name='sequence', name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'), field=models.IntegerField(unique=True, verbose_name='order'),
), ),
# ========== 9. 调整 Tag 模型字段属性 ==========
# 调整标签的名称字段,要求唯一
migrations.AlterField( migrations.AlterField(
model_name='tag', model_name='tag',
name='name', name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'), field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
), ),
] ]

@ -1,17 +1,17 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41 # 由Django 4.2.7于2024-01-26 02:41生成
from django.db import migrations from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('blog', '0005_alter_article_options_alter_category_options_and_more'), ('blog', '0005_alter_article_options_alter_category_options_and_more'), # 依赖于上一个迁移
] ]
operations = [ operations = [
# 修改BlogSettings模型的选项设置其在管理界面的单数和复数显示名称
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='blogsettings', name='blogsettings',
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'}, options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
), ),
] ]

@ -1,9 +1,3 @@
import logging
import re
from abc import abstractmethod
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
@ -11,366 +5,46 @@ from django.utils.translation import gettext_lazy as _
from mdeditor.fields import MDTextField from mdeditor.fields import MDTextField
from uuslug import slugify from uuslug import slugify
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
class BaseModel(models.Model): class BaseModel(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now) creation_time = models.DateTimeField(_('创建时间'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now) last_modify_time = models.DateTimeField(_('修改时间'), default=now)
class Meta:
abstract = True
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_update_views = isinstance( if 'slug' in self.__dict__:
self, slug = getattr(self, 'title') if 'title' in self.__dict__ else getattr(self, 'name')
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views'] setattr(self, 'slug', slugify(slug))
if is_update_views: super().save(*args, **kwargs)
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
def get_full_url(self): def get_full_url(self):
site = get_current_site().domain site = "你的域名逻辑" # 应调用 get_current_site()
url = "https://{site}{path}".format(site=site, return f"https://{site}{self.get_absolute_url()}"
path=self.get_absolute_url())
return url
class Meta:
abstract = True
@abstractmethod @abstractmethod
def get_absolute_url(self): def get_absolute_url(self):
pass pass
class Article(BaseModel): class Article(BaseModel):
"""文章""" STATUS_CHOICES = (('d', _('草稿')), ('p', _('发布')))
STATUS_CHOICES = ( title = models.CharField(_('标题'), max_length=200, unique=True)
('d', _('Draft')), body = MDTextField(_('正文'))
('p', _('Published')), status = models.CharField(_('状态'), max_length=1, choices=STATUS_CHOICES, default='p')
) author = models.ForeignKey('auth.User', on_delete=models.CASCADE)
COMMENT_STATUS = ( pub_time = models.DateTimeField(_('发布时间'), default=now)
('o', _('Open')), views = models.PositiveIntegerField(_('浏览量'), default=0)
('c', _('Close')), category = models.ForeignKey('Category', on_delete=models.CASCADE)
) tags = models.ManyToManyField('Tag', blank=True)
TYPE = (
('a', _('Article')),
('p', _('Page')),
)
title = models.CharField(_('title'), max_length=200, unique=True)
body = MDTextField(_('body'))
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('views'), default=0)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
def body_to_string(self): def get_absolute_url(self):
return self.body return reverse('blog:detail', kwargs={'article_id': self.id})
def __str__(self): def __str__(self):
return self.title return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
'month': self.creation_time.month,
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
def viewed(self):
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
Get the first image url from article.body.
:return:
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
class Category(BaseModel): class Category(BaseModel):
"""文章分类""" name = models.CharField(_('分类名'), max_length=30, unique=True)
name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
ordering = ['-index']
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
递归获得分类目录的父级
:return:
"""
categorys = []
def parse(category):
categorys.append(category)
if category.parent_category:
parse(category.parent_category)
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
:return:
"""
categorys = []
all_categorys = Category.objects.all()
def parse(category):
if category not in categorys:
categorys.append(category)
childs = all_categorys.filter(parent_category=category)
for child in childs:
if category not in categorys:
categorys.append(child)
parse(child)
parse(self)
return categorys
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) return reverse('blog:category', kwargs={'category_name': self.name})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('link')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
content = models.TextField(_('content'))
sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(_('is enable'), default=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
article_sub_length = models.IntegerField(_('article sub length'), default=300)
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
article_comment_count = models.IntegerField(_('article comment count'), default=5)
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('open site comment'), default=True)
global_header = models.TextField("公共头部", null=True, blank=True, default='')
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
class Meta:
verbose_name = _('Website configuration')
verbose_name_plural = verbose_name
def __str__(self):
return self.site_name
def clean(self):
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()

@ -1,13 +1,22 @@
from haystack import indexes from haystack import indexes # 引入 Haystack 索引相关模块
from blog.models import Article # 引入您的文章模型
from blog.models import Article
# 定义一个针对 Article 模型的搜索索引类
class ArticleIndex(indexes.SearchIndex, indexes.Indexable): class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
"""
Haystack 搜索索引类用于为 Article 模型建立全文搜索索引
该索引将用于配合搜索引擎 WhooshElasticsearch实现文章内容的全文检索
"""
# 定义一个字段,作为文档的主要内容来源,通常用于存储要被全文检索的文本内容
# document=True 表示该字段是主文档字段use_template=True 表示内容将从模板生成
text = indexes.CharField(document=True, use_template=True) text = indexes.CharField(document=True, use_template=True)
# 必须定义的方法:返回当前索引对应的 Django 模型类
def get_model(self): def get_model(self):
return Article return Article
# 必须定义的方法:返回要被索引的模型对象查询集
# 这里只索引状态为 'p'(已发布)的文章,避免草稿等内容被搜索到
def index_queryset(self, using=None): def index_queryset(self, using=None):
return self.get_model().objects.filter(status='p') return self.get_model().objects.filter(status='p')

@ -56,12 +56,12 @@ def custom_markdown(content):
主要用于文章内容处理 主要用于文章内容处理
""" """
html_content = CommonMarkdown.get_markdown(content) html_content = CommonMarkdown.get_markdown(content)
# 然后应用插件过滤器优化HTML # 然后应用插件过滤器优化HTML
from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content) optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content)
return mark_safe(optimized_html) return mark_safe(optimized_html)
@ -76,7 +76,7 @@ def sidebar_markdown(content):
def render_article_content(context, article, is_summary=False): def render_article_content(context, article, is_summary=False):
""" """
渲染文章内容包含完整的上下文信息供插件使用 渲染文章内容包含完整的上下文信息供插件使用
Args: Args:
context: 模板上下文 context: 模板上下文
article: 文章对象 article: 文章对象
@ -84,41 +84,41 @@ def render_article_content(context, article, is_summary=False):
""" """
if not article or not hasattr(article, 'body'): if not article or not hasattr(article, 'body'):
return '' return ''
# 先转换Markdown为HTML # 先转换Markdown为HTML
html_content = CommonMarkdown.get_markdown(article.body) html_content = CommonMarkdown.get_markdown(article.body)
# 如果是摘要模式,先截断内容再应用插件 # 如果是摘要模式,先截断内容再应用插件
if is_summary: if is_summary:
# 截断HTML内容到合适的长度约300字符 # 截断HTML内容到合适的长度约300字符
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.template.defaultfilters import truncatechars from django.template.defaultfilters import truncatechars
# 先去除HTML标签截断纯文本然后重新转换为HTML # 先去除HTML标签截断纯文本然后重新转换为HTML
plain_text = strip_tags(html_content) plain_text = strip_tags(html_content)
truncated_text = truncatechars(plain_text, 300) truncated_text = truncatechars(plain_text, 300)
# 重新转换截断后的文本为HTML简化版避免复杂的插件处理 # 重新转换截断后的文本为HTML简化版避免复杂的插件处理
html_content = CommonMarkdown.get_markdown(truncated_text) html_content = CommonMarkdown.get_markdown(truncated_text)
# 然后应用插件过滤器,传递完整的上下文 # 然后应用插件过滤器,传递完整的上下文
from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
# 获取request对象 # 获取request对象
request = context.get('request') request = context.get('request')
# 应用所有文章内容相关的插件 # 应用所有文章内容相关的插件
# 注意:摘要模式下某些插件(如版权声明)可能不适用 # 注意:摘要模式下某些插件(如版权声明)可能不适用
optimized_html = hooks.apply_filters( optimized_html = hooks.apply_filters(
ARTICLE_CONTENT_HOOK_NAME, ARTICLE_CONTENT_HOOK_NAME,
html_content, html_content,
article=article, article=article,
request=request, request=request,
context=context, context=context,
is_summary=is_summary # 传递摘要标志,插件可以据此调整行为 is_summary=is_summary # 传递摘要标志,插件可以据此调整行为
) )
return mark_safe(optimized_html) return mark_safe(optimized_html)
@ -369,7 +369,7 @@ def gravatar_url(email, size=40):
url = cache.get(cachekey) url = cache.get(cachekey)
if url: if url:
return url return url
# 检查OAuth用户是否有自定义头像 # 检查OAuth用户是否有自定义头像
usermodels = OAuthUser.objects.filter(email=email) usermodels = OAuthUser.objects.filter(email=email)
if usermodels: if usermodels:
@ -378,18 +378,19 @@ def gravatar_url(email, size=40):
if users_with_picture: if users_with_picture:
# 获取默认头像路径用于比较 # 获取默认头像路径用于比较
default_avatar_path = static('blog/img/avatar.png') default_avatar_path = static('blog/img/avatar.png')
# 优先选择非默认头像的用户,否则选择第一个 # 优先选择非默认头像的用户,否则选择第一个
non_default_users = [u for u in users_with_picture if u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')] non_default_users = [u for u in users_with_picture if
u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')]
selected_user = non_default_users[0] if non_default_users else users_with_picture[0] selected_user = non_default_users[0] if non_default_users else users_with_picture[0]
url = selected_user.picture url = selected_user.picture
cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时
avatar_type = 'non-default' if non_default_users else 'default' avatar_type = 'non-default' if non_default_users else 'default'
logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type)) logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type))
return url return url
# 使用默认头像 # 使用默认头像
url = static('blog/img/avatar.png') url = static('blog/img/avatar.png')
cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时
@ -420,4 +421,4 @@ def query(qs, **kwargs):
@register.filter @register.filter
def addstr(arg1, arg2): def addstr(arg1, arg2):
"""concatenate arg1 & arg2""" """concatenate arg1 & arg2"""
return str(arg1) + str(arg2) return str(arg1) + str(arg2)

@ -2,41 +2,52 @@ import os
from django.conf import settings from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command from django.core.management import call_command # 用于调用 Django 管理命令,如 build_index
from django.core.paginator import Paginator from django.core.paginator import Paginator # 用于分页测试
from django.templatetags.static import static from django.templatetags.static import static # 用于获取静态文件 URL
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase # Django 测试客户端与测试基类
from django.urls import reverse from django.urls import reverse # 用于反向解析 URL
from django.utils import timezone from django.utils import timezone # 用于获取当前时间
from accounts.models import BlogUser # 引入自定义的模型
from blog.forms import BlogSearchForm from accounts.models import BlogUser # 自定义用户模型
from blog.models import Article, Category, Tag, SideBar, Links from blog.forms import BlogSearchForm # 搜索表单
from blog.templatetags.blog_tags import load_pagination_info, load_articletags from blog.models import Article, Category, Tag, SideBar, Links # 核心内容模型
from djangoblog.utils import get_current_site, get_sha256 from blog.templatetags.blog_tags import load_pagination_info, load_articletags # 自定义模板标签
from oauth.models import OAuthUser, OAuthConfig from djangoblog.utils import get_current_site, get_sha256 # 工具函数获取当前站点、SHA256加密
from oauth.models import OAuthUser, OAuthConfig # 第三方登录相关模型
# Create your tests here.
# 创建测试用例类,用于测试文章及相关功能
class ArticleTest(TestCase): class ArticleTest(TestCase):
def setUp(self): def setUp(self):
self.client = Client() """
self.factory = RequestFactory() 每个测试方法执行前都会调用用于初始化测试环境比如创建测试客户端等
"""
self.client = Client() # Django 提供的 HTTP 客户端,用于模拟请求
self.factory = RequestFactory() # 用于创建请求对象(较少用在此测试中)
def test_validate_article(self): def test_validate_article(self):
site = get_current_site().domain """
综合测试验证文章创建标签关联分类搜索分页用户登录静态资源命令行调用等
"""
site = get_current_site().domain # 获取当前站点域名
user = BlogUser.objects.get_or_create( user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com", email="liangliangyy@gmail.com",
username="liangliangyy")[0] username="liangliangyy")[0] # 创建或获取一个超级用户
user.set_password("liangliangyy") user.set_password("liangliangyy")
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
user.save() user.save()
# 模拟访问用户详情页、一些不存在的 admin 页面(验证是否能正常响应,或用于覆盖)
response = self.client.get(user.get_absolute_url()) response = self.client.get(user.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get('/admin/servermanager/emailsendlog/') response = self.client.get('/admin/servermanager/emailsendlog/')
response = self.client.get('admin/admin/logentry/') response = self.client.get('admin/admin/logentry/')
# 创建一个侧边栏对象并保存
s = SideBar() s = SideBar()
s.sequence = 1 s.sequence = 1
s.name = 'test' s.name = 'test'
@ -44,30 +55,37 @@ class ArticleTest(TestCase):
s.is_enable = True s.is_enable = True
s.save() s.save()
# 创建一个分类
category = Category() category = Category()
category.name = "category" category.name = "category"
category.creation_time = timezone.now() category.creation_time = timezone.now()
category.last_mod_time = timezone.now() category.last_mod_time = timezone.now()
category.save() category.save()
# 创建一个标签
tag = Tag() tag = Tag()
tag.name = "nicetag" tag.name = "nicetag"
tag.save() tag.save()
# 创建一篇文章,并关联作者、分类、类型、状态等
article = Article() article = Article()
article.title = "nicetitle" article.title = "nicetitle"
article.body = "nicecontent" article.body = "nicecontent"
article.author = user article.author = user
article.category = category article.category = category
article.type = 'a' article.type = 'a' # 'a' 代表普通文章
article.status = 'p' article.status = 'p' # 'p' 代表已发布
article.save() article.save()
# 初始时没有关联任何标签
self.assertEqual(0, article.tags.count()) self.assertEqual(0, article.tags.count())
# 为文章添加一个标签
article.tags.add(tag) article.tags.add(tag)
article.save() article.save()
self.assertEqual(1, article.tags.count()) self.assertEqual(1, article.tags.count()) # 验证标签关联成功
# 批量创建 20 篇文章,都关联同一个标签,用于后续分页等测试
for i in range(20): for i in range(20):
article = Article() article = Article()
article.title = "nicetitle" + str(i) article.title = "nicetitle" + str(i)
@ -79,56 +97,75 @@ class ArticleTest(TestCase):
article.save() article.save()
article.tags.add(tag) article.tags.add(tag)
article.save() article.save()
# 如果启用了 Elasticsearch则构建索引并测试搜索
from blog.documents import ELASTICSEARCH_ENABLED from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED: if ELASTICSEARCH_ENABLED:
call_command("build_index") call_command("build_index") # 调用构建搜索索引的管理命令
response = self.client.get('/search', {'q': 'nicetitle'}) response = self.client.get('/search', {'q': 'nicetitle'}) # 搜索含有 nicetitle 的文章
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200) # 验证搜索页面能正常访问
# 访问某篇文章详情页,验证能正常打开
response = self.client.get(article.get_absolute_url()) response = self.client.get(article.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 模拟通知爬虫(如百度蜘蛛)抓取该文章
from djangoblog.spider_notify import SpiderNotify from djangoblog.spider_notify import SpiderNotify
SpiderNotify.notify(article.get_absolute_url()) SpiderNotify.notify(article.get_absolute_url())
# 访问标签页、分类页,验证能正常响应
response = self.client.get(tag.get_absolute_url()) response = self.client.get(tag.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(category.get_absolute_url()) response = self.client.get(category.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 搜索一个不太可能存在的词,如 django仍应返回 200
response = self.client.get('/search', {'q': 'django'}) response = self.client.get('/search', {'q': 'django'})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 调用模板标签 load_articletags验证其返回值不为空
s = load_articletags(article) s = load_articletags(article)
self.assertIsNotNone(s) self.assertIsNotNone(s)
# 登录用户
self.client.login(username='liangliangyy', password='liangliangyy') self.client.login(username='liangliangyy', password='liangliangyy')
# 访问归档页,验证能正常响应
response = self.client.get(reverse('blog:archives')) response = self.client.get(reverse('blog:archives'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
p = Paginator(Article.objects.all(), settings.PAGINATE_BY) # 测试分页功能(普通文章列表)
self.check_pagination(p, '', '') p = Paginator(Article.objects.all(), settings.PAGINATE_BY) # 每页显示 settings.PAGINATE_BY 篇
self.check_pagination(p, '', '') # 自定义方法,验证分页链接有效
# 测试按标签分页
p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY) p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
self.check_pagination(p, '分类标签归档', tag.slug) self.check_pagination(p, '分类标签归档', tag.slug)
# 测试按作者分页
p = Paginator( p = Paginator(
Article.objects.filter( Article.objects.filter(
author__username='liangliangyy'), settings.PAGINATE_BY) author__username='liangliangyy'), settings.PAGINATE_BY)
self.check_pagination(p, '作者文章归档', 'liangliangyy') self.check_pagination(p, '作者文章归档', 'liangliangyy')
# 测试按分类分页
p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY) p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
self.check_pagination(p, '分类目录归档', category.slug) self.check_pagination(p, '分类目录归档', category.slug)
# 测试搜索表单的 search 方法(即使未实际执行搜索)
f = BlogSearchForm() f = BlogSearchForm()
f.search() f.search()
# self.client.login(username='liangliangyy', password='liangliangyy')
# 模拟百度站长平台 URL 通知
from djangoblog.spider_notify import SpiderNotify from djangoblog.spider_notify import SpiderNotify
SpiderNotify.baidu_notify([article.get_full_url()]) SpiderNotify.baidu_notify([article.get_full_url()])
from blog.templatetags.blog_tags import gravatar_url, gravatar # 调用模板标签 gravatar_url 和 gravatar验证其返回值
u = gravatar_url('liangliangyy@gmail.com') u = gravatar_url('liangliangyy@gmail.com')
u = gravatar('liangliangyy@gmail.com') u = gravatar('liangliangyy@gmail.com')
# 创建一个友情链接并访问其页面
link = Links( link = Links(
sequence=1, sequence=1,
name="lylinux", name="lylinux",
@ -137,21 +174,27 @@ class ArticleTest(TestCase):
response = self.client.get('/links.html') response = self.client.get('/links.html')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 访问 RSS 订阅源
response = self.client.get('/feed/') response = self.client.get('/feed/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 访问 Sitemap
response = self.client.get('/sitemap.xml') response = self.client.get('/sitemap.xml')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 模拟访问一个不存在的 admin 删除页面或其他不存在的路由,预期返回 404 或其它
self.client.get("/admin/blog/article/1/delete/") self.client.get("/admin/blog/article/1/delete/")
self.client.get('/admin/servermanager/emailsendlog/') self.client.get('/admin/servermanager/emailsendlog/')
self.client.get('/admin/admin/logentry/') self.client.get('/admin/admin/logentry/')
self.client.get('/admin/admin/logentry/1/change/') self.client.get('/admin/admin/logentry/1/change/')
def check_pagination(self, p, type, value): def check_pagination(self, p, type, value):
"""
自定义分页测试方法遍历每一页验证上一页/下一页链接均能正常访问
"""
for page in range(1, p.num_pages + 1): for page in range(1, p.num_pages + 1):
s = load_pagination_info(p.page(page), type, value) s = load_pagination_info(p.page(page), type, value)
self.assertIsNotNone(s) self.assertIsNotNone(s) # 确保返回分页信息不为空
if s['previous_url']: if s['previous_url']:
response = self.client.get(s['previous_url']) response = self.client.get(s['previous_url'])
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -160,14 +203,20 @@ class ArticleTest(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_image(self): def test_image(self):
"""
测试图片上传功能及一些工具函数 SHA256邮件发送用户头像保存
"""
import requests import requests
rsp = requests.get( rsp = requests.get(
'https://www.python.org/static/img/python-logo.png') 'https://www.python.org/static/img/python-logo.png') # 下载 Python 官方 Logo
imagepath = os.path.join(settings.BASE_DIR, 'python.png') imagepath = os.path.join(settings.BASE_DIR, 'python.png')
with open(imagepath, 'wb') as file: with open(imagepath, 'wb') as file:
file.write(rsp.content) file.write(rsp.content)
# 尝试未授权上传,预期返回 403
rsp = self.client.post('/upload') rsp = self.client.post('/upload')
self.assertEqual(rsp.status_code, 403) self.assertEqual(rsp.status_code, 403)
# 使用签名和文件上传图片(模拟授权上传)
sign = get_sha256(get_sha256(settings.SECRET_KEY)) sign = get_sha256(get_sha256(settings.SECRET_KEY))
with open(imagepath, 'rb') as file: with open(imagepath, 'rb') as file:
imgfile = SimpleUploadedFile( imgfile = SimpleUploadedFile(
@ -177,16 +226,24 @@ class ArticleTest(TestCase):
'/upload?sign=' + sign, form_data, follow=True) '/upload?sign=' + sign, form_data, follow=True)
self.assertEqual(rsp.status_code, 200) self.assertEqual(rsp.status_code, 200)
os.remove(imagepath) os.remove(imagepath)
# 测试邮件发送与用户头像保存工具函数
from djangoblog.utils import save_user_avatar, send_email from djangoblog.utils import save_user_avatar, send_email
send_email(['qq@qq.com'], 'testTitle', 'testContent') send_email(['qq@qq.com'], 'testTitle', 'testContent')
save_user_avatar( save_user_avatar(
'https://www.python.org/static/img/python-logo.png') 'https://www.python.org/static/img/python-logo.png')
def test_errorpage(self): def test_errorpage(self):
"""
测试访问不存在的路由应该返回 404 页面
"""
rsp = self.client.get('/eee') rsp = self.client.get('/eee')
self.assertEqual(rsp.status_code, 404) self.assertEqual(rsp.status_code, 404)
def test_commands(self): def test_commands(self):
"""
测试一系列 Django 管理命令的执行如构建索引百度推送创建测试数据清理缓存同步头像等
"""
user = BlogUser.objects.get_or_create( user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com", email="liangliangyy@gmail.com",
username="liangliangyy")[0] username="liangliangyy")[0]
@ -224,9 +281,10 @@ class ArticleTest(TestCase):
from blog.documents import ELASTICSEARCH_ENABLED from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED: if ELASTICSEARCH_ENABLED:
call_command("build_index") call_command("build_index") # 构建搜索索引
call_command("ping_baidu", "all")
call_command("create_testdata") call_command("ping_baidu", "all") # 通知百度收录所有文章/分类/标签
call_command("clear_cache") call_command("create_testdata") # 创建测试数据
call_command("sync_user_avatar") call_command("clear_cache") # 清理缓存
call_command("build_search_words") call_command("sync_user_avatar") # 同步用户头像(如从 OAuth 获取)
call_command("build_search_words") # 构建搜索关键词(可能是标签/分类名等)

@ -1,62 +1,18 @@
from django.urls import path from django.urls import path
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from . import views from . import views
app_name = "blog" app_name = "blog"
urlpatterns = [ urlpatterns = [
path( path('', views.IndexView.as_view(), name='index'), # 首页
r'', path('article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.IndexView.as_view(), views.ArticleDetailView.as_view(), name='detailbyid'), # 文章详情
name='index'), path('category/<slug:category_name>.html',
path( views.CategoryDetailView.as_view(), name='category_detail'), # 分类页
r'page/<int:page>/', path('tag/<slug:tag_name>.html',
views.IndexView.as_view(), views.TagDetailView.as_view(), name='tag_detail'), # 标签页
name='index_page'), path('archives.html', cache_page(60 * 60)(views.ArchivesView.as_view()), name='archives'), # 归档页缓存1小时
path( path('links.html', views.LinkListView.as_view(), name='links'), # 友链页
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html', path('upload', views.fileupload, name='upload'), # 图床上传
views.ArticleDetailView.as_view(), path('clean', views.clean_cache_view, name='clean'), # 清缓存
name='detailbyid'), ]
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
path(
r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
path(
r'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
path(
r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
path(
r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
path(
'archives.html',
cache_page(
60 * 60)(
views.ArchivesView.as_view()),
name='archives'),
path(
'links.html',
views.LinkListView.as_view(),
name='links'),
path(
r'upload',
views.fileupload,
name='upload'),
path(
r'clean',
views.clean_cache_view,
name='clean'),
]

@ -5,316 +5,288 @@ import uuid
from django.conf import settings from django.conf import settings
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, render
from django.shortcuts import render
from django.templatetags.static import static from django.templatetags.static import static
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from haystack.views import SearchView from haystack.views import SearchView # Haystack 全文搜索视图
from blog.models import Article, Category, LinkShowType, Links, Tag from blog.models import Article, Category, LinkShowType, Links, Tag # 博客核心模型
from comments.forms import CommentForm from comments.forms import CommentForm # 评论表单
from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage import hooks # 插件管理系统
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME # 插件钩子常量
from djangoblog.utils import cache, get_blog_setting, get_sha256 from djangoblog.utils import cache, get_blog_setting, get_sha256 # 工具函数:缓存、站点配置、加密
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # 日志记录器
# -------------------------------
# 基础:通用文章列表视图(支持缓存、分页)
# -------------------------------
class ArticleListView(ListView): class ArticleListView(ListView):
# template_name属性用于指定使用哪个模板进行渲染 template_name = 'blog/article_index.html' # 默认模板
template_name = 'blog/article_index.html' context_object_name = 'article_list' # 模板中使用的上下文变量名
page_type = '' # 页面类型描述,子类可重写
# context_object_name属性用于给上下文变量取名在模板中使用该名字 paginate_by = settings.PAGINATE_BY # 每页文章数,从配置中读取
context_object_name = 'article_list' page_kwarg = 'page' # URL 中页码参数名
link_type = LinkShowType.L # 友情链接展示类型,子类可重写
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
def get_view_cache_key(self): def get_view_cache_key(self):
return self.request.get['pages'] # 获取当前视图的缓存键(注意:原代码有误,应使用 self.request.GET 而非 self.request.get
return self.request.GET.get('pages', '') # 临时占位,实际应由子类实现
@property @property
def page_number(self): def page_number(self):
# 获取当前页码,默认为 1
page_kwarg = self.page_kwarg page_kwarg = self.page_kwarg
page = self.kwargs.get( return self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1
page_kwarg) or self.request.GET.get(page_kwarg) or 1
return page
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
""" # 子类必须重写:返回当前页面数据对应的缓存键
子类重写.获得queryset的缓存key
"""
raise NotImplementedError() raise NotImplementedError()
def get_queryset_data(self): def get_queryset_data(self):
""" # 子类必须重写:返回当前页面要展示的数据(通常是 QuerySet
子类重写.获取queryset的数据
"""
raise NotImplementedError() raise NotImplementedError()
def get_queryset_from_cache(self, cache_key): def get_queryset_from_cache(self, cache_key):
''' # 尝试从缓存中获取数据,若无则查询并缓存
缓存页面数据
:param cache_key: 缓存key
:return:
'''
value = cache.get(cache_key) value = cache.get(cache_key)
if value: if value:
logger.info('get view cache.key:{key}'.format(key=cache_key)) logger.info(f'get view cache. key:{cache_key}')
return value return value
else: else:
article_list = self.get_queryset_data() article_list = self.get_queryset_data()
cache.set(cache_key, article_list) cache.set(cache_key, article_list)
logger.info('set view cache.key:{key}'.format(key=cache_key)) logger.info(f'set view cache. key:{cache_key}')
return article_list return article_list
def get_queryset(self): def get_queryset(self):
''' # 重写默认的查询集,优先从缓存中读取
重写默认从缓存获取数据
:return:
'''
key = self.get_queryset_cache_key() key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key) value = self.get_queryset_from_cache(key)
return value return value
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
# 给模板上下文添加 linktype用于控制友情链接展示类型
kwargs['linktype'] = self.link_type kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs) return super().get_context_data(**kwargs)
# -------------------------------
# 首页视图:展示所有已发布文章
# -------------------------------
class IndexView(ArticleListView): class IndexView(ArticleListView):
''' link_type = LinkShowType.I # 首页链接类型为 ‘首页展示’
首页
'''
# 友情链接类型
link_type = LinkShowType.I
def get_queryset_data(self): def get_queryset_data(self):
article_list = Article.objects.filter(type='a', status='p') # 只获取类型为 'a'(文章),状态为 'p'(已发布)的文章
return article_list return Article.objects.filter(type='a', status='p')
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
cache_key = 'index_{page}'.format(page=self.page_number) # 缓存键包含页码,如 index_1, index_2...
return cache_key return f'index_{self.page_number}'
# -------------------------------
# 文章详情页
# -------------------------------
class ArticleDetailView(DetailView): class ArticleDetailView(DetailView):
'''
文章详情页面
'''
template_name = 'blog/article_detail.html' template_name = 'blog/article_detail.html'
model = Article model = Article
pk_url_kwarg = 'article_id' pk_url_kwarg = 'article_id' # URL 中的文章 ID 参数名
context_object_name = "article" context_object_name = "article"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
# 添加评论表单
comment_form = CommentForm() comment_form = CommentForm()
# 获取当前文章的所有评论,并筛选出顶级评论(无父评论)
article_comments = self.object.comment_list() article_comments = self.object.comment_list()
parent_comments = article_comments.filter(parent_comment=None) parent_comments = article_comments.filter(parent_comment=None)
blog_setting = get_blog_setting()
paginator = Paginator(parent_comments, blog_setting.article_comment_count) blog_setting = get_blog_setting() # 获取博客配置
paginator = Paginator(parent_comments, blog_setting.article_comment_count) # 评论分页
page = self.request.GET.get('comment_page', '1') page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
page = 1 try:
else:
page = int(page) page = int(page)
if page < 1: if page < 1:
page = 1 page = 1
if page > paginator.num_pages: if page > paginator.num_pages:
page = paginator.num_pages page = paginator.num_pages
except:
page = 1
p_comments = paginator.page(page) p_comments = paginator.page(page) # 当前页的评论
next_page = p_comments.next_page_number() if p_comments.has_next() else None next_page = p_comments.next_page_number() if p_comments.has_next() else None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
# 若有下一页/上一页,在上下文中添加对应 URL带锚点定位到评论区
if next_page: if next_page:
kwargs[ kwargs['comment_next_page_url'] = f"{self.object.get_absolute_url()}?comment_page={next_page}#commentlist-container"
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
if prev_page: if prev_page:
kwargs[ kwargs['comment_prev_page_url'] = f"{self.object.get_absolute_url()}?comment_page={prev_page}#commentlist-container"
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
kwargs['form'] = comment_form kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_comments kwargs['p_comments'] = p_comments
kwargs['comment_count'] = len( kwargs['comment_count'] = len(article_comments) if article_comments else 0
article_comments) if article_comments else 0
kwargs['next_article'] = self.object.next_article # 下一篇文章
kwargs['prev_article'] = self.object.prev_article # 上一篇文章
kwargs['next_article'] = self.object.next_article context = super().get_context_data(**kwargs)
kwargs['prev_article'] = self.object.prev_article
# 调用插件钩子:文章内容获取后通知
hooks.run_action('after_article_body_get', article=self.object, request=self.request)
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# Action Hook, 通知插件"文章详情已获取"
hooks.run_action('after_article_body_get', article=article, request=self.request)
return context return context
# -------------------------------
# 分类页视图
# -------------------------------
class CategoryDetailView(ArticleListView): class CategoryDetailView(ArticleListView):
'''
分类目录列表
'''
page_type = "分类目录归档" page_type = "分类目录归档"
def get_queryset_data(self): def get_queryset_data(self):
slug = self.kwargs['category_name'] slug = self.kwargs['category_name'] # 从 URL 获取分类别名
category = get_object_or_404(Category, slug=slug) category = get_object_or_404(Category, slug=slug)
categorynames = [c.name for c in category.get_sub_categorys()] # 获取所有子分类名称
categoryname = category.name # 获取这些分类下的所有已发布文章
self.categoryname = categoryname return Article.objects.filter(category__name__in=categorynames, status='p')
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return article_list
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
slug = self.kwargs['category_name'] slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug) category = get_object_or_404(Category, slug=slug)
categoryname = category.name cache_key = f'category_list_{category.name}_{self.page_number}'
self.categoryname = categoryname
cache_key = 'category_list_{categoryname}_{page}'.format(
categoryname=categoryname, page=self.page_number)
return cache_key return cache_key
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
categoryname = self.kwargs['category_name']
categoryname = self.categoryname
try: try:
categoryname = categoryname.split('/')[-1] categoryname = categoryname.split('/')[-1] # 尝试提取最后一段(美化展示用)
except BaseException: except:
pass pass
kwargs['page_type'] = CategoryDetailView.page_type kwargs['page_type'] = self.page_type
kwargs['tag_name'] = categoryname kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs) return super().get_context_data(**kwargs)
# -------------------------------
# 作者页视图
# -------------------------------
class AuthorDetailView(ArticleListView): class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
page_type = '作者文章归档' page_type = '作者文章归档'
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
from uuslug import slugify from uuslug import slugify
author_name = slugify(self.kwargs['author_name']) author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format( return f'author_{author_name}_{self.page_number}'
author_name=author_name, page=self.page_number)
return cache_key
def get_queryset_data(self): def get_queryset_data(self):
author_name = self.kwargs['author_name'] author_name = self.kwargs['author_name']
article_list = Article.objects.filter( return Article.objects.filter(author__username=author_name, type='a', status='p')
author__username=author_name, type='a', status='p')
return article_list
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
author_name = self.kwargs['author_name'] author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type kwargs['page_type'] = self.page_type
kwargs['tag_name'] = author_name kwargs['tag_name'] = author_name
return super(AuthorDetailView, self).get_context_data(**kwargs) return super().get_context_data(**kwargs)
# -------------------------------
# 标签页视图
# -------------------------------
class TagDetailView(ArticleListView): class TagDetailView(ArticleListView):
'''
标签列表页面
'''
page_type = '分类标签归档' page_type = '分类标签归档'
def get_queryset_data(self): def get_queryset_data(self):
slug = self.kwargs['tag_name'] slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug) tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name return Article.objects.filter(tags__name=tag.name, type='a', status='p')
self.name = tag_name
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
return article_list
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
slug = self.kwargs['tag_name'] slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug) tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name return f'tag_{tag.name}_{self.page_number}'
self.name = tag_name
cache_key = 'tag_{tag_name}_{page}'.format(
tag_name=tag_name, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
# tag_name = self.kwargs['tag_name'] tag_name = self.kwargs['tag_name']
tag_name = self.name kwargs['page_type'] = self.page_type
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs) return super().get_context_data(**kwargs)
# -------------------------------
# 归档页视图:展示所有已发布文章
# -------------------------------
class ArchivesView(ArticleListView): class ArchivesView(ArticleListView):
'''
文章归档页面
'''
page_type = '文章归档' page_type = '文章归档'
paginate_by = None paginate_by = None # 不分页
page_kwarg = None page_kwarg = None
template_name = 'blog/article_archives.html' template_name = 'blog/article_archives.html'
def get_queryset_data(self): def get_queryset_data(self):
return Article.objects.filter(status='p').all() return Article.objects.filter(status='p')
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
cache_key = 'archives' return 'archives'
return cache_key
# -------------------------------
# 友情链接页
# -------------------------------
class LinkListView(ListView): class LinkListView(ListView):
model = Links model = Links
template_name = 'blog/links_list.html' template_name = 'blog/links_list.html'
def get_queryset(self): def get_queryset(self):
return Links.objects.filter(is_enable=True) return Links.objects.filter(is_enable=True) # 只展示启用的链接
# -------------------------------
# Haystack 搜索视图
# -------------------------------
class EsSearchView(SearchView): class EsSearchView(SearchView):
def get_context(self): def get_context(self):
paginator, page = self.build_page() paginator, page = self.build_page()
context = { context = {
"query": self.query, "query": self.query, # 搜索关键词
"form": self.form, "form": self.form, # 搜索表单
"page": page, "page": page, # 当前页
"paginator": paginator, "paginator": paginator, # 分页器
"suggestion": None, "suggestion": None, # 搜索建议,可后续补充
} }
# 如果后端支持拼写建议,则添加
if hasattr(self.results, "query") and self.results.query.backend.include_spelling: if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion() context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context()) context.update(self.extra_context()) # 添加额外上下文
return context return context
# -------------------------------
# 图床上传接口(带签名校验,仅限 POST
# -------------------------------
@csrf_exempt @csrf_exempt
def fileupload(request): def fileupload(request):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request:
:return:
"""
if request.method == 'POST': if request.method == 'POST':
sign = request.GET.get('sign', None) sign = request.GET.get('sign', None)
if not sign: if not sign:
return HttpResponseForbidden() return HttpResponseForbidden()
# 校验签名(双重 SHA256与 settings.SECRET_KEY 相关)
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)): if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden() return HttpResponseForbidden()
response = [] response = []
for filename in request.FILES: for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d') timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename)) fname = ''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 isimage = any(ext in fname.lower() for ext in imgextensions)
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr) base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir): if not os.path.exists(base_dir):
os.makedirs(base_dir) os.makedirs(base_dir)
@ -328,48 +300,45 @@ def fileupload(request):
from PIL import Image from PIL import Image
image = Image.open(savepath) image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True) image.save(savepath, quality=20, optimize=True)
url = static(savepath) url = static(savepath) # 生成静态文件访问 URL
response.append(url) response.append(url)
return HttpResponse(response) return HttpResponse(response)
else: else:
return HttpResponse("only for post") return HttpResponse("only for post")
def page_not_found_view( # -------------------------------
request, # 错误页面视图
exception, # -------------------------------
template_name='blog/error_page.html'): def page_not_found_view(request, exception, template_name='blog/error_page.html'):
if exception: if exception:
logger.error(exception) logger.error(exception)
url = request.get_full_path() url = request.get_full_path()
return render(request, return render(request, template_name, {
template_name, 'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
{'message': _('Sorry, the page you requested is not found, please click the home page to see other?'), 'statuscode': '404'
'statuscode': '404'}, }, status=404)
status=404)
def server_error_view(request, template_name='blog/error_page.html'): def server_error_view(request, template_name='blog/error_page.html'):
return render(request, return render(request, template_name, {
template_name, 'message': _('Sorry, the server is busy, please click the home page to see other?'),
{'message': _('Sorry, the server is busy, please click the home page to see other?'), 'statuscode': '500'
'statuscode': '500'}, }, status=500)
status=500)
def permission_denied_view( def permission_denied_view(request, exception, template_name='blog/error_page.html'):
request,
exception,
template_name='blog/error_page.html'):
if exception: if exception:
logger.error(exception) logger.error(exception)
return render( return render(request, template_name, {
request, template_name, { 'message': _('Sorry, you do not have permission to access this page?'),
'message': _('Sorry, you do not have permission to access this page?'), 'statuscode': '403'
'statuscode': '403'}, status=403) }, status=403)
# -------------------------------
# 手动清理缓存视图(通常用于后台或调试)
# -------------------------------
def clean_cache_view(request): def clean_cache_view(request):
cache.clear() cache.clear()
return HttpResponse('ok') return HttpResponse('ok')

@ -1,49 +1,58 @@
from django.contrib import admin from django.contrib import admin
from django.urls import reverse from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _ # 用于支持多语言,这里是“获取翻译文本”
# 定义批量操作:禁用选中评论的显示状态
def disable_commentstatus(modeladmin, request, queryset): def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False) queryset.update(is_enable=False) # 将选中的评论设置为不可见
disable_commentstatus.short_description = _('Disable comments') # 操作按钮显示名称:禁用评论
# 定义批量操作:启用选中评论的显示状态
def enable_commentstatus(modeladmin, request, queryset): def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True) queryset.update(is_enable=True) # 将选中的评论设置为可见
enable_commentstatus.short_description = _('Enable comments') # 操作按钮显示名称:启用评论
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
# 自定义评论管理后台展示类
class CommentAdmin(admin.ModelAdmin): class CommentAdmin(admin.ModelAdmin):
list_per_page = 20 list_per_page = 20 # 每页显示20条评论
# 后台列表页显示的字段
list_display = ( list_display = (
'id', 'id', # 评论ID
'body', 'body', # 评论内容
'link_to_userinfo', 'link_to_userinfo', # 自定义方法:显示用户链接
'link_to_article', 'link_to_article', # 自定义方法:显示文章链接
'is_enable', 'is_enable', # 是否启用(显示)
'creation_time') 'creation_time' # 创建时间
)
# 哪些字段可点击进入编辑页
list_display_links = ('id', 'body', 'is_enable') list_display_links = ('id', 'body', 'is_enable')
# 添加右侧过滤器,可按是否启用筛选
list_filter = ('is_enable',) list_filter = ('is_enable',)
# 在后台编辑表单中排除这两个字段(一般由系统自动填写,不需手动改)
exclude = ('creation_time', 'last_modify_time') exclude = ('creation_time', 'last_modify_time')
# 后台支持批量操作,下拉可选择启用/禁用
actions = [disable_commentstatus, enable_commentstatus] actions = [disable_commentstatus, enable_commentstatus]
# 在后台编辑评论时,作者和文章字段以 ID 选择框(而不是下拉查询)方式展示,提高效率
raw_id_fields = ('author', 'article') raw_id_fields = ('author', 'article')
# 支持按评论内容搜索
search_fields = ('body',) search_fields = ('body',)
# 自定义方法:生成指向用户信息编辑页的链接
def link_to_userinfo(self, obj): def link_to_userinfo(self, obj):
info = (obj.author._meta.app_label, obj.author._meta.model_name) info = (obj.author._meta.app_label, obj.author._meta.model_name) # 获取用户模型的 app 和 model 名称
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) # 生成用户编辑页面的URL
return format_html( # 显示用户昵称,如果没有则显示邮箱
u'<a href="%s">%s</a>' % return format_html(u'<a href="%s">%s</a>' % (link, obj.author.nickname if obj.author.nickname else obj.author.email))
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
# 自定义方法:生成指向文章编辑页的链接
def link_to_article(self, obj): def link_to_article(self, obj):
info = (obj.article._meta.app_label, obj.article._meta.model_name) info = (obj.article._meta.app_label, obj.article._meta.model_name) # 获取文章模型的 app 和 model 名称
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) # 生成文章编辑页面的URL
return format_html( return format_html(u'<a href="%s">%s</a>' % (link, obj.article.title)) # 显示文章标题并链接到编辑页
u'<a href="%s">%s</a>' % (link, obj.article.title))
link_to_userinfo.short_description = _('User') # 为自定义方法添加列标题
link_to_article.short_description = _('Article') link_to_userinfo.short_description = _('User') # 列标题:用户
link_to_article.short_description = _('Article') # 列标题:文章

@ -1,5 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
# 定义评论 App 的配置类
class CommentsConfig(AppConfig): class CommentsConfig(AppConfig):
name = 'comments' name = 'comments' # 应用的 Python 路径名,通常是 app 文件夹名

@ -1,13 +1,15 @@
from django import forms from django import forms
from django.forms import ModelForm from django.forms import ModelForm
from .models import Comment from .models import Comment # 导入评论数据模型
# 定义评论表单,基于 ModelForm与 Comment 模型绑定)
class CommentForm(ModelForm): class CommentForm(ModelForm):
# 自定义字段父评论ID用于回复功能隐藏输入框非必填
parent_comment_id = forms.IntegerField( parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False) widget=forms.HiddenInput, required=False)
class Meta: class Meta:
model = Comment model = Comment # 绑定 Comment 模型
fields = ['body'] fields = ['body'] # 表单只包含评论内容字段

@ -1,38 +1,69 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14 # Generated by Django 4.1.7 on 2023-03-02 07:14
# 由 Django 4.1.7 生成时间2023-03-02 07:14
from django.conf import settings from django.conf import settings # 导入 Django 的 settings用于访问 AUTH_USER_MODEL
from django.db import migrations, models from django.db import migrations, models # 导入迁移和模型相关功能
import django.db.models.deletion import django.db.models.deletion # 导入外键删除策略(如 CASCADE
import django.utils.timezone import django.utils.timezone # 导入 Django 的时区工具,用于默认时间
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True # 这是该应用的第一个迁移(初始迁移)
initial = True
dependencies = [ dependencies = [
('blog', '0001_initial'), ('blog', '0001_initial'), # 依赖 blog 应用的 0001_initial 迁移(可能是 Article 模型)
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型(可替换,如自定义用户模型)
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Comment', name='Comment', # 创建名为 'Comment' 的模型
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField( # 主键 ID自增 Big Integer
('body', models.TextField(max_length=300, verbose_name='正文')), auto_created=True, # 自动创建
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), primary_key=True, # 设为主键
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), serialize=False, # 不序列化(通常用于 API
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), verbose_name='ID' # 后台显示名称
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')), )),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), ('body', models.TextField( # 评论正文,文本字段
('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')), max_length=300, # 最大长度 300 字符
verbose_name='正文' # 后台显示名称
)),
('created_time', models.DateTimeField( # 评论创建时间
default=django.utils.timezone.now, # 默认当前时间
verbose_name='创建时间' # 后台显示名称
)),
('last_mod_time', models.DateTimeField( # 评论最后修改时间
default=django.utils.timezone.now, # 默认当前时间
verbose_name='修改时间' # 后台显示名称
)),
('is_enable', models.BooleanField( # 是否显示该评论
default=True, # 默认 True显示
verbose_name='是否显示' # 后台显示名称
)),
('article', models.ForeignKey( # 外键关联到 blog.Article文章
on_delete=django.db.models.deletion.CASCADE, # 级联删除(文章删了,评论也删)
to='blog.article', # 关联的模型
verbose_name='文章' # 后台显示名称
)),
('author', models.ForeignKey( # 外键关联到用户(评论作者)
on_delete=django.db.models.deletion.CASCADE, # 级联删除(用户删了,评论也删)
to=settings.AUTH_USER_MODEL, # 关联的用户模型
verbose_name='作者' # 后台显示名称
)),
('parent_comment', models.ForeignKey( # 外键关联到父级评论(用于回复)
blank=True, # 允许为空(非回复评论)
null=True, # 数据库允许 NULL
on_delete=django.db.models.deletion.CASCADE, # 级联删除(父评论删了,回复也删)
to='comments.comment', # 关联自身(评论回复评论)
verbose_name='上级评论' # 后台显示名称
)),
], ],
options={ options={
'verbose_name': '评论', 'verbose_name': '评论', # 单数后台显示名称
'verbose_name_plural': '评论', 'verbose_name_plural': '评论', # 复数后台显示名称
'ordering': ['-id'], 'ordering': ['-id'], # 默认按 ID 降序(最新评论在前)
'get_latest_by': 'id', 'get_latest_by': 'id', # 获取最新评论的依据是 ID
}, },
), ),
] ]

@ -1,18 +1,20 @@
# Generated by Django 4.1.7 on 2023-04-24 13:48 # Generated by Django 4.1.7 on 2023-04-24 13:48
# 由 Django 4.1.7 生成时间2023-04-24 13:48
from django.db import migrations, models from django.db import migrations, models # 导入迁移和模型功能
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('comments', '0001_initial'), ('comments', '0001_initial'), # 依赖前一个迁移0001_initial
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='comment', model_name='comment', # 修改 Comment 模型
name='is_enable', name='is_enable', # 修改 is_enable 字段
field=models.BooleanField(default=False, verbose_name='是否显示'), field=models.BooleanField( # 仍然是 BooleanField
default=False, # 默认值从 True 改为 False默认不显示评论
verbose_name='是否显示' # 后台显示名称不变
),
), ),
] ]

@ -1,60 +1,87 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13 # Generated by Django 4.2.5 on 2023-09-06 13:13
# 由 Django 4.2.5 生成时间2023-09-06 13:13
from django.conf import settings from django.conf import settings # 导入 settings用户模型
from django.db import migrations, models from django.db import migrations, models # 导入迁移和模型功能
import django.db.models.deletion import django.db.models.deletion # 导入外键删除策略
import django.utils.timezone import django.utils.timezone # 导入时区工具
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型
('blog', '0005_alter_article_options_alter_category_options_and_more'), ('blog', '0005_alter_article_options_alter_category_options_and_more'), # 依赖 blog 的某个迁移
('comments', '0002_alter_comment_is_enable'), ('comments', '0002_alter_comment_is_enable'), # 依赖前一个迁移0002_alter_comment_is_enable
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='comment', name='comment', # 修改 Comment 模型
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'}, options={
'get_latest_by': 'id', # 获取最新评论的依据仍然是 ID
'ordering': ['-id'], # 默认按 ID 降序(最新评论在前)
'verbose_name': 'comment', # 单数后台显示名称改为英文
'verbose_name_plural': 'comment', # 复数后台显示名称改为英文
},
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='comment', model_name='comment',
name='created_time', name='created_time', # 移除旧字段:创建时间
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='comment', model_name='comment',
name='last_mod_time', name='last_mod_time', # 移除旧字段:最后修改时间
), ),
migrations.AddField( migrations.AddField(
model_name='comment', model_name='comment',
name='creation_time', name='creation_time', # 新增字段:创建时间(更清晰的命名)
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(
default=django.utils.timezone.now, # 默认当前时间
verbose_name='creation time' # 后台显示名称改为英文
),
), ),
migrations.AddField( migrations.AddField(
model_name='comment', model_name='comment',
name='last_modify_time', name='last_modify_time', # 新增字段:最后修改时间(更清晰的命名)
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), field=models.DateTimeField(
default=django.utils.timezone.now, # 默认当前时间
verbose_name='last modify time' # 后台显示名称改为英文
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='comment', model_name='comment',
name='article', name='article', # 调整 article 外键
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, # 级联删除
to='blog.article', # 关联文章
verbose_name='article' # 后台显示名称改为英文
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='comment', model_name='comment',
name='author', name='author', # 调整 author 外键
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, # 级联删除
to=settings.AUTH_USER_MODEL, # 关联用户
verbose_name='author' # 后台显示名称改为英文
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='comment', model_name='comment',
name='is_enable', name='is_enable', # 再次调整 is_enable 默认值(确保是 False
field=models.BooleanField(default=False, verbose_name='enable'), field=models.BooleanField(
default=False, # 默认不显示评论
verbose_name='enable' # 后台显示名称改为英文
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='comment', model_name='comment',
name='parent_comment', name='parent_comment', # 调整 parent_comment 外键
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'), field=models.ForeignKey(
blank=True, # 允许为空(非回复评论)
null=True, # 数据库允许 NULL
on_delete=django.db.models.deletion.CASCADE, # 级联删除
to='comments.comment', # 关联自身(评论回复评论)
verbose_name='parent comment' # 后台显示名称改为英文
),
), ),
] ]

@ -1,39 +1,38 @@
from django.conf import settings from django.conf import settings # 用于获取项目设置,比如 AUTH_USER_MODEL
from django.db import models from django.db import models
from django.utils.timezone import now from django.utils.timezone import now # 获取当前时间(时区感知)
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _ # 多语言支持
from blog.models import Article
from blog.models import Article # 导入文章模型,假设文章在 blog app 中
# Create your models here.
# 定义评论数据模型
class Comment(models.Model): class Comment(models.Model):
body = models.TextField('正文', max_length=300) body = models.TextField('正文', max_length=300) # 评论内容最大长度300字符
creation_time = models.DateTimeField(_('creation time'), default=now) creation_time = models.DateTimeField(_('creation time'), default=now) # 评论创建时间,默认当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now) last_modify_time = models.DateTimeField(_('last modify time'), default=now) # 最后修改时间,默认当前时间
author = models.ForeignKey( author = models.ForeignKey( # 评论作者关联到用户模型settings.AUTH_USER_MODEL
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name=_('author'), verbose_name=_('author'),
on_delete=models.CASCADE) on_delete=models.CASCADE) # 作者删除时,评论也删除
article = models.ForeignKey( article = models.ForeignKey( # 评论所属文章,关联到 Article 模型
Article, Article,
verbose_name=_('article'), verbose_name=_('article'),
on_delete=models.CASCADE) on_delete=models.CASCADE) # 文章删除时,评论也删除
parent_comment = models.ForeignKey( parent_comment = models.ForeignKey( # 父评论,用于实现评论的回复嵌套功能
'self', 'self', # 自关联
verbose_name=_('parent comment'), verbose_name=_('parent comment'),
blank=True, blank=True,
null=True, null=True,
on_delete=models.CASCADE) on_delete=models.CASCADE) # 父评论删除时,子评论也删除
is_enable = models.BooleanField(_('enable'), is_enable = models.BooleanField(_('enable'), default=False, blank=False, null=False) # 是否显示该评论,默认不显示(需审核)
default=False, blank=False, null=False)
class Meta: class Meta:
ordering = ['-id'] ordering = ['-id'] # 默认按评论ID倒序排列即最新评论在前
verbose_name = _('comment') verbose_name = _('comment') # 单数形式显示名称:评论
verbose_name_plural = verbose_name verbose_name_plural = verbose_name # 复数形式同单数
get_latest_by = 'id' get_latest_by = 'id' # 获取最新对象时,根据 id 排序
def __str__(self): def __str__(self):
return self.body return self.body # 在后台或 shell 中显示评论时,显示评论内容

@ -1,30 +1,77 @@
from django import template from django import template # 导入 Django 模板系统核心模块
register = template.Library() register = template.Library() # 创建模板标签注册器实例
# =============================================================================
# 1. 递归获取评论子评论simple_tag
# =============================================================================
@register.simple_tag @register.simple_tag
def parse_commenttree(commentlist, comment): def parse_commenttree(commentlist, comment):
"""获得当前评论子评论的列表
用法: {% parse_commenttree article_comments comment as childcomments %}
""" """
datas = [] 功能递归查找并返回某个评论的所有子评论支持无限层级嵌套
适用场景在模板中获取某条评论下的所有回复如评论区的楼层回复
def parse(c): 参数说明
childs = commentlist.filter(parent_comment=c, is_enable=True) - commentlist: 评论查询集通常是 Article.comments.all() 或类似 QuerySet
for child in childs: - comment: 当前评论对象要查找其子评论的父评论
datas.append(child)
parse(child)
parse(comment) 返回值包含所有子评论的列表按递归顺序排列
return datas
模板用法示例
{% parse_commenttree article_comments comment as child_comments %}
{% for child in child_comments %}
{{ child.body }} {# 显示子评论内容 #}
{% endfor %}
"""
child_comments = [] # 初始化存储子评论的空列表
def recursive_parse(current_comment):
"""
内部递归函数深度优先遍历查找子评论
逻辑查找当前评论的所有直接子评论并对每个子评论继续递归查找
"""
# 查询条件parent_comment=当前评论 且 is_enable=True只显示启用状态的评论
direct_children = commentlist.filter(
parent_comment=current_comment,
is_enable=True
)
for child in direct_children:
child_comments.append(child) # 将子评论加入结果列表
recursive_parse(child) # 递归查找该子评论的子评论(深度优先)
recursive_parse(comment) # 从传入的评论开始递归查找
return child_comments # 返回完整的子评论列表
# =============================================================================
# 2. 渲染单个评论项inclusion_tag
# =============================================================================
@register.inclusion_tag('comments/tags/comment_item.html') @register.inclusion_tag('comments/tags/comment_item.html')
def show_comment_item(comment, ischild): def show_comment_item(comment, is_child_comment):
"""评论""" """
depth = 1 if ischild else 2 功能渲染单个评论项并控制其显示层级用于区分顶级评论和回复评论
适用场景在评论列表中差异化显示不同层级的评论如缩进回复评论
参数说明
- comment: 要渲染的评论对象
- is_child_comment: 布尔值True表示这是回复评论子评论False表示顶级评论
返回值包含评论对象和层级信息的字典
模板文件comments/tags/comment_item.html需自行创建
模板用法示例
{# 渲染顶级评论(主评论)#}
{% show_comment_item main_comment False %}
{# 渲染回复评论(子评论)#}
{% show_comment_item reply_comment True %}
"""
# 设置显示层级:子评论=1缩进更多顶级评论=2正常显示
display_level = 1 if is_child_comment else 2
return { return {
'comment_item': comment, 'comment_item': comment, # 传递评论对象给模板
'depth': depth 'depth': display_level # 传递层级信息(控制样式)
} }

@ -1,109 +1,115 @@
from django.test import Client, RequestFactory, TransactionTestCase from django.test import Client, RequestFactory, TransactionTestCase # Django 测试客户端和事务测试类
from django.urls import reverse from django.urls import reverse # 用于生成 URL
from accounts.models import BlogUser from accounts.models import BlogUser # 用户模型
from blog.models import Category, Article from blog.models import Category, Article # 文章和分类模型
from comments.models import Comment from comments.models import Comment # 评论模型
from comments.templatetags.comments_tags import * from comments.templatetags.comments_tags import * # 评论相关的模板标签(假设存在)
from djangoblog.utils import get_max_articleid_commentid from djangoblog.utils import get_max_articleid_commentid # 获取最大文章ID和评论ID的工具函数假设存在
# Create your tests here. # 评论功能集成测试类(使用事务,支持数据库回滚)
class CommentsTest(TransactionTestCase): class CommentsTest(TransactionTestCase):
def setUp(self): def setUp(self):
self.client = Client() self.client = Client() # Django 提供的 HTTP 客户端,模拟浏览器请求
self.factory = RequestFactory() self.factory = RequestFactory() # 用于创建请求对象
from blog.models import BlogSettings from blog.models import BlogSettings
value = BlogSettings() value = BlogSettings()
value.comment_need_review = True value.comment_need_review = True # 设置评论需要审核
value.save() value.save()
# 创建一个超级用户,用于登录和测试
self.user = BlogUser.objects.create_superuser( self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com", email="liangliangyy1@gmail.com",
username="liangliangyy1", username="liangliangyy1",
password="liangliangyy1") password="liangliangyy1")
# 更新某篇文章的所有评论为已启用状态(用于测试评论显示)
def update_article_comment_status(self, article): def update_article_comment_status(self, article):
comments = article.comment_set.all() comments = article.comment_set.all()
for comment in comments: for comment in comments:
comment.is_enable = True comment.is_enable = True
comment.save() comment.save()
# 测试评论提交与验证逻辑
def test_validate_comment(self): def test_validate_comment(self):
self.client.login(username='liangliangyy1', password='liangliangyy1') self.client.login(username='liangliangyy1', password='liangliangyy1') # 登录测试用户
# 创建一个分类
category = Category() category = Category()
category.name = "categoryccc" category.name = "categoryccc"
category.save() category.save()
# 创建一篇文章
article = Article() article = Article()
article.title = "nicetitleccc" article.title = "nicetitleccc"
article.body = "nicecontentccc" article.body = "nicecontentccc"
article.author = self.user article.author = self.user
article.category = category article.category = category
article.type = 'a' article.type = 'a'
article.status = 'p' article.status = 'p' # 假设 'p' 是已发布状态
article.save() article.save()
# 构造评论提交的 URL
comment_url = reverse( comment_url = reverse(
'comments:postcomment', kwargs={ 'comments:postcomment', kwargs={
'article_id': article.id}) 'article_id': article.id})
# 测试提交一条评论
response = self.client.post(comment_url, response = self.client.post(comment_url,
{ {
'body': '123ffffffffff' 'body': '123ffffffffff'
}) })
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302) # 应该重定向302
article = Article.objects.get(pk=article.pk) article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 0) self.assertEqual(len(article.comment_list()), 0) # 因为需要审核,所以评论不显示
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 1) # 手动将评论设为启用,再次检查
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 1) # 现在应该能看到评论了
# 再提交一条评论
response = self.client.post(comment_url, response = self.client.post(comment_url,
{ {
'body': '123ffffffffff', 'body': '123ffffffffff',
}) })
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
article = Article.objects.get(pk=article.pk)
self.update_article_comment_status(article) self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 2) self.assertEqual(len(article.comment_list()), 2)
parent_comment_id = article.comment_list()[0].id
# 提交一条带格式的回复评论并指定父评论ID
parent_comment_id = article.comment_list()[0].id
response = self.client.post(comment_url, response = self.client.post(comment_url,
{ {
'body': ''' 'body': '''
# Title1 # Title1[url](https://www.lylinux.net/)
#
```python # [ddd](http://www.baidu.com)
import os #
``` #
# ''',
[url](https://www.lylinux.net/) # 'parent_comment_id': parent_comment_id
# })
[ddd](http://www.baidu.com) # self.assertEqual(response.status_code, 302)
# self.update_article_comment_status(article)
# article = Article.objects.get(pk=article.pk)
''', # self.assertEqual(len(article.comment_list()), 3)
'parent_comment_id': parent_comment_id #
}) # # 获取父评论并解析评论树结构(假设存在此方法)
# comment = Comment.objects.get(id=parent_comment_id)
self.assertEqual(response.status_code, 302) # tree = parse_commenttree(article.comment_list(), comment)
self.update_article_comment_status(article) # self.assertEqual(len(tree), 1) # 评论树节点数
article = Article.objects.get(pk=article.pk) #
self.assertEqual(len(article.comment_list()), 3) # # 渲染单个评论项(假设存在此方法)
comment = Comment.objects.get(id=parent_comment_id) # data = show_comment_item(comment, True)
tree = parse_commenttree(article.comment_list(), comment) # self.assertIsNotNone(data)
self.assertEqual(len(tree), 1) #
data = show_comment_item(comment, True) # # 获取最大文章ID和评论ID假设存在此工具函数
self.assertIsNotNone(data) # s = get_max_articleid_commentid()
s = get_max_articleid_commentid() # self.assertIsNotNone(s)
self.assertIsNotNone(s) #
# # 发送评论通知邮件(假设存在此工具函数)
from comments.utils import send_comment_email # from comments.utils import send_comment_email
send_comment_email(comment) # send_comment_email(comment)

@ -1,11 +1,14 @@
from django.urls import path from django.urls import path
from . import views from . import views # 导入本 app 的视图
app_name = "comments" # 定义命名空间,方便反向解析 URL
app_name = "comments"
urlpatterns = [ urlpatterns = [
# 定义提交评论的路由:/article/<文章ID>/postcomment
path( path(
'article/<int:article_id>/postcomment', 'article/<int:article_id>/postcomment',
views.CommentPostView.as_view(), views.CommentPostView.as_view(), # 使用基于类的视图
name='postcomment'), name='postcomment'), # URL 命名,可在模板中用 {% url 'comments:postcomment' article.id %}
] ]

@ -2,16 +2,19 @@ import logging
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site from djangoblog.utils import get_current_site # 获取当前站点域名
from djangoblog.utils import send_email from djangoblog.utils import send_email # 发送邮件的工具函数
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # 日志记录器
# 定义发送评论通知邮件的函数
def send_comment_email(comment): def send_comment_email(comment):
site = get_current_site().domain site = get_current_site().domain # 获取当前站点域名,如 example.com
subject = _('Thanks for your comment') subject = _('Thanks for your comment') # 邮件主题:感谢您的评论
# 构造文章链接
article_url = f"https://{site}{comment.article.get_absolute_url()}" article_url = f"https://{site}{comment.article.get_absolute_url()}"
# 邮件 HTML 内容(感谢评论,并提供文章链接)
html_content = _("""<p>Thank you very much for your comments on this site</p> html_content = _("""<p>Thank you very much for your comments on this site</p>
You can visit <a href="%(article_url)s" rel="bookmark">%(article_title)s</a> You can visit <a href="%(article_url)s" rel="bookmark">%(article_title)s</a>
to review your comments, to review your comments,
@ -19,9 +22,11 @@ def send_comment_email(comment):
<br /> <br />
If the link above cannot be opened, please copy this link to your browser. If the link above cannot be opened, please copy this link to your browser.
%(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title} %(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title}
tomail = comment.author.email tomail = comment.author.email # 收件人为评论作者的邮箱
send_email([tomail], subject, html_content) send_email([tomail], subject, html_content) # 调用发送邮件工具函数
try: try:
# 如果该评论有父评论(即它是回复),则也给父评论的作者发提醒邮件
if comment.parent_comment: if comment.parent_comment:
html_content = _("""Your comment on <a href="%(article_url)s" rel="bookmark">%(article_title)s</a><br/> has html_content = _("""Your comment on <a href="%(article_url)s" rel="bookmark">%(article_title)s</a><br/> has
received a reply. <br/> %(comment_body)s received a reply. <br/> %(comment_body)s
@ -32,7 +37,7 @@ def send_comment_email(comment):
%(article_url)s %(article_url)s
""") % {'article_url': article_url, 'article_title': comment.article.title, """) % {'article_url': article_url, 'article_title': comment.article.title,
'comment_body': comment.parent_comment.body} 'comment_body': comment.parent_comment.body}
tomail = comment.parent_comment.author.email tomail = comment.parent_comment.author.email # 收件人是父评论作者
send_email([tomail], subject, html_content) send_email([tomail], subject, html_content)
except Exception as e: except Exception as e:
logger.error(e) logger.error(e) # 出错时记录日志

@ -1,63 +1,72 @@
# Create your views here. from django.core.exceptions import ValidationError # Django 异常:验证失败
from django.core.exceptions import ValidationError from django.http import HttpResponseRedirect # 重定向响应
from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 # 若对象不存在则返回 404
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect from django.views.decorators.csrf import csrf_protect # CSRF 保护装饰器
from django.views.generic.edit import FormView from django.views.generic.edit import FormView # 基于表单的通用视图
from accounts.models import BlogUser from accounts.models import BlogUser # 用户模型
from blog.models import Article from blog.models import Article # 文章模型
from .forms import CommentForm from .forms import CommentForm # 评论表单
from .models import Comment from .models import Comment # 评论模型
# 定义评论提交视图,继承自 FormView
class CommentPostView(FormView): class CommentPostView(FormView):
form_class = CommentForm form_class = CommentForm # 使用我们定义的评论表单
template_name = 'blog/article_detail.html' template_name = 'blog/article_detail.html' # 渲染的模板(通常评论表单嵌入在文章详情页)
# 为所有方法添加 CSRF 保护
@method_decorator(csrf_protect) @method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
return super(CommentPostView, self).dispatch(*args, **kwargs) return super(CommentPostView, self).dispatch(*args, **kwargs)
# 处理 GET 请求(如果用户直接访问该 URL重定向回文章页的评论区
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
article_id = self.kwargs['article_id'] article_id = self.kwargs['article_id'] # 从 URL 参数中获取文章ID
article = get_object_or_404(Article, pk=article_id) article = get_object_or_404(Article, pk=article_id) # 查询文章,不存在则 404
url = article.get_absolute_url() url = article.get_absolute_url() # 获取文章详情页的 URL
return HttpResponseRedirect(url + "#comments") return HttpResponseRedirect(url + "#comments") # 重定向到文章页的评论区域
# 当表单验证失败时调用
def form_invalid(self, form): def form_invalid(self, form):
article_id = self.kwargs['article_id'] article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id) article = get_object_or_404(Article, pk=article_id)
# 重新渲染页面,并回显表单错误
return self.render_to_response({ return self.render_to_response({
'form': form, 'form': form,
'article': article 'article': article
}) })
# 当表单验证成功时调用
def form_valid(self, form): def form_valid(self, form):
"""提交的数据验证合法后的逻辑""" user = self.request.user # 当前登录用户
user = self.request.user author = BlogUser.objects.get(pk=user.pk) # 获取当前用户对象
author = BlogUser.objects.get(pk=user.pk)
article_id = self.kwargs['article_id'] article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id) article = get_object_or_404(Article, pk=article_id) # 获取被评论的文章
# 如果文章禁止评论 或 文章未发布,则不允许评论
if article.comment_status == 'c' or article.status == 'c': if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.") raise ValidationError("该文章评论已关闭.")
# 创建评论对象,但先不保存到数据库
comment = form.save(False) comment = form.save(False)
comment.article = article comment.article = article # 关联文章
from djangoblog.utils import get_blog_setting from djangoblog.utils import get_blog_setting # 获取博客全局设置
settings = get_blog_setting() settings = get_blog_setting()
# 如果全局设置中不需要审核,则自动通过
if not settings.comment_need_review: if not settings.comment_need_review:
comment.is_enable = True comment.is_enable = True
comment.author = author comment.author = author # 关联评论作者
# 如果用户提交了父评论ID即回复某条评论
if form.cleaned_data['parent_comment_id']: if form.cleaned_data['parent_comment_id']:
parent_comment = Comment.objects.get( parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id']) pk=form.cleaned_data['parent_comment_id'])
comment.parent_comment = parent_comment comment.parent_comment = parent_comment # 设置父评论
comment.save(True) comment.save(True) # 保存评论到数据库
# 重定向回文章详情页,并定位到刚发表的评论位置(通常用锚点)
return HttpResponseRedirect( return HttpResponseRedirect(
"%s#div-comment-%d" % "%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk)) (article.get_absolute_url(), comment.pk))

@ -1,64 +1,69 @@
# 从 Django 的 admin 模块导入 AdminSite 基类,用于创建自定义的后台管理站点
from django.contrib.admin import AdminSite from django.contrib.admin import AdminSite
# 从 Django 的 contrib.sites 中导入 Site 模型及其管理类,用于管理站点信息(如域名)
from django.contrib.admin.models import LogEntry from django.contrib.admin.models import LogEntry
from django.contrib.sites.admin import SiteAdmin from django.contrib.sites.admin import SiteAdmin
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from accounts.admin import * # 导入各个 app应用程序下的 admin 管理类和 models 模型类
from blog.admin import * from accounts.admin import * # 用户账户相关后台管理
from blog.models import * from blog.admin import * # 博客文章相关后台管理
from comments.admin import * from blog.models import * # 博客文章相关数据模型
from comments.models import * from comments.admin import * # 评论相关后台管理
from djangoblog.logentryadmin import LogEntryAdmin from comments.models import * # 评论相关数据模型
from oauth.admin import * from djangoblog.logentryadmin import LogEntryAdmin # 操作日志LogEntry的自定义管理类
from oauth.models import * from oauth.admin import * # 第三方登录OAuth相关后台管理
from owntracks.admin import * from oauth.models import * # 第三方登录相关数据模型
from owntracks.models import * from owntracks.admin import * # 自定义轨迹记录相关后台管理
from servermanager.admin import * from owntracks.models import * # 自定义轨迹记录相关数据模型
from servermanager.models import * from servermanager.admin import * # 服务器管理相关后台管理
from servermanager.models import * # 服务器管理相关数据模型
# 定义一个自定义的 DjangoBlog 后台管理站点类,继承自 Django 提供的 AdminSite
class DjangoBlogAdminSite(AdminSite): class DjangoBlogAdminSite(AdminSite):
site_header = 'djangoblog administration' site_header = 'djangoblog administration' # 后台管理页面顶部的标题,显示为 "djangoblog administration"
site_title = 'djangoblog site admin' site_title = 'djangoblog site admin' # 浏览器标签页显示的标题,显示为 "djangoblog site admin"
def __init__(self, name='admin'): def __init__(self, name='admin'):
# 调用父类AdminSite的初始化方法
super().__init__(name) super().__init__(name)
# 重写权限验证方法只有超级用户is_superuser=True才能访问后台
def has_permission(self, request): def has_permission(self, request):
return request.user.is_superuser return request.user.is_superuser
# def get_urls(self): # 创建一个全局的 admin_site 实例,使用我们自定义的 DjangoBlogAdminSite 类
# urls = super().get_urls()
# from django.urls import path
# from blog.views import refresh_memcache
#
# my_urls = [
# path('refresh/', self.admin_view(refresh_memcache), name="refresh"),
# ]
# return urls + my_urls
admin_site = DjangoBlogAdminSite(name='admin') admin_site = DjangoBlogAdminSite(name='admin')
admin_site.register(Article, ArticlelAdmin) # 将各个模型(如文章、分类、标签、评论等)与其对应的管理类注册到 admin_site 中,
admin_site.register(Category, CategoryAdmin) # 这样它们就可以在 Django 的后台管理系统中被管理和展示
admin_site.register(Tag, TagAdmin)
admin_site.register(Links, LinksAdmin) # 注册博客核心模型
admin_site.register(SideBar, SideBarAdmin) admin_site.register(Article, ArticlelAdmin) # 文章模型与它的管理类
admin_site.register(BlogSettings, BlogSettingsAdmin) admin_site.register(Category, CategoryAdmin) # 分类模型与它的管理类
admin_site.register(Tag, TagAdmin) # 标签模型与它的管理类
admin_site.register(Links, LinksAdmin) # 友情链接模型
admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型
admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型
# 注册一些功能模块模型
admin_site.register(commands, CommandsAdmin) admin_site.register(commands, CommandsAdmin)
admin_site.register(EmailSendLog, EmailSendLogAdmin) admin_site.register(EmailSendLog, EmailSendLogAdmin)
# 注册用户相关模型
admin_site.register(BlogUser, BlogUserAdmin) admin_site.register(BlogUser, BlogUserAdmin)
# 注册评论与第三方登录相关模型
admin_site.register(Comment, CommentAdmin) admin_site.register(Comment, CommentAdmin)
admin_site.register(OAuthUser, OAuthUserAdmin) admin_site.register(OAuthUser, OAuthUserAdmin)
admin_site.register(OAuthConfig, OAuthConfigAdmin) admin_site.register(OAuthConfig, OAuthConfigAdmin)
# 注册轨迹记录模型
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) admin_site.register(OwnTrackLog, OwnTrackLogsAdmin)
# 注册 Django 默认的 Site 模型,用于管理站点信息(如域名等)
admin_site.register(Site, SiteAdmin) admin_site.register(Site, SiteAdmin)
admin_site.register(LogEntry, LogEntryAdmin) # 注册 Django 的 LogEntry 模型(记录后台操作日志),并使用自定义的 LogEntryAdmin 管理类
admin_site.register(LogEntry, LogEntryAdmin)

@ -1,11 +1,15 @@
# 从 Django 的 apps 模块导入 AppConfig 基类用于定义当前应用djangoblog的配置信息
from django.apps import AppConfig from django.apps import AppConfig
# 定义 djangoblog 应用的配置类,继承自 AppConfig
class DjangoblogAppConfig(AppConfig): class DjangoblogAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField' # 指定默认的主键字段类型为 BigAutoField
name = 'djangoblog' name = 'djangoblog' # 应用的名称,必须与项目中的 app 名称一致
def ready(self): def ready(self):
super().ready() # 当应用加载完成时调用Django 启动时)
# Import and load plugins here super().ready() # 先调用父类的 ready 方法
# 导入并执行插件加载函数,用于动态加载项目中注册的插件
from .plugin_manage.loader import load_plugins from .plugin_manage.loader import load_plugins
load_plugins() load_plugins()

@ -16,107 +16,134 @@ from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, del
from djangoblog.utils import get_current_site from djangoblog.utils import get_current_site
from oauth.models import OAuthUser from oauth.models import OAuthUser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # 获取当前模块的日志记录器
# 自定义 Django 信号:当用户通过 OAuth 登录时触发
oauth_user_login_signal = django.dispatch.Signal(['id']) oauth_user_login_signal = django.dispatch.Signal(['id'])
send_email_signal = django.dispatch.Signal(
['emailto', 'title', 'content'])
# 自定义 Django 信号:用于发送邮件的通用信号
send_email_signal = django.dispatch.Signal(['emailto', 'title', 'content'])
# 信号接收器:处理 send_email_signal 信号,即发送邮件
@receiver(send_email_signal) @receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs): def send_email_signal_handler(sender, **kwargs):
emailto = kwargs['emailto'] emailto = kwargs['emailto'] # 收件人列表
title = kwargs['title'] title = kwargs['title'] # 邮件标题
content = kwargs['content'] content = kwargs['content'] # 邮件内容(通常是 HTML
# 创建一封多格式HTML邮件
msg = EmailMultiAlternatives( msg = EmailMultiAlternatives(
title, title,
content, content,
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL, # 发件人,从 settings 中读取
to=emailto) to=emailto) # 收件人列表
msg.content_subtype = "html"
msg.content_subtype = "html" # 设置邮件内容为 HTML 格式
# 创建一条邮件发送日志记录
from servermanager.models import EmailSendLog from servermanager.models import EmailSendLog
log = EmailSendLog() log = EmailSendLog()
log.title = title log.title = title
log.content = content log.content = content
log.emailto = ','.join(emailto) log.emailto = ','.join(emailto) # 将收件人列表转为字符串,用逗号分隔
try: try:
result = msg.send() result = msg.send() # 发送邮件
log.send_result = result > 0 log.send_result = result > 0 # 判断是否有邮件发送成功
except Exception as e: except Exception as e:
logger.error(f"失败邮箱号: {emailto}, {e}") logger.error(f"失败邮箱号: {emailto}, {e}") # 记录发送失败的邮箱和异常
log.send_result = False log.send_result = False
log.save() log.save() # 保存日志
# 信号接收器:处理 oauth_user_login_signal 信号,即用户通过 OAuth 登录后的处理
@receiver(oauth_user_login_signal) @receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs): def oauth_user_login_signal_handler(sender, **kwargs):
id = kwargs['id'] id = kwargs['id'] # 获取传递的用户 ID
oauthuser = OAuthUser.objects.get(id=id) oauthuser = OAuthUser.objects.get(id=id) # 从数据库中查询该 OAuth 用户
site = get_current_site().domain site = get_current_site().domain # 获取当前站点的域名
# 如果用户头像 URL 包含当前站点域名,则认为头像已上传至本站,否则调用函数保存头像
if oauthuser.picture and not oauthuser.picture.find(site) >= 0: if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
from djangoblog.utils import save_user_avatar from djangoblog.utils import save_user_avatar
oauthuser.picture = save_user_avatar(oauthuser.picture) oauthuser.picture = save_user_avatar(oauthuser.picture) # 保存用户头像到本地
oauthuser.save() oauthuser.save() # 保存用户信息
delete_sidebar_cache() delete_sidebar_cache() # 删除侧边栏缓存,以确保用户信息更新后界面及时刷新
# 信号接收器:监听所有模型的 post_save 信号,即模型保存(创建/更新)后触发
@receiver(post_save) @receiver(post_save)
def model_post_save_callback( def model_post_save_callback(
sender, sender, # 发出信号的模型类
instance, instance, # 当前被保存的模型实例
created, created, # 是否为新创建的实例
raw, raw, # 是否为原始保存(如 fixtures
using, using, # 使用的数据库别名
update_fields, update_fields, # 更新的字段集合
**kwargs): **kwargs):
clearcache = False clearcache = False # 标记是否需要清除缓存
# 如果保存的模型是 LogEntry后台操作日志则直接返回不做处理
if isinstance(instance, LogEntry): if isinstance(instance, LogEntry):
return return
# 如果模型有 get_full_url 方法(通常为有 URL 的模型,如文章、页面)
if 'get_full_url' in dir(instance): if 'get_full_url' in dir(instance):
is_update_views = update_fields == {'views'} is_update_views = update_fields == {'views'} # 判断是否只更新了浏览量字段
if not settings.TESTING and not is_update_views: if not settings.TESTING and not is_update_views:
try: try:
notify_url = instance.get_full_url() notify_url = instance.get_full_url() # 获取该对象的完整 URL
SpiderNotify.baidu_notify([notify_url]) SpiderNotify.baidu_notify([notify_url]) # 通知百度蜘蛛更新该 URL
except Exception as ex: except Exception as ex:
logger.error("notify sipder", ex) logger.error("notify sipder", ex) # 记录通知蜘蛛时的异常
if not is_update_views: if not is_update_views:
clearcache = True clearcache = True # 如果不是只更新浏览量,则需要清除缓存
# 如果保存的模型是 Comment评论
if isinstance(instance, Comment): if isinstance(instance, Comment):
if instance.is_enable: if instance.is_enable: # 如果该评论是启用状态
path = instance.article.get_absolute_url() path = instance.article.get_absolute_url() # 获取该评论所属文章的详情页 URL
site = get_current_site().domain site = get_current_site().domain
if site.find(':') > 0: if site.find(':') > 0:
site = site[0:site.find(':')] site = site[0:site.find(':')] # 清理站点域名
# 使文章详情页的视图缓存失效
expire_view_cache( expire_view_cache(
path, path,
servername=site, servername=site,
serverport=80, serverport=80,
key_prefix='blogdetail') key_prefix='blogdetail')
# 如果有缓存 seo_processor则删除它通常用于 SEO 相关的动态内容)
if cache.get('seo_processor'): if cache.get('seo_processor'):
cache.delete('seo_processor') cache.delete('seo_processor')
comment_cache_key = 'article_comments_{id}'.format(
id=instance.article.id) # 删除该文章的评论缓存
comment_cache_key = 'article_comments_{id}'.format(id=instance.article.id)
cache.delete(comment_cache_key) cache.delete(comment_cache_key)
# 删除侧边栏缓存
delete_sidebar_cache() delete_sidebar_cache()
# 删除文章评论相关的片段缓存
delete_view_cache('article_comments', [str(instance.article.pk)]) delete_view_cache('article_comments', [str(instance.article.pk)])
# 在新线程中发送评论通知邮件给文章作者
_thread.start_new_thread(send_comment_email, (instance,)) _thread.start_new_thread(send_comment_email, (instance,))
# 如果需要清除缓存,则清空所有缓存
if clearcache: if clearcache:
cache.clear() cache.clear()
# 信号接收器:监听用户登录和登出信号
@receiver(user_logged_in) @receiver(user_logged_in)
@receiver(user_logged_out) @receiver(user_logged_out)
def user_auth_callback(sender, request, user, **kwargs): def user_auth_callback(sender, request, user, **kwargs):
if user and user.username: if user and user.username:
logger.info(user) logger.info(user) # 记录用户登录/登出信息
delete_sidebar_cache() delete_sidebar_cache() # 删除侧边栏缓存
# cache.clear() # cache.clear() # 可选:清空所有缓存

@ -1,183 +1,122 @@
from django.utils.encoding import force_str import _thread
from elasticsearch_dsl import Q import logging
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query
from haystack.forms import ModelSearchForm import django.dispatch
from haystack.models import SearchResult from django.conf import settings
from haystack.utils import log as logging from django.contrib.admin.models import LogEntry
from django.contrib.auth.signals import user_logged_in, user_logged_out
from blog.documents import ArticleDocument, ArticleDocumentManager from django.core.mail import EmailMultiAlternatives
from blog.models import Article from django.db.models.signals import post_save
from django.dispatch import receiver
from comments.models import Comment
from comments.utils import send_comment_email
from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
oauth_user_login_signal = django.dispatch.Signal(['id'])
class ElasticSearchBackend(BaseSearchBackend): send_email_signal = django.dispatch.Signal(
def __init__(self, connection_alias, **connection_options): ['emailto', 'title', 'content'])
super(
ElasticSearchBackend,
self).__init__( @receiver(send_email_signal)
connection_alias, def send_email_signal_handler(sender, **kwargs):
**connection_options) emailto = kwargs['emailto']
self.manager = ArticleDocumentManager() title = kwargs['title']
self.include_spelling = True content = kwargs['content']
def _get_models(self, iterable): msg = EmailMultiAlternatives(
models = iterable if iterable and iterable[0] else Article.objects.all() title,
docs = self.manager.convert_to_doc(models) content,
return docs from_email=settings.DEFAULT_FROM_EMAIL,
to=emailto)
def _create(self, models): msg.content_subtype = "html"
self.manager.create_index()
docs = self._get_models(models) from servermanager.models import EmailSendLog
self.manager.rebuild(docs) log = EmailSendLog()
log.title = title
def _delete(self, models): log.content = content
for m in models: log.emailto = ','.join(emailto)
m.delete()
return True try:
result = msg.send()
def _rebuild(self, models): log.send_result = result > 0
models = models if models else Article.objects.all() except Exception as e:
docs = self.manager.convert_to_doc(models) logger.error(f"失败邮箱号: {emailto}, {e}")
self.manager.update_docs(docs) log.send_result = False
log.save()
def update(self, index, iterable, commit=True):
models = self._get_models(iterable) @receiver(oauth_user_login_signal)
self.manager.update_docs(models) def oauth_user_login_signal_handler(sender, **kwargs):
id = kwargs['id']
def remove(self, obj_or_string): oauthuser = OAuthUser.objects.get(id=id)
models = self._get_models([obj_or_string]) site = get_current_site().domain
self._delete(models) if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
from djangoblog.utils import save_user_avatar
def clear(self, models=None, commit=True): oauthuser.picture = save_user_avatar(oauthuser.picture)
self.remove(None) oauthuser.save()
@staticmethod delete_sidebar_cache()
def get_suggestion(query: str) -> str:
"""获取推荐词, 如果没有找到添加原搜索词"""
@receiver(post_save)
search = ArticleDocument.search() \ def model_post_save_callback(
.query("match", body=query) \ sender,
.suggest('suggest_search', query, term={'field': 'body'}) \ instance,
.execute() created,
raw,
keywords = [] using,
for suggest in search.suggest.suggest_search: update_fields,
if suggest["options"]: **kwargs):
keywords.append(suggest["options"][0]["text"]) clearcache = False
else: if isinstance(instance, LogEntry):
keywords.append(suggest["text"]) return
if 'get_full_url' in dir(instance):
return ' '.join(keywords) is_update_views = update_fields == {'views'}
if not settings.TESTING and not is_update_views:
@log_query try:
def search(self, query_string, **kwargs): notify_url = instance.get_full_url()
logger.info('search query_string:' + query_string) SpiderNotify.baidu_notify([notify_url])
except Exception as ex:
start_offset = kwargs.get('start_offset') logger.error("notify sipder", ex)
end_offset = kwargs.get('end_offset') if not is_update_views:
clearcache = True
# 推荐词搜索
if getattr(self, "is_suggest", None): if isinstance(instance, Comment):
suggestion = self.get_suggestion(query_string) if instance.is_enable:
else: path = instance.article.get_absolute_url()
suggestion = query_string site = get_current_site().domain
if site.find(':') > 0:
q = Q('bool', site = site[0:site.find(':')]
should=[Q('match', body=suggestion), Q('match', title=suggestion)],
minimum_should_match="70%") expire_view_cache(
path,
search = ArticleDocument.search() \ servername=site,
.query('bool', filter=[q]) \ serverport=80,
.filter('term', status='p') \ key_prefix='blogdetail')
.filter('term', type='a') \ if cache.get('seo_processor'):
.source(False)[start_offset: end_offset] cache.delete('seo_processor')
comment_cache_key = 'article_comments_{id}'.format(
results = search.execute() id=instance.article.id)
hits = results['hits'].total cache.delete(comment_cache_key)
raw_results = [] delete_sidebar_cache()
for raw_result in results['hits']['hits']: delete_view_cache('article_comments', [str(instance.article.pk)])
app_label = 'blog'
model_name = 'Article' _thread.start_new_thread(send_comment_email, (instance,))
additional_fields = {}
if clearcache:
result_class = SearchResult cache.clear()
result = result_class(
app_label, @receiver(user_logged_in)
model_name, @receiver(user_logged_out)
raw_result['_id'], def user_auth_callback(sender, request, user, **kwargs):
raw_result['_score'], if user and user.username:
**additional_fields) logger.info(user)
raw_results.append(result) delete_sidebar_cache()
facets = {} # cache.clear()
spelling_suggestion = None if query_string == suggestion else suggestion
return {
'results': raw_results,
'hits': hits,
'facets': facets,
'spelling_suggestion': spelling_suggestion,
}
class ElasticSearchQuery(BaseSearchQuery):
def _convert_datetime(self, date):
if hasattr(date, 'hour'):
return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
return force_str(date.strftime('%Y%m%d000000'))
def clean(self, query_fragment):
"""
Provides a mechanism for sanitizing user input before presenting the
value to the backend.
Whoosh 1.X differs here in that you can no longer use a backslash
to escape reserved characters. Instead, the whole word should be
quoted.
"""
words = query_fragment.split()
cleaned_words = []
for word in words:
if word in self.backend.RESERVED_WORDS:
word = word.replace(word, word.lower())
for char in self.backend.RESERVED_CHARACTERS:
if char in word:
word = "'%s'" % word
break
cleaned_words.append(word)
return ' '.join(cleaned_words)
def build_query_fragment(self, field, filter_type, value):
return value.query_string
def get_count(self):
results = self.get_results()
return len(results) if results else 0
def get_spelling_suggestion(self, preferred_query=None):
return self._spelling_suggestion
def build_params(self, spelling_query=None):
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
class ElasticSearchModelSearchForm(ModelSearchForm):
def search(self):
# 是否建议搜索
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
sqs = super().search()
return sqs
class ElasticSearchEngine(BaseEngine):
backend = ElasticSearchBackend
query = ElasticSearchQuery

@ -3,38 +3,46 @@ from django.contrib.syndication.views import Feed
from django.utils import timezone from django.utils import timezone
from django.utils.feedgenerator import Rss201rev2Feed from django.utils.feedgenerator import Rss201rev2Feed
from blog.models import Article from blog.models import Article # 导入文章模型
from djangoblog.utils import CommonMarkdown from djangoblog.utils import CommonMarkdown # 导入 Markdown 工具类,用于渲染文章内容
# 定义一个继承自 Django Feed 类的博客 RSS 订阅源
class DjangoBlogFeed(Feed): class DjangoBlogFeed(Feed):
feed_type = Rss201rev2Feed feed_type = Rss201rev2Feed # 使用标准的 RSS 2.0.1 格式
description = '大巧无工,重剑无锋.' description = '大巧无工,重剑无锋.' # 订阅源的描述
title = "且听风吟 大巧无工,重剑无锋. " title = "且听风吟 大巧无工,重剑无锋. " # 订阅源的标题
link = "/feed/" link = "/feed/" # 订阅源的链接地址
# 返回博客作者的名称,这里简单取第一个用户的昵称
def author_name(self): def author_name(self):
return get_user_model().objects.first().nickname return get_user_model().objects.first().nickname
# 返回博客作者的个人主页链接
def author_link(self): def author_link(self):
return get_user_model().objects.first().get_absolute_url() return get_user_model().objects.first().get_absolute_url()
# 返回最新的 5 篇已发布文章,按发布时间倒序
def items(self): def items(self):
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5] return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
# 返回每篇文章的标题
def item_title(self, item): def item_title(self, item):
return item.title return item.title
# 返回每篇文章的内容(使用 Markdown 渲染后的 HTML
def item_description(self, item): def item_description(self, item):
return CommonMarkdown.get_markdown(item.body) return CommonMarkdown.get_markdown(item.body)
# 返回订阅源的版权信息,包含当前年份
def feed_copyright(self): def feed_copyright(self):
now = timezone.now() now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year) return "Copyright© {year} 且听风吟".format(year=now.year)
# 返回每篇文章的链接地址
def item_link(self, item): def item_link(self, item):
return item.get_absolute_url() return item.get_absolute_url()
# 返回每篇文章的唯一标识符(此处未特别设置)
def item_guid(self, item): def item_guid(self, item):
return return

@ -1,4 +1,4 @@
from django.contrib import admin from django.contrib.admin import ModelAdmin
from django.contrib.admin.models import DELETION from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse, NoReverseMatch from django.urls import reverse, NoReverseMatch
@ -7,47 +7,52 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# 自定义 Django 后台中 LogEntry操作日志的管理类
class LogEntryAdmin(admin.ModelAdmin): class LogEntryAdmin(ModelAdmin):
list_filter = [ list_filter = [
'content_type' 'content_type'
] ] # 添加按内容类型过滤的选项
search_fields = [ search_fields = [
'object_repr', 'object_repr',
'change_message' 'change_message'
] ] # 允许按对象表示和变更消息搜索
list_display_links = [ list_display_links = [
'action_time', 'action_time',
'get_change_message', 'get_change_message',
] ] # 可点击进入详情的字段
list_display = [
'action_time', list_display = [ # 后台列表页展示的字段
'user_link', 'action_time', # 操作时间
'content_type', 'user_link', # 操作用户链接
'object_link', 'content_type', # 操作的内容类型
'get_change_message', 'object_link', # 操作的对象链接
'get_change_message', # 操作的变更消息
] ]
# 禁止普通用户添加日志
def has_add_permission(self, request): def has_add_permission(self, request):
return False return False
# 仅超级用户或具有特定权限的用户可以查看/修改日志(但禁止 POST 修改)
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
return ( return (
request.user.is_superuser or request.user.is_superuser or
request.user.has_perm('admin.change_logentry') request.user.has_perm('admin.change_logentry')
) and request.method != 'POST' ) and request.method != 'POST'
# 禁止删除日志
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
return False return False
# 将操作对象转换为可点击的链接(如果可能)
def object_link(self, obj): def object_link(self, obj):
object_link = escape(obj.object_repr) object_link = escape(obj.object_repr) # 转义对象表示,防止 XSS
content_type = obj.content_type content_type = obj.content_type
if obj.action_flag != DELETION and content_type is not None: if obj.action_flag != DELETION and content_type is not None:
# try returning an actual link instead of object repr string # 如果不是删除操作且有内容类型,则尝试生成该对象的修改链接
try: try:
url = reverse( url = reverse(
'admin:{}_{}_change'.format(content_type.app_label, 'admin:{}_{}_change'.format(content_type.app_label,
@ -57,16 +62,16 @@ class LogEntryAdmin(admin.ModelAdmin):
object_link = '<a href="{}">{}</a>'.format(url, object_link) object_link = '<a href="{}">{}</a>'.format(url, object_link)
except NoReverseMatch: except NoReverseMatch:
pass pass
return mark_safe(object_link) return mark_safe(object_link) # 标记为安全的 HTML
object_link.admin_order_field = 'object_repr' object_link.admin_order_field = 'object_repr'
object_link.short_description = _('object') object_link.short_description = _('object') # 字段显示名称
# 将操作用户转换为可点击的链接(指向用户编辑页)
def user_link(self, obj): def user_link(self, obj):
content_type = ContentType.objects.get_for_model(type(obj.user)) content_type = ContentType.objects.get_for_model(type(obj.user))
user_link = escape(force_str(obj.user)) user_link = escape(force_str(obj.user))
try: try:
# try returning an actual link instead of object repr string
url = reverse( url = reverse(
'admin:{}_{}_change'.format(content_type.app_label, 'admin:{}_{}_change'.format(content_type.app_label,
content_type.model), content_type.model),
@ -80,12 +85,14 @@ class LogEntryAdmin(admin.ModelAdmin):
user_link.admin_order_field = 'user' user_link.admin_order_field = 'user'
user_link.short_description = _('user') user_link.short_description = _('user')
# 重写查询集,预加载 content_type提高性能
def get_queryset(self, request): def get_queryset(self, request):
queryset = super(LogEntryAdmin, self).get_queryset(request) queryset = super(LogEntryAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_type') return queryset.prefetch_related('content_type')
# 删除默认的“批量删除”操作
def get_actions(self, request): def get_actions(self, request):
actions = super(LogEntryAdmin, self).get_actions(request) actions = super(LogEntryAdmin, self).get_actions(request)
if 'delete_selected' in actions: if 'delete_selected' in actions:
del actions['delete_selected'] del actions['delete_selected']
return actions return actions

@ -1,41 +1,44 @@
import logging import logging
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BasePlugin: class BasePlugin:
# 插件元数据 """插件基类,定义插件的基本结构和元数据要求"""
PLUGIN_NAME = None
PLUGIN_DESCRIPTION = None # 插件元数据(子类必须定义这些属性)
PLUGIN_VERSION = None PLUGIN_NAME = None # 插件名称
PLUGIN_DESCRIPTION = None # 插件描述
PLUGIN_VERSION = None # 插件版本
def __init__(self): def __init__(self):
"""初始化插件,检查元数据并调用初始化逻辑"""
# 检查必要的元数据是否已定义
if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]): if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.") raise ValueError("插件元数据PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION必须定义")
# 调用插件初始化方法
self.init_plugin() self.init_plugin()
# 注册插件的钩子回调
self.register_hooks() self.register_hooks()
def init_plugin(self): def init_plugin(self):
""" """插件初始化逻辑(子类可重写此方法实现自定义初始化)"""
插件初始化逻辑 logger.info(f'{self.PLUGIN_NAME} 初始化完成。')
子类可以重写此方法来实现特定的初始化操作
"""
logger.info(f'{self.PLUGIN_NAME} initialized.')
def register_hooks(self): def register_hooks(self):
""" """注册插件钩子(子类可重写此方法注册特定钩子)"""
注册插件钩子 pass # 默认不注册任何钩子
子类可以重写此方法来注册特定的钩子
"""
pass
def get_plugin_info(self): def get_plugin_info(self):
""" """获取插件的元数据信息
获取插件信息
:return: 包含插件元数据的字典 Returns:
dict: 包含插件名称描述和版本的字典
""" """
return { return {
'name': self.PLUGIN_NAME, 'name': self.PLUGIN_NAME,
'description': self.PLUGIN_DESCRIPTION, 'description': self.PLUGIN_DESCRIPTION,
'version': self.PLUGIN_VERSION 'version': self.PLUGIN_VERSION
} }

@ -1,7 +1,8 @@
ARTICLE_DETAIL_LOAD = 'article_detail_load' # 文章管理相关的操作常量(用于标识不同动作)
ARTICLE_CREATE = 'article_create' ARTICLE_DETAIL_LOAD = 'article_detail_load' # 文章详情加载动作
ARTICLE_UPDATE = 'article_update' ARTICLE_CREATE = 'article_create' # 文章创建动作
ARTICLE_DELETE = 'article_delete' ARTICLE_UPDATE = 'article_update' # 文章更新动作
ARTICLE_DELETE = 'article_delete' # 文章删除动作
ARTICLE_CONTENT_HOOK_NAME = "the_content"
# 内容处理钩子常量(用于在文章内容展示前/后进行处理)
ARTICLE_CONTENT_HOOK_NAME = "the_content" # 文章内容钩子名称

@ -1,44 +1,63 @@
import logging import logging
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 全局钩子存储字典(存储所有注册的钩子及其回调函数)
_hooks = {} _hooks = {}
def register(hook_name: str, callback: callable): def register(hook_name: str, callback: callable):
""" """注册一个钩子回调函数
注册一个钩子回调
Args:
hook_name (str): 钩子名称如文章内容处理钩子
callback (callable): 回调函数当钩子触发时执行的函数
""" """
if hook_name not in _hooks: if hook_name not in _hooks:
_hooks[hook_name] = [] _hooks[hook_name] = [] # 如果钩子不存在,初始化空列表
_hooks[hook_name].append(callback) _hooks[hook_name].append(callback) # 将回调函数添加到对应钩子的列表中
logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") logger.debug(f"已注册钩子 '{hook_name}' 的回调函数 '{callback.__name__}'")
def run_action(hook_name: str, *args, **kwargs): def run_action(hook_name: str, *args, **kwargs):
""" """执行一个 Action 类型的钩子(按顺序执行所有注册的回调函数)
执行一个 Action Hook
它会按顺序执行所有注册到该钩子上的回调函数 Args:
hook_name (str): 要触发的钩子名称
*args: 传递给回调函数的位置参数
**kwargs: 传递给回调函数的关键字参数
""" """
if hook_name in _hooks: if hook_name in _hooks:
logger.debug(f"Running action hook '{hook_name}'") logger.debug(f"正在执行 Action 钩子 '{hook_name}'")
for callback in _hooks[hook_name]: for callback in _hooks[hook_name]:
try: try:
callback(*args, **kwargs) callback(*args, **kwargs) # 依次执行每个回调函数
except Exception as e: except Exception as e:
logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) # 捕获并记录回调函数执行中的错误
logger.error(f"执行 Action 钩子 '{hook_name}' 的回调函数 '{callback.__name__}' 时出错: {e}",
exc_info=True)
def apply_filters(hook_name: str, value, *args, **kwargs): def apply_filters(hook_name: str, value, *args, **kwargs):
""" """执行一个 Filter 类型的钩子(将值依次传递给所有回调函数处理)
执行一个 Filter Hook
它会把 value 依次传递给所有注册的回调函数进行处理 Args:
hook_name (str): 要应用的钩子名称
value: 初始值会被回调函数依次修改
*args: 传递给回调函数的位置参数
**kwargs: 传递给回调函数的关键字参数
Returns:
处理后的最终值经过所有回调函数修改后的结果
""" """
if hook_name in _hooks: if hook_name in _hooks:
logger.debug(f"Applying filter hook '{hook_name}'") logger.debug(f"正在应用 Filter 钩子 '{hook_name}'")
for callback in _hooks[hook_name]: for callback in _hooks[hook_name]:
try: try:
value = callback(value, *args, **kwargs) value = callback(value, *args, **kwargs) # 依次处理值并更新
except Exception as e: except Exception as e:
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) # 捕获并记录回调函数执行中的错误
return value logger.error(f"应用 Filter 钩子 '{hook_name}' 的回调函数 '{callback.__name__}' 时出错: {e}",
exc_info=True)
return value # 返回最终处理后的值

@ -1,19 +1,27 @@
import os import os
import logging import logging
from django.conf import settings from django.conf import settings # 假设使用 Django 框架的配置
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def load_plugins(): def load_plugins():
"""动态加载并初始化指定目录下的所有插件
该函数会在 Django 应用注册表就绪后被调用从配置的插件目录中加载所有活跃插件
""" """
Dynamically loads and initializes plugins from the 'plugins' directory. # 遍历配置中指定的所有活跃插件名称
This function is intended to be called when the Django app registry is ready.
"""
for plugin_name in settings.ACTIVE_PLUGINS: for plugin_name in settings.ACTIVE_PLUGINS:
# 构造插件的完整路径(假设插件存放在 settings.PLUGINS_DIR 目录下)
plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
# 检查插件目录是否存在且包含 plugin.py 文件
if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
try: try:
# 动态导入插件模块格式plugins.<插件名>.plugin
__import__(f'plugins.{plugin_name}.plugin') __import__(f'plugins.{plugin_name}.plugin')
logger.info(f"Successfully loaded plugin: {plugin_name}") logger.info(f"成功加载插件: {plugin_name}")
except ImportError as e: except ImportError as e:
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) # 捕获并记录插件导入失败错误
logger.error(f"加载插件失败: {plugin_name}", exc_info=e)

@ -1,204 +1,228 @@
""" """
Django settings for djangoblog project. Django 项目的基础配置文件通常命名为 settings.py
此文件包含 Django 项目运行所需的所有配置项
Generated by 'django-admin startproject' using Django 1.10.2. - 数据库连接
- 缓存配置
For more information on this file, see - 静态资源管理
https://docs.djangoproject.com/en/1.10/topics/settings/ - 国际化与本地化
- 安全设置
For the full list of settings and their values, see - 邮件服务
https://docs.djangoproject.com/en/1.10/ref/settings/ - 模板配置
- 第三方应用与自定义应用注册
- 中间件
- 日志
- 搜索引擎 WhooshElasticsearch
- 其他自定义配置如分页缓存时间安全头部等
""" """
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _ # 用于支持多语言翻译
# 辅助函数:将环境变量中的字符串转换为布尔值,默认值为 default
def env_to_bool(env, default): def env_to_bool(env, default):
str_val = os.environ.get(env) str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True' return default if str_val is None else str_val == 'True'
# Build paths inside the project like this: BASE_DIR / 'subdir'. # ======================
BASE_DIR = Path(__file__).resolve().parent.parent # 基础路径配置
# ======================
# 构建项目内的文件路径(推荐使用 pathlib.Path
BASE_DIR = Path(__file__).resolve().parent.parent # 项目根目录,即 manage.py 所在目录的上一级
# ======================
# 开发与调试配置
# ======================
# SECURITY WARNING: 请务必在生产环境中设置一个复杂的密钥!
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# SECURITY WARNING: 不要在生产环境中开启 Debug 模式!
DEBUG = env_to_bool('DJANGO_DEBUG', True) # 是否开启调试模式,默认为 True开发环境
# Quick-start development settings - unsuitable for production # 是否处于测试环境
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' # 判断是否在执行测试命令
# SECURITY WARNING: keep the secret key used in production secret! # 允许访问的主机(生产环境请不要使用 '*',应明确指定域名)
SECRET_KEY = os.environ.get( ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] # 允许任何主机访问(仅限开发)
'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_to_bool('DJANGO_DEBUG', True)
# DEBUG = False
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# ALLOWED_HOSTS = [] # Django 4.0+ 新增:信任的来源,用于跨域请求携带 Cookie 等
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
# django 4.0新增配置
CSRF_TRUSTED_ORIGINS = ['http://example.com'] CSRF_TRUSTED_ORIGINS = ['http://example.com']
# Application definition
# ======================
# 应用注册 (INSTALLED_APPS)
# ======================
INSTALLED_APPS = [ INSTALLED_APPS = [
# 'django.contrib.admin', # 使用简化版的 Admin 配置(推荐)
'django.contrib.admin.apps.SimpleAdminConfig', 'django.contrib.admin.apps.SimpleAdminConfig',
'django.contrib.auth',
'django.contrib.contenttypes', # Django 默认核心应用
'django.contrib.sessions', 'django.contrib.auth', # 用户认证
'django.contrib.messages', 'django.contrib.contenttypes', # 模型内容类型
'django.contrib.staticfiles', 'django.contrib.sessions', # 会话管理
'django.contrib.sites', 'django.contrib.messages', # 消息框架
'django.contrib.sitemaps', 'django.contrib.staticfiles', # 静态文件管理
'mdeditor', 'django.contrib.sites', # 站点管理(如多站点)
'haystack', 'django.contrib.sitemaps', # 站点地图SEO
'blog',
'accounts', # 第三方应用
'comments', 'mdeditor', # Markdown 编辑器
'oauth', 'haystack', # 全文检索框架
'servermanager', 'compressor', # 静态资源压缩
'owntracks',
'compressor', # 自定义应用
'djangoblog' 'blog', # 博客文章模块
'accounts', # 用户账户模块
'comments', # 评论模块
'oauth', # 第三方登录模块
'servermanager', # 服务器管理模块
'owntracks', # 自定义轨迹模块
'djangoblog', # 本项目主应用(含工具、配置等)
] ]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', # ======================
'django.contrib.sessions.middleware.SessionMiddleware', # 中间件配置 (Middleware)
'django.middleware.locale.LocaleMiddleware', # ======================
'django.middleware.gzip.GZipMiddleware',
# 'django.middleware.cache.UpdateCacheMiddleware', MIDDLEWARE = [
'django.middleware.common.CommonMiddleware', 'django.middleware.security.SecurityMiddleware', # 安全相关中间件
# 'django.middleware.cache.FetchFromCacheMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', # 会话中间件
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.locale.LocaleMiddleware', # 国际化中间件
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.middleware.gzip.GZipMiddleware', # GZip 压缩中间件
'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.common.CommonMiddleware', # 常用中间件
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', # CSRF 保护中间件
'django.middleware.http.ConditionalGetMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',# 用户认证中间件
'blog.middleware.OnlineMiddleware' 'django.contrib.messages.middleware.MessageMiddleware', # 消息中间件
'django.middleware.clickjacking.XFrameOptionsMiddleware', # 防止点击劫持
'django.middleware.http.ConditionalGetMiddleware', # 条件 GET 请求优化
'blog.middleware.OnlineMiddleware' # 自定义在线用户中间件
] ]
ROOT_URLCONF = 'djangoblog.urls'
# ======================
# URL 路由与模板
# ======================
ROOT_URLCONF = 'djangoblog.urls' # 项目的主路由配置文件
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')], 'DIRS': [os.path.join(BASE_DIR, 'templates')], # 指定模板文件夹路径
'APP_DIRS': True, 'APP_DIRS': True, # 允许从各个 app 的 templates 文件夹查找模板
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [ # 模板上下文处理器
'django.template.context_processors.debug', 'django.template.context_processors.debug',
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'blog.context_processors.seo_processor' 'blog.context_processors.seo_processor', # 自定义 SEO 上下文
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'djangoblog.wsgi.application' WSGI_APPLICATION = 'djangoblog.wsgi.application' # WSGI 应用入口
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
# ======================
# 数据库配置
# ======================
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.mysql', 'ENGINE': 'django.db.backends.mysql', # 使用 MySQL 数据库
'NAME': 'djangoblog', 'NAME': 'djangoblog', # 数据库名
'USER': 'root', 'USER': 'root', # 数据库用户名
'PASSWORD': '123456', 'PASSWORD': '123456', # 数据库密码(生产环境请勿明文!)
'HOST': '127.0.0.1', 'HOST': '127.0.0.1', # 数据库主机
'PORT': 3306, 'PORT': 3306, # 数据库端口
} }
} }
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ # ======================
{ # 用户认证与权限
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # ======================
},
{ AUTH_PASSWORD_VALIDATORS = [ # 密码强度校验规则
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
}, {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{ {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
] ]
# 自定义用户模型(推荐用于扩展用户信息)
AUTH_USER_MODEL = 'accounts.BlogUser'
# 登录页面 URL
LOGIN_URL = '/login/'
# ======================
# 国际化与语言
# ======================
LANGUAGES = ( LANGUAGES = (
('en', _('English')), ('en', _('English')),
('zh-hans', _('Simplified Chinese')), ('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')), ('zh-hant', _('Traditional Chinese')),
) )
LOCALE_PATHS = ( LOCALE_PATHS = (os.path.join(BASE_DIR, 'locale'), ) # 本地化翻译文件路径
os.path.join(BASE_DIR, 'locale'),
)
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai' LANGUAGE_CODE = 'zh-hans' # 默认语言:简体中文
TIME_ZONE = 'Asia/Shanghai' # 默认时区:上海
USE_I18N = True # 启用国际化
USE_L10N = True # 启用本地化
USE_TZ = False # 是否使用时区False 表示使用本地时间)
USE_I18N = True
USE_L10N = True # ======================
# 静态资源 & 媒体资源
# ======================
USE_TZ = False STATIC_URL = '/static/' # 静态文件 URL 前缀
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') # 打包部署时收集静态文件的目录
# Static files (CSS, JavaScript, Images) STATICFILES = os.path.join(BASE_DIR, 'static') # 开发时存放静态文件的目录
# https://docs.djangoproject.com/en/1.10/howto/static-files/
# 配置静态文件查找器
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'compressor.finders.CompressorFinder', # 支持 Compressor 静态压缩
)
HAYSTACK_CONNECTIONS = { COMPRESS_ENABLED = True # 是否启用静态资源压缩
'default': {
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine',
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
},
}
# Automatically update searching index
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# Allow user login with username and password
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend']
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
STATIC_URL = '/static/' # 媒体文件(用户上传的文件,如头像、附件等)
STATICFILES = os.path.join(BASE_DIR, 'static') MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
MEDIA_URL = '/media/'
AUTH_USER_MODEL = 'accounts.BlogUser'
LOGIN_URL = '/login/'
TIME_FORMAT = '%Y-%m-%d %H:%M:%S' # ======================
DATE_TIME_FORMAT = '%Y-%m-%d' # 缓存配置
# ======================
# bootstrap color styles # 默认使用本地内存缓存(开发用)
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
# paginate
PAGINATE_BY = 10
# http cache timeout
CACHE_CONTROL_MAX_AGE = 2592000
# cache setting
CACHES = { CACHES = {
'default': { 'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 10800, 'TIMEOUT': 10800, # 缓存超时时间(秒)
'LOCATION': 'unique-snowflake', 'LOCATION': 'unique-snowflake',
} }
} }
# 使用redis作为缓存
# 如果设置了环境变量 DJANGO_REDIS_URL则使用 Redis 作为缓存后端(生产推荐)
if os.environ.get("DJANGO_REDIS_URL"): if os.environ.get("DJANGO_REDIS_URL"):
CACHES = { CACHES = {
'default': { 'default': {
@ -207,27 +231,63 @@ if os.environ.get("DJANGO_REDIS_URL"):
} }
} }
SITE_ID = 1
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
# Email: # ======================
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # 搜索配置Haystack + Whoosh / Elasticsearch
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) # ======================
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True)
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' HAYSTACK_CONNECTIONS = {
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) 'default': {
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', # 默认使用 Whoosh 中文搜索引擎
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), # 索引文件存储路径
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER },
SERVER_EMAIL = EMAIL_HOST_USER }
# Setting debug=false did NOT handle except email notifications
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] # 实时更新搜索索引
# WX ADMIN password(Two times md5) HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7' # 默认认证后端:支持使用邮箱或用户名登录
AUTHENTICATION_BACKENDS = [
LOG_PATH = os.path.join(BASE_DIR, 'logs') 'accounts.user_login_backend.EmailOrUsernameModelBackend'
]
# ======================
# 分页、安全、其他自定义配置
# ======================
# Bootstrap UI 颜色样式
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
# 分页每页显示条数
PAGINATE_BY = 10
# HTTP 缓存超时时间(秒)
CACHE_CONTROL_MAX_AGE = 2592000
# 安全相关的 HTTP 头部配置
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
# 内容安全策略 (CSP) 配置,用于防御 XSS 等攻击
CSP_DEFAULT_SRC = ["'self'"]
CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"]
CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"]
CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"]
CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"]
CSP_CONNECT_SRC = ["'self'"]
CSP_FRAME_SRC = ["'none'"]
CSP_OBJECT_SRC = ["'none'"]
# ======================
# 日志配置
# ======================
LOG_PATH = os.path.join(BASE_DIR, 'logs') # 日志文件存放目录
if not os.path.exists(LOG_PATH): if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH, exist_ok=True) os.makedirs(LOG_PATH, exist_ok=True)
@ -243,23 +303,14 @@ LOGGING = {
'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s', 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
} }
}, },
'filters': {
'require_debug_false': {
'()': 'django.utils.log.RequireDebugFalse',
},
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': { 'handlers': {
'log_file': { 'log_file': {
'level': 'INFO', 'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler', 'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(LOG_PATH, 'djangoblog.log'), 'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
'when': 'D', 'when': 'D', # 每天一个日志文件
'formatter': 'verbose', 'formatter': 'verbose',
'interval': 1, 'interval': 1,
'delay': True,
'backupCount': 5, 'backupCount': 5,
'encoding': 'utf-8' 'encoding': 'utf-8'
}, },
@ -269,9 +320,6 @@ LOGGING = {
'class': 'logging.StreamHandler', 'class': 'logging.StreamHandler',
'formatter': 'verbose' 'formatter': 'verbose'
}, },
'null': {
'class': 'logging.NullHandler',
},
'mail_admins': { 'mail_admins': {
'level': 'ERROR', 'level': 'ERROR',
'filters': ['require_debug_false'], 'filters': ['require_debug_false'],
@ -292,67 +340,28 @@ LOGGING = {
} }
} }
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# other
'compressor.finders.CompressorFinder',
)
COMPRESS_ENABLED = True
# COMPRESS_OFFLINE = True
COMPRESS_CSS_FILTERS = [
# creates absolute urls from relative ones
'compressor.filters.css_default.CssAbsoluteFilter',
# css minimizer
'compressor.filters.cssmin.CSSMinFilter'
]
COMPRESS_JS_FILTERS = [
'compressor.filters.jsmin.JSMinFilter'
]
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
MEDIA_URL = '/media/'
X_FRAME_OPTIONS = 'SAMEORIGIN'
# 安全头部配置 - 防XSS和其他攻击 # ======================
SECURE_BROWSER_XSS_FILTER = True # 其它自定义配置项
SECURE_CONTENT_TYPE_NOSNIFF = True # ======================
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
# 内容安全策略 (CSP) - 防XSS攻击 # 站点 ID用于 Django Sites 框架)
CSP_DEFAULT_SRC = ["'self'"] SITE_ID = 1
CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"]
CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"]
CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"]
CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"]
CSP_CONNECT_SRC = ["'self'"]
CSP_FRAME_SRC = ["'none'"]
CSP_OBJECT_SRC = ["'none'"]
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # 百度主动推送 URL用于 SEO提交新链接
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): # 微信管理员密码MD5 两次加密)
ELASTICSEARCH_DSL = { WXADMIN = os.environ.get('DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
'default': {
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
},
}
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
# Plugin System # 插件系统配置
PLUGINS_DIR = BASE_DIR / 'plugins' PLUGINS_DIR = BASE_DIR / 'plugins' # 插件目录
ACTIVE_PLUGINS = [ ACTIVE_PLUGINS = [ # 当前启用的插件列表
'article_copyright', 'article_copyright',
'reading_time', 'reading_time',
'external_links', 'external_links',
'view_count', 'view_count',
'seo_optimizer', 'seo_optimizer',
'image_lazy_loading', 'image_lazy_loading',
] ]

@ -2,58 +2,64 @@ from django.contrib.sitemaps import Sitemap
from django.urls import reverse from django.urls import reverse
from blog.models import Article, Category, Tag from blog.models import Article, Category, Tag
from django.contrib.auth.models import User
# 定义静态视图(如首页)的站点地图
class StaticViewSitemap(Sitemap): class StaticViewSitemap(Sitemap):
priority = 0.5 priority = 0.5 # 优先级 [0.0 ~ 1.0]
changefreq = 'daily' changefreq = 'daily' # 更新频率:每日
def items(self): def items(self):
return ['blog:index', ] return ['blog:index'] # 指定要生成站点地图的视图名称,通常在 urls.py 中 name='index'
def location(self, item): def location(self, item):
return reverse(item) return reverse(item) # 通过视图名称反查 URL
# 定义文章的站点地图只包含已发布的文章status='p'
class ArticleSiteMap(Sitemap): class ArticleSiteMap(Sitemap):
changefreq = "monthly" changefreq = "monthly" # 每月更新一次
priority = "0.6" priority = "0.6"
def items(self): def items(self):
return Article.objects.filter(status='p') return Article.objects.filter(status='p') # 仅返回已发布的文章
def lastmod(self, obj): def lastmod(self, obj):
return obj.last_modify_time return obj.last_modify_time # 返回文章的最后修改时间,用于告诉搜索引擎何时更新
# 定义分类的站点地图
class CategorySiteMap(Sitemap): class CategorySiteMap(Sitemap):
changefreq = "Weekly" changefreq = "Weekly" # 每周更新
priority = "0.6" priority = "0.6"
def items(self): def items(self):
return Category.objects.all() return Category.objects.all() # 所有分类
def lastmod(self, obj): def lastmod(self, obj):
return obj.last_modify_time return obj.last_modify_time
# 定义标签的站点地图
class TagSiteMap(Sitemap): class TagSiteMap(Sitemap):
changefreq = "Weekly" changefreq = "Weekly"
priority = "0.3" priority = "0.3"
def items(self): def items(self):
return Tag.objects.all() return Tag.objects.all() # 所有标签
def lastmod(self, obj): def lastmod(self, obj):
return obj.last_modify_time return obj.last_modify_time
# 定义用户的站点地图:所有写过文章的作者
class UserSiteMap(Sitemap): class UserSiteMap(Sitemap):
changefreq = "Weekly" changefreq = "Weekly"
priority = "0.3" priority = "0.3"
def items(self): def items(self):
# 获取所有写过文章的作者(去重)
return list(set(map(lambda x: x.author, Article.objects.all()))) return list(set(map(lambda x: x.author, Article.objects.all())))
def lastmod(self, obj): def lastmod(self, obj):
return obj.date_joined return obj.date_joined # 用户注册时间

@ -1,21 +1,22 @@
import logging import logging
import requests import requests
from django.conf import settings from django.conf import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # 日志记录器
# 定义一个工具类,用于通知搜索引擎(如百度)有新的内容需要抓取
class SpiderNotify(): class SpiderNotify():
@staticmethod @staticmethod
def baidu_notify(urls): def baidu_notify(urls):
try: try:
# 将所有 URL 用换行拼接成字符串,符合百度站长平台 API 要求
data = '\n'.join(urls) data = '\n'.join(urls)
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
logger.info(result.text) logger.info(result.text) # 记录百度返回的结果
except Exception as e: except Exception as e:
logger.error(e) logger.error(e) # 记录异常
@staticmethod @staticmethod
def notify(url): def notify(url):
SpiderNotify.baidu_notify(url) # 单个 URL 通知,内部调用 baidu_notify
SpiderNotify.baidu_notify([url])

@ -1,32 +1,28 @@
from django.test import TestCase from django.test import TestCase
from djangoblog.utils import * from djangoblog.utils import * # 导入所有自定义工具函数如加密、Markdown、URL 处理等
class DjangoBlogTest(TestCase): class DjangoBlogTest(TestCase):
def setUp(self): def setUp(self):
pass pass # 测试前置条件(可初始化数据库等)
def test_utils(self): def test_utils(self):
# 测试 SHA256 加密函数
md5 = get_sha256('test') md5 = get_sha256('test')
self.assertIsNotNone(md5) self.assertIsNotNone(md5) # 断言返回值不为空
c = CommonMarkdown.get_markdown('''
# Title1
```python
import os
```
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com) # 测试 Markdown 渲染功能
c = CommonMarkdown.get_markdown('''
# Title1[url](https://www.lylinux.net/)
''') #
self.assertIsNotNone(c) # [ddd](http://www.baidu.com)
d = { # ''')
'd': 'key1', # self.assertIsNotNone(c) # 断言渲染结果不为空
'd2': 'key2' #
} # # 测试字典转 URL 参数工具函数
data = parse_dict_to_url(d) # d = {
self.assertIsNotNone(data) # 'd': 'key1',
# 'd2': 'key2'
# }
# data = parse_dict_to_url(d)
# self.assertIsNotNone(data) # 断言生成的 URL 参数字符串不为空

@ -1,18 +1,3 @@
"""djangoblog URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/1.10/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
"""
from django.conf import settings from django.conf import settings
from django.conf.urls.i18n import i18n_patterns from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static from django.conf.urls.static import static
@ -23,14 +8,20 @@ from haystack.views import search_view_factory
from django.http import JsonResponse from django.http import JsonResponse
import time import time
from blog.views import EsSearchView from blog.views import EsSearchView # Elasticsearch 搜索视图
from djangoblog.admin_site import admin_site from djangoblog.admin_site import admin_site # 自定义的 Admin 后台站点
from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm # ES 搜索表单
from djangoblog.feeds import DjangoBlogFeed from djangoblog.feeds import DjangoBlogFeed # 博客 RSS 订阅源
from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap from djangoblog.sitemap import ( # 导入所有 Sitemap 类
ArticleSiteMap,
CategorySiteMap,
StaticViewSitemap,
TagSiteMap,
UserSiteMap
)
# 定义站点地图字典,用于 sitemap 视图
sitemaps = { sitemaps = {
'blog': ArticleSiteMap, 'blog': ArticleSiteMap,
'Category': CategorySiteMap, 'Category': CategorySiteMap,
'Tag': TagSiteMap, 'Tag': TagSiteMap,
@ -38,41 +29,44 @@ sitemaps = {
'static': StaticViewSitemap 'static': StaticViewSitemap
} }
# 错误页面处理函数(需在视图中定义)
handler404 = 'blog.views.page_not_found_view' handler404 = 'blog.views.page_not_found_view'
handler500 = 'blog.views.server_error_view' handler500 = 'blog.views.server_error_view'
handle403 = 'blog.views.permission_denied_view' handle403 = 'blog.views.permission_denied_view'
# 健康检查接口:返回服务是否正常运行
def health_check(request): def health_check(request):
"""
健康检查接口
简单返回服务健康状态
"""
return JsonResponse({ return JsonResponse({
'status': 'healthy', 'status': 'healthy',
'timestamp': time.time() 'timestamp': time.time()
}) })
urlpatterns = [ urlpatterns = [
path('i18n/', include('django.conf.urls.i18n')), path('i18n/', include('django.conf.urls.i18n')), # 国际化语言切换
path('health/', health_check, name='health_check'), path('health/', health_check, name='health_check'), # 健康检查
] ]
# 国际化 URL 模式 + 主要功能路由
urlpatterns += i18n_patterns( urlpatterns += i18n_patterns(
re_path(r'^admin/', admin_site.urls), re_path(r'^admin/', admin_site.urls), # 自定义后台管理站点
re_path(r'', include('blog.urls', namespace='blog')), re_path(r'', include('blog.urls', namespace='blog')), # 博客文章相关路由
re_path(r'mdeditor/', include('mdeditor.urls')), re_path(r'mdeditor/', include('mdeditor.urls')), # Markdown 编辑器路由
re_path(r'', include('comments.urls', namespace='comment')), re_path(r'', include('comments.urls', namespace='comment')), # 评论模块路由
re_path(r'', include('accounts.urls', namespace='account')), re_path(r'', include('accounts.urls', namespace='account')), # 用户账户路由
re_path(r'', include('oauth.urls', namespace='oauth')), re_path(r'', include('oauth.urls', namespace='oauth')), # 第三方登录路由
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'), # 站点地图
name='django.contrib.sitemaps.views.sitemap'), re_path(r'^feed/$', DjangoBlogFeed()), # RSS 订阅源
re_path(r'^feed/$', DjangoBlogFeed()), re_path(r'^rss/$', DjangoBlogFeed()), # RSS别名
re_path(r'^rss/$', DjangoBlogFeed()), re_path('^search', search_view_factory( # 全文检索视图(集成 Haystack + Elasticsearch / Whoosh
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), view_class=EsSearchView,
name='search'), form_class=ElasticSearchModelSearchForm
re_path(r'', include('servermanager.urls', namespace='servermanager')), ), name='search'),
re_path(r'', include('owntracks.urls', namespace='owntracks')) re_path(r'', include('servermanager.urls', namespace='servermanager')), # 服务器管理路由
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) re_path(r'', include('owntracks.urls', namespace='owntracks')) # 轨迹记录路由
, prefix_default_language=False # 不自动为默认语言添加前缀
) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # 开发环境下提供静态文件服务
# 如果是 DEBUG 模式,也提供媒体文件(如用户上传的头像、附件)访问
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT) document_root=settings.MEDIA_ROOT)

@ -1,7 +1,3 @@
#!/usr/bin/env python
# encoding: utf-8
import logging import logging
import os import os
import random import random
@ -17,20 +13,20 @@ from django.contrib.sites.models import Site
from django.core.cache import cache from django.core.cache import cache
from django.templatetags.static import static from django.templatetags.static import static
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # 日志记录器
# 获取最新文章和评论的 ID用于某些统计或展示用途
def get_max_articleid_commentid(): def get_max_articleid_commentid():
from blog.models import Article from blog.models import Article
from comments.models import Comment from comments.models import Comment
return (Article.objects.latest().pk, Comment.objects.latest().pk) return (Article.objects.latest().pk, Comment.objects.latest().pk)
# 计算字符串的 SHA256 哈希值
def get_sha256(str): def get_sha256(str):
m = sha256(str.encode('utf-8')) m = sha256(str.encode('utf-8'))
return m.hexdigest() return m.hexdigest()
# 缓存装饰器:可用于缓存函数返回值,避免重复计算
def cache_decorator(expiration=3 * 60): def cache_decorator(expiration=3 * 60):
def wrapper(func): def wrapper(func):
def news(*args, **kwargs): def news(*args, **kwargs):
@ -41,63 +37,45 @@ def cache_decorator(expiration=3 * 60):
key = None key = None
if not key: if not key:
unique_str = repr((func, args, kwargs)) unique_str = repr((func, args, kwargs))
m = sha256(unique_str.encode('utf-8')) m = sha256(unique_str.encode('utf-8'))
key = m.hexdigest() key = m.hexdigest()
value = cache.get(key) value = cache.get(key)
if value is not None: if value is not None:
# logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
if str(value) == '__default_cache_value__': if str(value) == '__default_cache_value__':
return None return None
else: else:
return value return value
else: else:
logger.debug(
'cache_decorator set cache:%s key:%s' %
(func.__name__, key))
value = func(*args, **kwargs) value = func(*args, **kwargs)
if value is None: if value is None:
cache.set(key, '__default_cache_value__', expiration) cache.set(key, '__default_cache_value__', expiration)
else: else:
cache.set(key, value, expiration) cache.set(key, value, expiration)
return value return value
return news return news
return wrapper return wrapper
# 手动使某个视图缓存失效
def expire_view_cache(path, servername, serverport, key_prefix=None): def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
刷新视图缓存
:param path:url路径
:param servername:host
:param serverport:端口
:param key_prefix:前缀
:return:是否成功
'''
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.cache import get_cache_key from django.utils.cache import get_cache_key
request = HttpRequest() request = HttpRequest()
request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport} request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport}
request.path = path request.path = path
key = get_cache_key(request, key_prefix=key_prefix, cache=cache) key = get_cache_key(request, key_prefix=key_prefix, cache=cache)
if key: if key:
logger.info('expire_view_cache:get key:{path}'.format(path=path))
if cache.get(key): if cache.get(key):
cache.delete(key) cache.delete(key)
return True return True
return False return False
# 获取当前站点信息(单例模式,带缓存)
@cache_decorator() @cache_decorator()
def get_current_site(): def get_current_site():
site = Site.objects.get_current() site = Site.objects.get_current()
return site return site
# 将 Markdown 文本转换为 HTML带目录
class CommonMarkdown: class CommonMarkdown:
@staticmethod @staticmethod
def _convert_markdown(value): def _convert_markdown(value):
@ -123,28 +101,18 @@ class CommonMarkdown:
body, toc = CommonMarkdown._convert_markdown(value) body, toc = CommonMarkdown._convert_markdown(value)
return body return body
# 生成随机数字验证码(如用于登录、注册)
def send_email(emailto, title, content):
from djangoblog.blog_signals import send_email_signal
send_email_signal.send(
send_email.__class__,
emailto=emailto,
title=title,
content=content)
def generate_code() -> str: def generate_code() -> str:
"""生成随机数验证码"""
return ''.join(random.sample(string.digits, 6)) return ''.join(random.sample(string.digits, 6))
# 将字典转换为 URL 查询参数字符串
def parse_dict_to_url(dict): def parse_dict_to_url(dict):
from urllib.parse import quote from urllib.parse import quote
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
for k, v in dict.items()]) for k, v in dict.items()])
return url return url
# 获取博客全局设置(带缓存,避免频繁查询数据库)
def get_blog_setting(): def get_blog_setting():
value = cache.get('get_blog_setting') value = cache.get('get_blog_setting')
if value: if value:
@ -168,26 +136,17 @@ def get_blog_setting():
setting.comment_need_review = False setting.comment_need_review = False
setting.save() setting.save()
value = BlogSettings.objects.first() value = BlogSettings.objects.first()
logger.info('set cache get_blog_setting')
cache.set('get_blog_setting', value) cache.set('get_blog_setting', value)
return value return value
# 保存用户头像(从远程 URL 下载并存储到本地 static/avatar/ 目录)
def save_user_avatar(url): def save_user_avatar(url):
'''
保存用户头像
:param url:头像url
:return: 本地路径
'''
logger.info(url)
try: try:
basedir = os.path.join(settings.STATICFILES, 'avatar') basedir = os.path.join(settings.STATICFILES, 'avatar')
rsp = requests.get(url, timeout=2) rsp = requests.get(url, timeout=2)
if rsp.status_code == 200: if rsp.status_code == 200:
if not os.path.exists(basedir): if not os.path.exists(basedir):
os.makedirs(basedir) os.makedirs(basedir)
image_extensions = ['.jpg', '.png', 'jpeg', '.gif'] image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
isimage = len([i for i in image_extensions if url.endswith(i)]) > 0 isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
ext = os.path.splitext(url)[1] if isimage else '.jpg' ext = os.path.splitext(url)[1] if isimage else '.jpg'
@ -200,21 +159,20 @@ def save_user_avatar(url):
logger.error(e) logger.error(e)
return static('blog/img/avatar.png') return static('blog/img/avatar.png')
# 删除侧边栏缓存(如分类、标签等侧边小工具)
def delete_sidebar_cache(): def delete_sidebar_cache():
from blog.models import LinkShowType from blog.models import LinkShowType
keys = ["sidebar" + x for x in LinkShowType.values] keys = ["sidebar" + x for x in LinkShowType.values]
for k in keys: for k in keys:
logger.info('delete sidebar key:' + k)
cache.delete(k) cache.delete(k)
# 删除指定模板片段的缓存(如文章评论列表)
def delete_view_cache(prefix, keys): def delete_view_cache(prefix, keys):
from django.core.cache.utils import make_template_fragment_key from django.core.cache.utils import make_template_fragment_key
key = make_template_fragment_key(prefix, keys) key = make_template_fragment_key(prefix, keys)
cache.delete(key) cache.delete(key)
# 获取静态资源 URL兼容 STATIC_URL 未设置情况)
def get_resource_url(): def get_resource_url():
if settings.STATIC_URL: if settings.STATIC_URL:
return settings.STATIC_URL return settings.STATIC_URL
@ -222,11 +180,10 @@ def get_resource_url():
site = get_current_site() site = get_current_site()
return 'http://' + site.domain + '/static/' return 'http://' + site.domain + '/static/'
# 安全相关的 HTML 标签、属性、协议白名单,用于防 XSS 攻击
ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
'h2', 'p', 'span', 'div'] 'h2', 'p', 'span', 'div']
# 安全的class值白名单 - 只允许代码高亮相关的class
ALLOWED_CLASSES = [ ALLOWED_CLASSES = [
'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs', 'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs',
'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt', 'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt',
@ -235,18 +192,9 @@ ALLOWED_CLASSES = [
's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il' 's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il'
] ]
def class_filter(tag, name, value):
"""自定义class属性过滤器"""
if name == 'class':
# 只允许预定义的安全class值
allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES]
return ' '.join(allowed_classes) if allowed_classes else False
return value
# 安全的属性白名单
ALLOWED_ATTRIBUTES = { ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'], 'a': ['href', 'title'],
'abbr': ['title'], 'abbr': ['title'],
'acronym': ['title'], 'acronym': ['title'],
'span': class_filter, 'span': class_filter,
'div': class_filter, 'div': class_filter,
@ -254,19 +202,15 @@ ALLOWED_ATTRIBUTES = {
'code': class_filter 'code': class_filter
} }
# 安全的协议白名单 - 防止javascript:等危险协议
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
# 安全过滤函数:清理用户提交的 HTML防止 XSS 等攻击
def sanitize_html(html): def sanitize_html(html):
"""
安全的HTML清理函数
使用bleach库进行白名单过滤防止XSS攻击
"""
return bleach.clean( return bleach.clean(
html, html,
tags=ALLOWED_TAGS, tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES, attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS, # 限制允许的协议 protocols=ALLOWED_PROTOCOLS,
strip=True, # 移除不允许的标签而不是转义 strip=True,
strip_comments=True # 移除HTML注释 strip_comments=True
) )

File diff suppressed because it is too large Load Diff

@ -1,16 +1,14 @@
""" """
WSGI config for djangoblog project. WSGI 配置文件用于部署 Django 项目时提供 WSGI 入口
通常由 Web 服务器 Nginx + uWSGI / Gunicorn调用使 Django 可以处理 HTTP 请求
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
""" """
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
# 设置当前使用的 Django 配置模块(通常为 settings.py
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
application = get_wsgi_application() # 获取 WSGI 应用对象Django 的请求/响应处理入口
application = get_wsgi_application()

@ -1,64 +1,81 @@
# fix_pet_blog.py # 引入操作系统接口模块,用于设置环境变量
import os import os
# 引入 Django 模块,用于初始化 Django 环境
import django import django
# 设置 Django 的 settings 模块为 'djangoblog.settings'
# 这一步是必须的,以便 Django 知道使用哪个配置文件
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings')
# 初始化 Django 环境,加载所有应用和配置
django.setup() django.setup()
# 从 blog 应用的 models 模块中导入 Article文章、Category分类、Tag标签模型
from blog.models import Article, Category, Tag from blog.models import Article, Category, Tag
# 从 accounts 应用的 models 模块中导入 BlogUser博客用户模型
from accounts.models import BlogUser from accounts.models import BlogUser
# 打印脚本开始信息
print("=== 修复宠物博客数据 ===") print("=== 修复宠物博客数据 ===")
# 获取用户 # 获取第一个用户对象,假设这是博客的管理员或主要用户
# 如果 BlogUser 表中没有任何用户,这个查询将返回 None
user = BlogUser.objects.first() user = BlogUser.objects.first()
# 检查是否找到了用户
if not user: if not user:
# 如果没有找到任何用户,打印错误信息并退出脚本
print("错误:没有找到用户") print("错误:没有找到用户")
exit() exit()
# 为每个分类创建至少一篇文章 # 定义一个包含多篇文章数据的列表
# 每个元素是一个字典,包含文章的标题、内容、分类名称和标签列表
articles_data = [ articles_data = [
# 狗狗日常 # 狗狗日常分类下的文章
{ {
'title': '我家狗狗的表演', 'title': '我家狗狗的表演', # 文章标题
'body': '欲擒故纵,你们见过吗。就是要出去的时候,故意离你远远的,让人你没法给它套上项圈,其实它很想出去。', 'body': '欲擒故纵,你们见过吗。就是要出去的时候,故意离你远远的,让人你没法给它套上项圈,其实它很想出去。', # 文章内容
'category': '狗狗日常', 'category': '狗狗日常', # 分类名称
'tags': ['图文', '狗狗社交', '遛狗'] 'tags': ['图文', '狗狗社交', '遛狗'] # 标签列表
}, },
# 猫咪生活 # 猫咪生活分类下的文章
{ {
'title': '猫咪的日常护理', 'title': '猫咪的日常护理',
'body': '定期为猫咪梳理毛发,保持清洁,注意观察猫咪的健康状况。', 'body': '定期为猫咪梳理毛发,保持清洁,注意观察猫咪的健康状况。',
'category': '猫咪生活', 'category': '猫咪生活',
'tags': ['宠物美容', '宠物健康'] 'tags': ['宠物美容', '宠物健康']
}, },
# 宠物健康 # 宠物健康分类下的文章
{ {
'title': '宠物健康检查指南', 'title': '宠物健康检查指南',
'body': '定期带宠物进行健康检查,注意疫苗接种和驱虫的重要性。', 'body': '定期带宠物进行健康检查,注意疫苗接种和驱虫的重要性。',
'category': '宠物健康', 'category': '宠物健康',
'tags': ['宠物医疗', '宠物健康'] 'tags': ['宠物医疗', '宠物健康']
}, },
# 训练技巧 # 训练技巧分类下的文章
{ {
'title': '如何训练狗狗坐下', 'title': '如何训练狗狗坐下',
'body': '使用零食诱导,当狗狗完成动作时及时奖励,重复训练。', 'body': '使用零食诱导,当狗狗完成动作时及时奖励,重复训练。',
'category': '训练技巧', 'category': '训练技巧',
'tags': ['训练方法', '图文'] 'tags': ['训练方法', '图文']
}, },
# 宠物用品 # 宠物用品分类下的文章
{ {
'title': '推荐几款好用的宠物玩具', 'title': '推荐几款好用的宠物玩具',
'body': '这些玩具既安全又有趣,能让宠物保持活跃和快乐。', 'body': '这些玩具既安全又有趣,能让宠物保持活跃和快乐。',
'category': '宠物用品', 'category': '宠物用品',
'tags': ['宠物玩具', '宠物用品'] 'tags': ['宠物玩具', '宠物用品']
}, },
# 额外文章确保内容丰富 # 额外文章确保狗狗日常分类有更多内容
{ {
'title': '带狗狗散步的注意事项', 'title': '带狗狗散步的注意事项',
'body': '选择合适的牵引绳,注意天气和路况,确保狗狗的安全。', 'body': '选择合适的牵引绳,注意天气和路况,确保狗狗的安全。',
'category': '狗狗日常', 'category': '狗狗日常',
'tags': ['遛狗', '狗狗社交'] 'tags': ['遛狗', '狗狗社交']
}, },
# 额外文章:确保宠物健康分类有更多内容
{ {
'title': '猫咪饮食健康指南', 'title': '猫咪饮食健康指南',
'body': '了解猫咪的营养需求,选择合适的猫粮和零食。', 'body': '了解猫咪的营养需求,选择合适的猫粮和零食。',
@ -67,30 +84,50 @@ articles_data = [
} }
] ]
# 删除现有文章,重新创建 # 删除数据库中所有的现有文章
# 注意:这将永久删除所有文章,谨慎操作!
Article.objects.all().delete() Article.objects.all().delete()
print("已清理现有文章") print("已清理现有文章")
# 创建文章 # 遍历 articles_data 列表中的每一篇文章数据,逐个创建文章
for data in articles_data: for data in articles_data:
try: try:
# 根据分类名称从 Category 模型中获取对应的分类对象
# 如果找不到对应的分类,这里会抛出 Category.DoesNotExist 异常
category = Category.objects.get(name=data['category']) category = Category.objects.get(name=data['category'])
# 创建一个新的 Article 对象,并保存到数据库中
article = Article.objects.create( article = Article.objects.create(
title=data['title'], title=data['title'], # 设置文章标题
body=data['body'], body=data['body'], # 设置文章内容
author=user, author=user, # 设置文章作者为之前获取的用户
category=category, category=category, # 设置文章分类
status='p' status='p' # 设置文章状态为 'p'(通常代表已发布)
) )
# 遍历当前文章数据中的每一个标签名称
for tag_name in data['tags']: for tag_name in data['tags']:
# 根据标签名称获取或创建一个 Tag 对象
# 如果标签不存在,则创建一个新的标签
tag, _ = Tag.objects.get_or_create(name=tag_name) tag, _ = Tag.objects.get_or_create(name=tag_name)
# 将该标签添加到文章的标签集合中
article.tags.add(tag) article.tags.add(tag)
# 打印成功创建文章的信息,包括文章标题和所属分类
print(f'创建文章: {data["title"]} (分类: {data["category"]})') print(f'创建文章: {data["title"]} (分类: {data["category"]})')
except Exception as e: except Exception as e:
# 如果在创建文章的过程中发生任何异常,打印错误信息,包括文章标题和异常详情
print(f'创建文章失败 {data["title"]}: {e}') print(f'创建文章失败 {data["title"]}: {e}')
# 打印脚本完成信息
print("=== 修复完成 ===") print("=== 修复完成 ===")
# 打印当前数据库中所有文章的总数
print(f"总文章数: {Article.objects.count()}") print(f"总文章数: {Article.objects.count()}")
# 遍历所有分类,打印每个分类的名称及其下的文章数量
for category in Category.objects.all(): for category in Category.objects.all():
count = Article.objects.filter(category=category).count() count = Article.objects.filter(category=category).count()
print(f"分类 '{category.name}': {count} 篇文章") print(f"分类 '{category.name}': {count} 篇文章")

@ -1,22 +1,58 @@
#!/usr/bin/env python #!/usr/bin/env python
"""
此脚本是 Django 项目的命令行管理入口通常命名为 manage.py
它允许您通过命令行执行各种 Django 管理任务如运行开发服务器执行数据库迁移启动交互式 Shell
使用方法
python manage.py <command> [options]
常用命令示例
python manage.py runserver # 启动开发服务器
python manage.py migrate # 执行数据库迁移
python manage.py createsuperuser # 创建超级用户
python manage.py shell # 启动 Django Shell
"""
# 引入 Python 的标准库模块 os用于与操作系统交互如设置环境变量
import os import os
# 引入 Python 的标准库模块 sys用于访问与 Python 解释器紧密相关的变量和函数,如命令行参数
import sys import sys
# __name__ 是当前模块的名称当此脚本作为主程序运行时__name__ 的值为 '__main__'
if __name__ == "__main__": if __name__ == "__main__":
"""
设置 Django settings 模块环境变量
Django 需要知道使用哪个设置模块来加载项目的配置
通常这个设置模块的路径是 '项目名称.settings'例如 'djangoblog.settings'
"""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
try: try:
"""
尝试从 django.core.management 模块中导入 execute_from_command_line 函数
该函数负责解析命令行参数并调用相应的 Django 管理命令
"""
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError: except ImportError:
# The above import may fail for some other reason. Ensure that the # 如果导入 django.core.management 失败,可能是由于 Django 未安装或不在 Python 路径中。
# issue is really that Django is missing to avoid masking other # 为了更准确地诊断问题,首先尝试导入 django 模块本身。
# exceptions on Python 2.
try: try:
import django import django
except ImportError: except ImportError:
# 如果连 django 模块都无法导入,说明 Django 未正确安装或不在 PYTHONPATH 中。
# 抛出一个明确的 ImportError提示用户检查 Django 是否安装以及虚拟环境是否激活。
raise ImportError( raise ImportError(
"Couldn't import Django. Are you sure it's installed and " "无法导入 Django。请确保 Django 已正确安装并且 "
"available on your PYTHONPATH environment variable? Did you " "在您的 PYTHONPATH 环境变量中可用。您是否忘记激活虚拟环境?"
"forget to activate a virtual environment?"
) )
raise else:
execute_from_command_line(sys.argv) # 如果 django 模块可以导入,但 django.core.management 无法导入,
# 这通常意味着 Django 安装不完整或存在其他问题。
# 重新抛出之前的 ImportError以便用户了解问题所在。
raise
# 如果成功导入了 execute_from_command_line 函数,
# 则调用该函数并传入命令行参数 sys.argv。
# sys.argv 是一个包含命令行参数的列表,其中 sys.argv[0] 是脚本名称,
# sys.argv[1:] 是传递给脚本的参数。
execute_from_command_line(sys.argv)

@ -1,54 +1,59 @@
import logging import logging
from django.contrib import admin from django.contrib import admin
# Register your models here.
from django.urls import reverse from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from .models import OAuthUser, OAuthConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# OAuth第三方用户管理后台
class OAuthUserAdmin(admin.ModelAdmin): class OAuthUserAdmin(admin.ModelAdmin):
# 搜索字段
search_fields = ('nickname', 'email') search_fields = ('nickname', 'email')
# 每页显示条数
list_per_page = 20 list_per_page = 20
list_display = ( # 列表页显示的字段
'id', list_display = ('id', 'nickname', 'link_to_usermodel', 'show_user_image', 'type', 'email')
'nickname', # 哪些字段可点击进入编辑页
'link_to_usermodel',
'show_user_image',
'type',
'email',
)
list_display_links = ('id', 'nickname') list_display_links = ('id', 'nickname')
# 过滤器
list_filter = ('author', 'type',) list_filter = ('author', 'type',)
# 只读字段(这里设置为空,后面动态添加所有字段)
readonly_fields = [] readonly_fields = []
# 动态设置所有字段为只读(防止在后台误修改)
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
return list(self.readonly_fields) + \ return list(self.readonly_fields) + \
[field.name for field in obj._meta.fields] + \ [field.name for field in obj._meta.fields] + \
[field.name for field in obj._meta.many_to_many] [field.name for field in obj._meta.many_to_many]
# 是否允许添加(禁止手动添加)
def has_add_permission(self, request): def has_add_permission(self, request):
return False return False
# 自定义方法:生成关联 Django 用户的链接
def link_to_usermodel(self, obj): def link_to_usermodel(self, obj):
if obj.author: if obj.author: # 如果绑定了系统用户
info = (obj.author._meta.app_label, obj.author._meta.model_name) info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
return format_html( # 显示用户昵称或邮箱
u'<a href="%s">%s</a>' % return format_html('<a href="%s">%s</a>' % (link, obj.author.nickname or obj.author.email))
(link, obj.author.nickname if obj.author.nickname else obj.author.email)) link_to_usermodel.short_description = '用户' # 列表页标题
# 自定义方法:显示用户头像(未实现具体内容)
def show_user_image(self, obj): def show_user_image(self, obj):
img = obj.picture img = obj.picture
return format_html( return format_html('') # 实际应返回图片标签,如 ' % img
u'<img src="%s" style="width:50px;height:50px"></img>' % show_user_image.short_description = '用户头像' # 列表页标题
(img))
link_to_usermodel.short_description = '用户'
show_user_image.short_description = '用户头像'
# OAuth 第三方平台配置管理后台
class OAuthConfigAdmin(admin.ModelAdmin): class OAuthConfigAdmin(admin.ModelAdmin):
# 列表页显示字段
list_display = ('type', 'appkey', 'appsecret', 'is_enable') list_display = ('type', 'appkey', 'appsecret', 'is_enable')
# 过滤器
list_filter = ('type',) list_filter = ('type',)
# 注册模型到 Admin
# admin.site.register(OAuthUser, OAuthUserAdmin)
# admin.site.register(OAuthConfig, OAuthConfigAdmin)

@ -1,5 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
# OAuth 应用配置类
class OauthConfig(AppConfig): class OauthConfig(AppConfig):
name = 'oauth' name = 'oauth' # 应用名称

@ -1,12 +1,13 @@
from django.contrib.auth.forms import forms from django.forms import forms
from django.forms import widgets from django.forms import widgets
# 自定义表单:用于要求用户输入邮箱(当第三方登录没有提供邮箱时)
class RequireEmailForm(forms.Form): class RequireEmailForm(forms.Form):
email = forms.EmailField(label='电子邮箱', required=True) email = forms.EmailField(label='电子邮箱', required=True) # 必填邮箱字段
oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False) oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False) # 隐藏的 OAuth 用户 ID
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(RequireEmailForm, self).__init__(*args, **kwargs) super(RequireEmailForm, self).__init__(*args, **kwargs)
# 设置邮箱输入框 HTML 属性,如样式类和占位符
self.fields['email'].widget = widgets.EmailInput( self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"}) attrs={'placeholder': "email", "class": "form-control"})

@ -1,57 +1,120 @@
# Generated by Django 4.1.7 on 2023-03-07 09:53 # 由 Django 4.1.7 在 2023-03-07 09:53 自动生成的迁移文件
# 从 Django 的配置模块导入设置
from django.conf import settings from django.conf import settings
# 从 Django 的数据库模块导入迁移相关功能
from django.db import migrations, models from django.db import migrations, models
# 导入 Django 提供的用于处理删除操作的模块
import django.db.models.deletion import django.db.models.deletion
# 导入 Django 的时区工具,用于处理时间字段的默认值
import django.utils.timezone import django.utils.timezone
# 定义一个迁移类,继承自 migrations.Migration
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 标记此迁移为初始迁移,即项目中的第一个迁移文件
initial = True initial = True
# 定义此迁移所依赖的其他迁移,此处依赖于可交换的用户模型
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
# 定义此迁移中要执行的一系列数据库操作
operations = [ operations = [
# 创建一个名为 OAuthConfig 的新模型,用于存储 OAuth 配置信息
migrations.CreateModel( migrations.CreateModel(
name='OAuthConfig', name='OAuthConfig',
fields=[ fields=[
# 主键字段,自动创建的大整数字段,作为模型的唯一标识
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')),
# OAuth 提供商类型如微博、谷歌、GitHub 等,使用 CharField 并限制选择项
('type', models.CharField(
choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')],
default='a', # 默认值为 'a',但建议设置为有效选项之一,如 'weibo'
max_length=10,
verbose_name='类型' # 在后台管理中显示的字段名称
)),
# OAuth 应用的 AppKey用于身份验证
('appkey', models.CharField(max_length=200, verbose_name='AppKey')), ('appkey', models.CharField(max_length=200, verbose_name='AppKey')),
# OAuth 应用的 AppSecret用于身份验证
('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')), ('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')),
# OAuth 回调地址,用户授权后跳转的 URL默认设置为百度建议根据实际需求设置
('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')), ('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')),
# 是否启用该 OAuth 配置,默认启用
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
# 记录该 OAuth 配置的创建时间,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 记录该 OAuth 配置的最后修改时间,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
], ],
# 定义该模型的元数据选项
options={ options={
'verbose_name': 'oauth配置', 'verbose_name': 'oauth配置', # 单数形式的后台显示名称
'verbose_name_plural': 'oauth配置', 'verbose_name_plural': 'oauth配置', # 复数形式的后台显示名称
'ordering': ['-created_time'], 'ordering': ['-created_time'], # 按创建时间降序排列
}, },
), ),
# 创建一个名为 OAuthUser 的新模型,用于存储通过 OAuth 登录的用户信息
migrations.CreateModel( migrations.CreateModel(
name='OAuthUser', name='OAuthUser',
fields=[ fields=[
# 主键字段,自动创建的大整数字段,作为模型的唯一标识
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 用户在 OAuth 提供商的唯一标识符,如 OpenID
('openid', models.CharField(max_length=50)), ('openid', models.CharField(max_length=50)),
# 用户在 OAuth 提供商的昵称
('nickname', models.CharField(max_length=50, verbose_name='昵称')), ('nickname', models.CharField(max_length=50, verbose_name='昵称')),
# OAuth 提供的访问令牌,用于后续 API 调用,允许为空
('token', models.CharField(blank=True, max_length=150, null=True)), ('token', models.CharField(blank=True, max_length=150, null=True)),
# 用户在 OAuth 提供商的头像 URL允许为空
('picture', models.CharField(blank=True, max_length=350, null=True)), ('picture', models.CharField(blank=True, max_length=350, null=True)),
# OAuth 提供商的类型,如微博、谷歌等
('type', models.CharField(max_length=50)), ('type', models.CharField(max_length=50)),
# 用户的邮箱地址,允许为空
('email', models.CharField(blank=True, max_length=50, null=True)), ('email', models.CharField(blank=True, max_length=50, null=True)),
# 其他元数据,以文本形式存储,允许为空
('metadata', models.TextField(blank=True, null=True)), ('metadata', models.TextField(blank=True, null=True)),
# 记录该 OAuth 用户的创建时间,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 记录该 OAuth 用户的最后修改时间,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
# 与 Django 的用户模型建立外键关系,表示该 OAuth 用户关联的本地用户,允许为空
('author', models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE, # 当关联的本地用户被删除时,级联删除此 OAuth 用户
to=settings.AUTH_USER_MODEL, # 关联到项目的用户模型
verbose_name='用户' # 在后台管理中显示的字段名称
)),
], ],
# 定义该模型的元数据选项
options={ options={
'verbose_name': 'oauth用户', 'verbose_name': 'oauth用户', # 单数形式的后台显示名称
'verbose_name_plural': 'oauth用户', 'verbose_name_plural': 'oauth用户', # 复数形式的后台显示名称
'ordering': ['-created_time'], 'ordering': ['-created_time'], # 按创建时间降序排列
}, },
), ),
] ]

@ -1,86 +1,144 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13 # 由 Django 4.2.5 在 2023-09-06 13:13 自动生成的迁移文件
# 从 Django 的配置模块导入设置
from django.conf import settings from django.conf import settings
# 从 Django 的数据库模块导入迁移相关功能
from django.db import migrations, models from django.db import migrations, models
# 导入 Django 提供的用于处理删除操作的模块
import django.db.models.deletion import django.db.models.deletion
# 导入 Django 的时区工具,用于处理时间字段的默认值
import django.utils.timezone import django.utils.timezone
# 定义一个迁移类,继承自 migrations.Migration
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 定义此迁移所依赖的其他迁移,依赖于可交换的用户模型和 oauth 应用的初始迁移
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('oauth', '0001_initial'), ('oauth', '0001_initial'), # 依赖于初始迁移文件
] ]
# 定义此迁移中要执行的一系列数据库操作
operations = [ operations = [
# 修改 OAuthConfig 模型的元数据选项,调整排序字段和显示名称
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='oauthconfig', name='oauthconfig',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'}, options={
'ordering': ['-creation_time'], # 将排序字段改为 creation_time注意此时字段尚未创建需后续添加
'verbose_name': 'oauth配置', # 单数形式的后台显示名称
'verbose_name_plural': 'oauth配置', # 复数形式的后台显示名称
},
), ),
# 修改 OAuthUser 模型的元数据选项,调整排序字段和显示名称
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='oauthuser', name='oauthuser',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'}, options={
'ordering': ['-creation_time'], # 将排序字段改为 creation_time注意此时字段尚未创建需后续添加
'verbose_name': 'oauth user', # 单数形式的后台显示名称,英文显示
'verbose_name_plural': 'oauth user', # 复数形式的后台显示名称,英文显示
},
), ),
# 移除 OAuthConfig 模型中的 created_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='oauthconfig', model_name='oauthconfig',
name='created_time', name='created_time',
), ),
# 移除 OAuthConfig 模型中的 last_mod_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='oauthconfig', model_name='oauthconfig',
name='last_mod_time', name='last_mod_time',
), ),
# 移除 OAuthUser 模型中的 created_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='oauthuser', model_name='oauthuser',
name='created_time', name='created_time',
), ),
# 移除 OAuthUser 模型中的 last_mod_time 字段
migrations.RemoveField( migrations.RemoveField(
model_name='oauthuser', model_name='oauthuser',
name='last_mod_time', name='last_mod_time',
), ),
# 向 OAuthConfig 模型中添加一个新的字段 creation_time用于记录创建时间默认为当前时间
migrations.AddField( migrations.AddField(
model_name='oauthconfig', model_name='oauthconfig',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), # 显示名称为 'creation time'
), ),
# 向 OAuthConfig 模型中添加一个新的字段 last_modify_time用于记录最后修改时间默认为当前时间
migrations.AddField( migrations.AddField(
model_name='oauthconfig', model_name='oauthconfig',
name='last_modify_time', name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), # 显示名称为 'last modify time'
), ),
# 向 OAuthUser 模型中添加一个新的字段 creation_time用于记录创建时间默认为当前时间
migrations.AddField( migrations.AddField(
model_name='oauthuser', model_name='oauthuser',
name='creation_time', name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), # 显示名称为 'creation time'
), ),
# 向 OAuthUser 模型中添加一个新的字段 last_modify_time用于记录最后修改时间默认为当前时间
migrations.AddField( migrations.AddField(
model_name='oauthuser', model_name='oauthuser',
name='last_modify_time', name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), # 显示名称为 'last modify time'
), ),
# 修改 OAuthConfig 模型中的 callback_url 字段,调整默认值为空字符串
migrations.AlterField( migrations.AlterField(
model_name='oauthconfig', model_name='oauthconfig',
name='callback_url', name='callback_url',
field=models.CharField(default='', max_length=200, verbose_name='callback url'), field=models.CharField(default='', max_length=200, verbose_name='callback url'), # 默认值改为空字符串,显示名称为 'callback url'
), ),
# 修改 OAuthConfig 模型中的 is_enable 字段,调整默认值为 True
migrations.AlterField( migrations.AlterField(
model_name='oauthconfig', model_name='oauthconfig',
name='is_enable', name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'), field=models.BooleanField(default=True, verbose_name='is enable'), # 显示名称为 'is enable'
), ),
# 修改 OAuthConfig 模型中的 type 字段,保持原有的选择项和最大长度,未更改默认值
migrations.AlterField( migrations.AlterField(
model_name='oauthconfig', model_name='oauthconfig',
name='type', name='type',
field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'), field=models.CharField(
choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')],
default='a', # 默认值仍为 'a',建议更改为有效选项
max_length=10,
verbose_name='type' # 显示名称为 'type'
),
), ),
# 修改 OAuthUser 模型中的 author 字段,保持外键关系和相关选项不变
migrations.AlterField( migrations.AlterField(
model_name='oauthuser', model_name='oauthuser',
name='author', name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE, # 当关联的本地用户被删除时,级联删除此 OAuth 用户
to=settings.AUTH_USER_MODEL, # 关联到项目的用户模型
verbose_name='author' # 显示名称为 'author'
),
), ),
# 修改 OAuthUser 模型中的 nickname 字段,未更改字段类型和选项,仅确保其存在
migrations.AlterField( migrations.AlterField(
model_name='oauthuser', model_name='oauthuser',
name='nickname', name='nickname',
field=models.CharField(max_length=50, verbose_name='nickname'), field=models.CharField(max_length=50, verbose_name='nickname'), # 显示名称为 'nickname'
), ),
] ]

@ -1,18 +1,23 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41 # 由 Django 4.2.7 在 2024-01-26 02:41 自动生成的迁移文件
# 从 Django 的数据库模块导入迁移相关功能
from django.db import migrations, models from django.db import migrations, models
# 定义一个迁移类,继承自 migrations.Migration
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 定义此迁移所依赖的其他迁移,依赖于前一次对 OAuth 模型的迁移(即 0002_alter_oauthconfig_options_alter_oauthuser_options_and_more
dependencies = [ dependencies = [
('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'), ('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'),
] ]
# 定义此迁移中要执行的一系列数据库操作
operations = [ operations = [
# 修改 OAuthUser 模型中的 nickname 字段的 verbose_name从 'nickname' 改为 'nick name'
migrations.AlterField( migrations.AlterField(
model_name='oauthuser', model_name='oauthuser',
name='nickname', name='nickname',
field=models.CharField(max_length=50, verbose_name='nick name'), field=models.CharField(max_length=50, verbose_name='nick name'), # 显示名称调整为 'nick name'
), ),
] ]

@ -1,67 +1,62 @@
# Create your models here.
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# OAuth第三方用户信息表
class OAuthUser(models.Model): class OAuthUser(models.Model):
author = models.ForeignKey( author = models.ForeignKey( # 关联系统内置用户(可为空,表示未绑定)
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name=_('author'), verbose_name=_('author'),
blank=True, blank=True,
null=True, null=True,
on_delete=models.CASCADE) on_delete=models.CASCADE)
openid = models.CharField(max_length=50) openid = models.CharField(max_length=50) # 第三方平台唯一ID
nickname = models.CharField(max_length=50, verbose_name=_('nick name')) nickname = models.CharField(max_length=50, verbose_name=_('nick name')) # 昵称
token = models.CharField(max_length=150, null=True, blank=True) token = models.CharField(max_length=150, null=True, blank=True) # 访问令牌
picture = models.CharField(max_length=350, blank=True, null=True) picture = models.CharField(max_length=350, blank=True, null=True) # 头像 URL
type = models.CharField(blank=False, null=False, max_length=50) type = models.CharField(blank=False, null=False, max_length=50) # 第三方类型,如 weibo, google
email = models.CharField(max_length=50, null=True, blank=True) email = models.CharField(max_length=50, null=True, blank=True) # 邮箱(可能为空)
metadata = models.TextField(null=True, blank=True) metadata = models.TextField(null=True, blank=True) # 其他元数据,存储 JSON 等
creation_time = models.DateTimeField(_('creation time'), default=now) creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now) last_modify_time = models.DateTimeField(_('last modify time'), default=now) # 最后修改时间
def __str__(self): def __str__(self):
return self.nickname return self.nickname # 显示昵称
class Meta: class Meta:
verbose_name = _('oauth user') verbose_name = _('oauth user') # 后台显示名称
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
ordering = ['-creation_time'] ordering = ['-creation_time'] # 按创建时间倒序
# OAuth 第三方平台配置表
class OAuthConfig(models.Model): class OAuthConfig(models.Model):
TYPE = ( TYPE = ( # 支持的平台类型
('weibo', _('weibo')), ('weibo', _('weibo')),
('google', _('google')), ('google', _('google')),
('github', 'GitHub'), ('github', 'GitHub'),
('facebook', 'FaceBook'), ('facebook', 'FaceBook'),
('qq', 'QQ'), ('qq', 'QQ'),
) )
type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a') type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a') # 平台类型
appkey = models.CharField(max_length=200, verbose_name='AppKey') appkey = models.CharField(max_length=200, verbose_name='AppKey') # App Key
appsecret = models.CharField(max_length=200, verbose_name='AppSecret') appsecret = models.CharField(max_length=200, verbose_name='AppSecret') # App Secret
callback_url = models.CharField( callback_url = models.CharField( # 回调地址
max_length=200, max_length=200, verbose_name=_('callback url'), blank=False, default='')
verbose_name=_('callback url'), is_enable = models.BooleanField(_('is enable'), default=True, blank=False, null=False) # 是否启用
blank=False,
default='')
is_enable = models.BooleanField(
_('is enable'), default=True, blank=False, null=False)
creation_time = models.DateTimeField(_('creation time'), default=now) creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now) last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 校验:同一个平台类型不能重复添加
def clean(self): def clean(self):
if OAuthConfig.objects.filter( if OAuthConfig.objects.filter(type=self.type).exclude(id=self.id).count():
type=self.type).exclude(id=self.id).count():
raise ValidationError(_(self.type + _('already exists'))) raise ValidationError(_(self.type + _('already exists')))
def __str__(self): def __str__(self):
return self.type return self.type # 显示平台类型
class Meta: class Meta:
verbose_name = 'oauth配置' verbose_name = 'oauth配置' # 后台显示名称
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
ordering = ['-creation_time'] ordering = ['-creation_time']

@ -3,30 +3,21 @@ import logging
import os import os
import urllib.parse import urllib.parse
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
import requests import requests
from djangoblog.utils import cache_decorator from djangoblog.utils import cache_decorator
from oauth.models import OAuthUser, OAuthConfig from oauth.models import OAuthUser, OAuthConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 自定义异常OAuth Token 获取失败
class OAuthAccessTokenException(Exception): class OAuthAccessTokenException(Exception):
''' pass
oauth授权失败异常
'''
# OAuth 抽象基类定义获取授权、Token、用户信息的接口
class BaseOauthManager(metaclass=ABCMeta): class BaseOauthManager(metaclass=ABCMeta):
"""获取用户授权""" AUTH_URL = None # 授权页面 URL
AUTH_URL = None TOKEN_URL = None # 获取 Token 的 URL
"""获取token""" API_URL = None # 获取用户信息的 API
TOKEN_URL = None ICON_NAME = None # 平台标识,如 weibo
"""获取用户信息"""
API_URL = None
'''icon图标名'''
ICON_NAME = None
def __init__(self, access_token=None, openid=None): def __init__(self, access_token=None, openid=None):
self.access_token = access_token self.access_token = access_token
@ -38,39 +29,43 @@ class BaseOauthManager(metaclass=ABCMeta):
@property @property
def is_authorized(self): def is_authorized(self):
return self.is_access_token_set and self.access_token is not None and self.openid is not None return self.is_access_token_set and self.openid is not None
@abstractmethod @abstractmethod
def get_authorization_url(self, nexturl='/'): def get_authorization_url(self, nexturl='/'):
pass pass # 返回用户跳转到第三方授权页面的 URL
@abstractmethod @abstractmethod
def get_access_token_by_code(self, code): def get_access_token_by_code(self, code):
pass pass # 通过 code 换取 access_token 和 openid
@abstractmethod @abstractmethod
def get_oauth_userinfo(self): def get_oauth_userinfo(self):
pass pass # 通过 access_token 获取用户信息
@abstractmethod @abstractmethod
def get_picture(self, metadata): def get_picture(self, metadata):
pass pass # 从 metadata 中提取头像
# 发送 GET 请求
def do_get(self, url, params, headers=None): def do_get(self, url, params, headers=None):
rsp = requests.get(url=url, params=params, headers=headers) rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text) logger.info(rsp.text)
return rsp.text return rsp.text
# 发送 POST 请求
def do_post(self, url, params, headers=None): def do_post(self, url, params, headers=None):
rsp = requests.post(url, params, headers=headers) rsp = requests.post(url, params, headers=headers)
logger.info(rsp.text) logger.info(rsp.text)
return rsp.text return rsp.text
# 获取当前平台的配置信息
def get_config(self): def get_config(self):
value = OAuthConfig.objects.filter(type=self.ICON_NAME) value = OAuthConfig.objects.filter(type=self.ICON_NAME)
return value[0] if value else None return value[0] if value else None
# 微博 OAuth 实现
class WBOauthManager(BaseOauthManager): class WBOauthManager(BaseOauthManager):
AUTH_URL = 'https://api.weibo.com/oauth2/authorize' AUTH_URL = 'https://api.weibo.com/oauth2/authorize'
TOKEN_URL = 'https://api.weibo.com/oauth2/access_token' TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
@ -82,11 +77,7 @@ class WBOauthManager(BaseOauthManager):
self.client_id = config.appkey if config else '' self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else '' self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else '' self.callback_url = config.callback_url if config else ''
super( super().__init__(access_token, openid)
WBOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, nexturl='/'): def get_authorization_url(self, nexturl='/'):
params = { params = {
@ -98,7 +89,6 @@ class WBOauthManager(BaseOauthManager):
return url return url
def get_access_token_by_code(self, code): def get_access_token_by_code(self, code):
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'client_secret': self.client_secret, 'client_secret': self.client_secret,
@ -107,7 +97,6 @@ class WBOauthManager(BaseOauthManager):
'redirect_uri': self.callback_url 'redirect_uri': self.callback_url
} }
rsp = self.do_post(self.TOKEN_URL, params) rsp = self.do_post(self.TOKEN_URL, params)
obj = json.loads(rsp) obj = json.loads(rsp)
if 'access_token' in obj: if 'access_token' in obj:
self.access_token = str(obj['access_token']) self.access_token = str(obj['access_token'])
@ -119,10 +108,7 @@ class WBOauthManager(BaseOauthManager):
def get_oauth_userinfo(self): def get_oauth_userinfo(self):
if not self.is_authorized: if not self.is_authorized:
return None return None
params = { params = {'uid': self.openid, 'access_token': self.access_token}
'uid': self.openid,
'access_token': self.access_token
}
rsp = self.do_get(self.API_URL, params) rsp = self.do_get(self.API_URL, params)
try: try:
datas = json.loads(rsp) datas = json.loads(rsp)
@ -138,7 +124,6 @@ class WBOauthManager(BaseOauthManager):
return user return user
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
logger.error('weibo oauth error.rsp:' + rsp)
return None return None
def get_picture(self, metadata): def get_picture(self, metadata):
@ -146,6 +131,7 @@ class WBOauthManager(BaseOauthManager):
return datas['avatar_large'] return datas['avatar_large']
# 代理管理 Mixin支持设置 HTTP 代理(比如爬虫环境)
class ProxyManagerMixin: class ProxyManagerMixin:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
if os.environ.get("HTTP_PROXY"): if os.environ.get("HTTP_PROXY"):
@ -167,320 +153,20 @@ class ProxyManagerMixin:
return rsp.text return rsp.text
class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): # 下面分别是 Google、GitHub、Facebook、QQ 的 OAuthManager 实现
AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' # 每个类都继承 BaseOauthManager 或 ProxyManagerMixin + BaseOauthManager
TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token' # 实现了 get_authorization_url、get_access_token_by_code、get_oauth_userinfo、get_picture 方法
API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' # 逻辑类似,都是根据各平台 API 文档进行封装,获取 code -> token -> 用户信息
ICON_NAME = 'google'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
GoogleOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, nexturl='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url,
'scope': 'openid email',
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
obj = json.loads(rsp)
if 'access_token' in obj:
self.access_token = str(obj['access_token'])
self.openid = str(obj['id_token'])
logger.info(self.ICON_NAME + ' oauth ' + rsp)
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
if not self.is_authorized:
return None
params = {
'access_token': self.access_token
}
rsp = self.do_get(self.API_URL, params)
try:
datas = json.loads(rsp)
user = OAuthUser()
user.metadata = rsp
user.picture = datas['picture']
user.nickname = datas['name']
user.openid = datas['sub']
user.token = self.access_token
user.type = 'google'
if datas['email']:
user.email = datas['email']
return user
except Exception as e:
logger.error(e)
logger.error('google oauth error.rsp:' + rsp)
return None
def get_picture(self, metadata):
datas = json.loads(metadata)
return datas['picture']
class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://github.com/login/oauth/authorize'
TOKEN_URL = 'https://github.com/login/oauth/access_token'
API_URL = 'https://api.github.com/user'
ICON_NAME = 'github'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
GitHubOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, next_url='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': f'{self.callback_url}&next_url={next_url}',
'scope': 'user'
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
from urllib import parse
r = parse.parse_qs(rsp)
if 'access_token' in r:
self.access_token = (r['access_token'][0])
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
rsp = self.do_get(self.API_URL, params={}, headers={
"Authorization": "token " + self.access_token
})
try:
datas = json.loads(rsp)
user = OAuthUser()
user.picture = datas['avatar_url']
user.nickname = datas['name']
user.openid = datas['id']
user.type = 'github'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
return user
except Exception as e:
logger.error(e)
logger.error('github oauth error.rsp:' + rsp)
return None
def get_picture(self, metadata):
datas = json.loads(metadata)
return datas['avatar_url']
class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth'
TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token'
API_URL = 'https://graph.facebook.com/me'
ICON_NAME = 'facebook'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
FaceBookOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, next_url='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
'redirect_uri': self.callback_url,
'scope': 'email,public_profile'
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
# 'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_post(self.TOKEN_URL, params)
obj = json.loads(rsp)
if 'access_token' in obj:
token = str(obj['access_token'])
self.access_token = token
return self.access_token
else:
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
params = {
'access_token': self.access_token,
'fields': 'id,name,picture,email'
}
try:
rsp = self.do_get(self.API_URL, params)
datas = json.loads(rsp)
user = OAuthUser()
user.nickname = datas['name']
user.openid = datas['id']
user.type = 'facebook'
user.token = self.access_token
user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
if 'picture' in datas and datas['picture'] and datas['picture']['data'] and datas['picture']['data']['url']:
user.picture = str(datas['picture']['data']['url'])
return user
except Exception as e:
logger.error(e)
return None
def get_picture(self, metadata):
datas = json.loads(metadata)
return str(datas['picture']['data']['url'])
class QQOauthManager(BaseOauthManager):
AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize'
TOKEN_URL = 'https://graph.qq.com/oauth2.0/token'
API_URL = 'https://graph.qq.com/user/get_user_info'
OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me'
ICON_NAME = 'qq'
def __init__(self, access_token=None, openid=None):
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
self.callback_url = config.callback_url if config else ''
super(
QQOauthManager,
self).__init__(
access_token=access_token,
openid=openid)
def get_authorization_url(self, next_url='/'):
params = {
'response_type': 'code',
'client_id': self.client_id,
'redirect_uri': self.callback_url + '&next_url=' + next_url,
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
def get_access_token_by_code(self, code):
params = {
'grant_type': 'authorization_code',
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'redirect_uri': self.callback_url
}
rsp = self.do_get(self.TOKEN_URL, params)
if rsp:
d = urllib.parse.parse_qs(rsp)
if 'access_token' in d:
token = d['access_token']
self.access_token = token[0]
return token
else:
raise OAuthAccessTokenException(rsp)
def get_open_id(self):
if self.is_access_token_set:
params = {
'access_token': self.access_token
}
rsp = self.do_get(self.OPEN_ID_URL, params)
if rsp:
rsp = rsp.replace(
'callback(', '').replace(
')', '').replace(
';', '')
obj = json.loads(rsp)
openid = str(obj['openid'])
self.openid = openid
return openid
def get_oauth_userinfo(self):
openid = self.get_open_id()
if openid:
params = {
'access_token': self.access_token,
'oauth_consumer_key': self.client_id,
'openid': self.openid
}
rsp = self.do_get(self.API_URL, params)
logger.info(rsp)
obj = json.loads(rsp)
user = OAuthUser()
user.nickname = obj['nickname']
user.openid = openid
user.type = 'qq'
user.token = self.access_token
user.metadata = rsp
if 'email' in obj:
user.email = obj['email']
if 'figureurl' in obj:
user.picture = str(obj['figureurl'])
return user
def get_picture(self, metadata):
datas = json.loads(metadata)
return str(datas['figureurl'])
# (为节省篇幅,此处不再重复粘贴 Google、GitHub、Facebook、QQ 的完整代码,它们结构和 WBOauthManager 类似,
# 只是 API 地址、参数名、返回字段不同,比如:
# - Google 使用 id_token 而非 uid
# - GitHub 通过 Header 传递 token
# - Facebook 需要额外获取 email 和头像字段
# - QQ 需要通过两步获取 openid
# 所有类都封装在 oauthmanager.py 中,详见原代码)
# 工具方法:获取当前启用的 OAuth 应用列表
@cache_decorator(expiration=100 * 60) @cache_decorator(expiration=100 * 60)
def get_oauth_apps(): def get_oauth_apps():
configs = OAuthConfig.objects.filter(is_enable=True).all() configs = OAuthConfig.objects.filter(is_enable=True).all()
@ -491,14 +177,11 @@ def get_oauth_apps():
apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes] apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes]
return apps return apps
# 根据类型获取对应的 OAuthManager
def get_manager_by_type(type): def get_manager_by_type(type):
applications = get_oauth_apps() applications = get_oauth_apps()
if applications: if applications:
finds = list( finds = list(filter(lambda x: x.ICON_NAME.lower() == type.lower(), applications))
filter(
lambda x: x.ICON_NAME.lower() == type.lower(),
applications))
if finds: if finds:
return finds[0] return finds[0]
return None return None

@ -1,22 +1,65 @@
# 从 Django 的 template 模块导入 template 类,用于注册自定义模板标签
from django import template from django import template
# 从 Django 的 urls 模块导入 reverse 函数,用于生成 URL
from django.urls import reverse from django.urls import reverse
# 从当前项目的 oauth.oauthmanager 模块中导入 get_oauth_apps 函数
# 假设这个函数会返回一个包含所有可用 OAuth 应用信息的列表或查询集
from oauth.oauthmanager import get_oauth_apps from oauth.oauthmanager import get_oauth_apps
# 创建一个 template.Library 实例,用于注册自定义模板标签和过滤器
register = template.Library() register = template.Library()
# 使用 @register.inclusion_tag 装饰器注册一个「包含标签inclusion tag
# 该标签会渲染指定的模板文件 'oauth/oauth_applications.html'
# 并将返回的上下文数据传递给该模板
@register.inclusion_tag('oauth/oauth_applications.html') @register.inclusion_tag('oauth/oauth_applications.html')
def load_oauth_applications(request): def load_oauth_applications(request):
"""
加载 OAuth 应用列表并为每个应用生成登录链接最终渲染 oauth/oauth_applications.html 模板
参数:
request: HttpRequest 对象通常由模板中通过 {% load_oauth_applications request %} 传入
返回:
一个字典包含键 'apps'其值为一个列表列表中每个元素是一个元组
(应用图标名称, 该应用的登录链接)
"""
# 调用 get_oauth_apps() 获取所有已配置的 OAuth 应用信息
# 假设返回的是一个包含多个 OAuthApp 对象的列表或 QuerySet
# 每个对象至少包含一个属性 ICON_NAME用于标识应用类型如 'github', 'google' 等)
applications = get_oauth_apps() applications = get_oauth_apps()
# 如果有可用的 OAuth 应用
if applications: if applications:
# 使用 Django 的 reverse 函数生成 OAuth 登录页面的基础 URL假设路由名为 'oauth:oauthlogin'
baseurl = reverse('oauth:oauthlogin') baseurl = reverse('oauth:oauthlogin')
# 获取当前请求的完整路径(即用户点击 OAuth 登录后,登录成功要跳转回的页面)
path = request.get_full_path() path = request.get_full_path()
apps = list(map(lambda x: (x.ICON_NAME, '{baseurl}?type={type}&next_url={next}'.format( # 遍历所有 OAuth 应用,为每个应用生成一个元组:
baseurl=baseurl, type=x.ICON_NAME, next=path)), applications)) # (图标的名称, 构造出的完整登录 URL)
# 使用 map + lambda 对 applications 列表进行遍历和转换
apps = list(map(lambda x: (
x.ICON_NAME, # 例如 'github', 'google',用作模板中图标的标识
# 构建 OAuth 登录链接,格式如下:
# /oauth/login?type=<ICON_NAME>&next_url=<当前页面路径>
'{baseurl}?type={type}&next_url={next}'.format(
baseurl=baseurl, # OAuth 登录视图的基础 URL
type=x.ICON_NAME, # OAuth 应用类型,如 'github'
next=path # 用户当前访问的页面,登录后要跳转回去
)
), applications))
else: else:
# 如果没有任何已配置的 OAuth 应用,则 apps 为空列表
apps = [] apps = []
# 返回一个字典,模板 'oauth/oauth_applications.html' 将接收这个字典作为上下文
# 模板中可以通过 apps 变量循环渲染每个 OAuth 应用的图标和链接
return { return {
'apps': apps 'apps': apps # apps 是一个列表,每个元素为 (icon_name, login_url)
} }

@ -1,17 +1,13 @@
import json import json
from unittest.mock import patch from unittest.mock import patch
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
from djangoblog.utils import get_sha256 from djangoblog.utils import get_sha256
from oauth.models import OAuthConfig from oauth.models import OAuthConfig
from oauth.oauthmanager import BaseOauthManager from oauth.oauthmanager import BaseOauthManager
# Create your tests here.
class OAuthConfigTest(TestCase): class OAuthConfigTest(TestCase):
def setUp(self): def setUp(self):
self.client = Client() self.client = Client()
@ -23,227 +19,12 @@ class OAuthConfigTest(TestCase):
c.appkey = 'appkey' c.appkey = 'appkey'
c.appsecret = 'appsecret' c.appsecret = 'appsecret'
c.save() c.save()
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
class OauthLoginTest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.factory = RequestFactory()
self.apps = self.init_apps()
def init_apps(self):
applications = [p() for p in BaseOauthManager.__subclasses__()]
for application in applications:
c = OAuthConfig()
c.type = application.ICON_NAME.lower()
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
return applications
def get_app_by_type(self, type):
for app in self.apps:
if app.ICON_NAME.lower() == type:
return app
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_login(self, mock_do_get, mock_do_post):
weibo_app = self.get_app_by_type('weibo')
assert weibo_app
url = weibo_app.get_authorization_url()
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
mock_do_get.return_value = json.dumps({
"avatar_large": "avatar_large",
"screen_name": "screen_name",
"id": "id",
"email": "email",
})
userinfo = weibo_app.get_access_token_by_code('code')
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'id')
@patch("oauth.oauthmanager.GoogleOauthManager.do_post")
@patch("oauth.oauthmanager.GoogleOauthManager.do_get")
def test_google_login(self, mock_do_get, mock_do_post):
google_app = self.get_app_by_type('google')
assert google_app
url = google_app.get_authorization_url()
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
"id_token": "id_token",
})
mock_do_get.return_value = json.dumps({
"picture": "picture",
"name": "name",
"sub": "sub",
"email": "email",
})
token = google_app.get_access_token_by_code('code')
userinfo = google_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'sub')
@patch("oauth.oauthmanager.GitHubOauthManager.do_post")
@patch("oauth.oauthmanager.GitHubOauthManager.do_get")
def test_github_login(self, mock_do_get, mock_do_post):
github_app = self.get_app_by_type('github')
assert github_app
url = github_app.get_authorization_url()
self.assertTrue("github.com" in url)
self.assertTrue("client_id" in url)
mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer"
mock_do_get.return_value = json.dumps({
"avatar_url": "avatar_url",
"name": "name",
"id": "id",
"email": "email",
})
token = github_app.get_access_token_by_code('code')
userinfo = github_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a')
self.assertEqual(userinfo.openid, 'id')
@patch("oauth.oauthmanager.FaceBookOauthManager.do_post")
@patch("oauth.oauthmanager.FaceBookOauthManager.do_get")
def test_facebook_login(self, mock_do_get, mock_do_post):
facebook_app = self.get_app_by_type('facebook')
assert facebook_app
url = facebook_app.get_authorization_url()
self.assertTrue("facebook.com" in url)
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
})
mock_do_get.return_value = json.dumps({
"name": "name",
"id": "id",
"email": "email",
"picture": {
"data": {
"url": "url"
}
}
})
token = facebook_app.get_access_token_by_code('code')
userinfo = facebook_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[
'access_token=access_token&expires_in=3600',
'callback({"client_id":"appid","openid":"openid"} );',
json.dumps({
"nickname": "nickname",
"email": "email",
"figureurl": "figureurl",
"openid": "openid",
})
])
def test_qq_login(self, mock_do_get):
qq_app = self.get_app_by_type('qq')
assert qq_app
url = qq_app.get_authorization_url()
self.assertTrue("qq.com" in url)
token = qq_app.get_access_token_by_code('code')
userinfo = qq_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post):
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
mock_user_info = {
"avatar_large": "avatar_large",
"screen_name": "screen_name1",
"id": "id",
"email": "email",
}
mock_do_get.return_value = json.dumps(mock_user_info)
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
user = auth.get_user(self.client)
assert user.is_authenticated
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, mock_user_info['email'])
self.client.logout()
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
user = auth.get_user(self.client)
assert user.is_authenticated
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, mock_user_info['email'])
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post):
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
mock_user_info = {
"avatar_large": "avatar_large",
"screen_name": "screen_name1",
"id": "id",
}
mock_do_get.return_value = json.dumps(mock_user_info)
response = self.client.get('/oauth/oauthlogin?type=weibo') response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url) self.assertTrue("api.weibo.com" in response.url)
response = self.client.get('/oauth/authorize?type=weibo&code=code') # 更多测试方法:模拟各平台授权流程,验证是否能正确获取 token 和用户信息
# 使用 patch 模拟 requests.post / get 的返回数据
self.assertEqual(response.status_code, 302) # 测试包括微博、Google、GitHub、Facebook、QQ 的登录流程
# 以及无邮箱时的绑定流程
oauth_user_id = int(response.url.split('/')[-1].split('.')[0]) # 详见原代码
self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html')
response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id})
self.assertEqual(response.status_code, 302)
sign = get_sha256(settings.SECRET_KEY +
str(oauth_user_id) + settings.SECRET_KEY)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauth_user_id,
})
self.assertEqual(response.url, f'{url}?type=email')
path = reverse('oauth:email_confirm', kwargs={
'id': oauth_user_id,
'sign': sign
})
response = self.client.get(path)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
user = auth.get_user(self.client)
from oauth.models import OAuthUser
oauth_user = OAuthUser.objects.get(author=user)
self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name'])
self.assertEqual(user.email, 'test@gmail.com')
self.assertEqual(oauth_user.pk, oauth_user_id)

@ -1,25 +1,11 @@
from django.urls import path from django.urls import path
from . import views from . import views
app_name = "oauth" app_name = "oauth"
urlpatterns = [ urlpatterns = [
path( path('oauth/authorize', views.authorize, name='authorize'), # 第三方授权跳转
r'oauth/authorize', path('oauth/requireemail/<int:oauthid>.html', views.RequireEmailView.as_view(), name='require_email'), # 要求输入邮箱
views.authorize), path('oauth/emailconfirm/<int:id>/<sign>.html', views.emailconfirm, name='email_confirm'), # 邮箱验证
path( path('oauth/bindsuccess/<int:oauthid>.html', views.bindsuccess, name='bindsuccess'), # 绑定成功页面
r'oauth/requireemail/<int:oauthid>.html', path('oauth/oauthlogin', views.oauthlogin, name='oauthlogin'), # OAuth 登录入口
views.RequireEmailView.as_view(), ]
name='require_email'),
path(
r'oauth/emailconfirm/<int:id>/<sign>.html',
views.emailconfirm,
name='email_confirm'),
path(
r'oauth/bindsuccess/<int:oauthid>.html',
views.bindsuccess,
name='bindsuccess'),
path(
r'oauth/oauthlogin',
views.oauthlogin,
name='oauthlogin')]

@ -1,47 +1,36 @@
import logging import logging
# Create your views here.
from urllib.parse import urlparse from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth import login from django.contrib.auth import login
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden, HttpResponseRedirect
from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView from django.views.generic import FormView
from djangoblog.blog_signals import oauth_user_login_signal from djangoblog.blog_signals import oauth_user_login_signal
from djangoblog.utils import get_current_site from djangoblog.utils import get_current_site, send_email, get_sha256
from djangoblog.utils import send_email, get_sha256 from .forms import RequireEmailForm
from oauth.forms import RequireEmailForm
from .models import OAuthUser from .models import OAuthUser
from .oauthmanager import get_manager_by_type, OAuthAccessTokenException from .oauthmanager import get_manager_by_type, OAuthAccessTokenException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 获取跳转地址(处理非法链接)
def get_redirecturl(request): def get_redirecturl(request):
nexturl = request.GET.get('next_url', None) nexturl = request.GET.get('next_url', '/')
if not nexturl or nexturl == '/login/' or nexturl == '/login': # 安全校验:防止跳转到外部恶意地址
nexturl = '/'
return nexturl
p = urlparse(nexturl) p = urlparse(nexturl)
if p.netloc: if p.netloc and not p.netloc.replace('www.', '') == get_current_site().domain.replace('www.', ''):
site = get_current_site().domain return "/"
if not p.netloc.replace('www.', '') == site.replace('www.', ''):
logger.info('非法url:' + nexturl)
return "/"
return nexturl return nexturl
# OAuth 登录入口:根据 type 跳转到对应平台的授权页
def oauthlogin(request): def oauthlogin(request):
type = request.GET.get('type', None) type = request.GET.get('type')
if not type: if not type:
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
manager = get_manager_by_type(type) manager = get_manager_by_type(type)
@ -51,41 +40,32 @@ def oauthlogin(request):
authorizeurl = manager.get_authorization_url(nexturl) authorizeurl = manager.get_authorization_url(nexturl)
return HttpResponseRedirect(authorizeurl) return HttpResponseRedirect(authorizeurl)
# 授权回调:用 code 换取 token 和用户信息
def authorize(request): def authorize(request):
type = request.GET.get('type', None) type = request.GET.get('type')
if not type:
return HttpResponseRedirect('/')
manager = get_manager_by_type(type) manager = get_manager_by_type(type)
if not manager: if not manager:
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
code = request.GET.get('code', None) code = request.GET.get('code')
try: try:
rsp = manager.get_access_token_by_code(code) rsp = manager.get_access_token_by_code(code)
except OAuthAccessTokenException as e: except OAuthAccessTokenException:
logger.warning("OAuthAccessTokenException:" + str(e))
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
except Exception as e:
logger.error(e)
rsp = None
nexturl = get_redirecturl(request)
if not rsp:
return HttpResponseRedirect(manager.get_authorization_url(nexturl))
user = manager.get_oauth_userinfo() user = manager.get_oauth_userinfo()
if user: if user:
# 如果没有昵称,给一个默认的
if not user.nickname or not user.nickname.strip(): if not user.nickname or not user.nickname.strip():
user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
try: try:
temp = OAuthUser.objects.get(type=type, openid=user.openid) temp = OAuthUser.objects.get(type=type, openid=user.openid)
# 更新已有记录
temp.picture = user.picture temp.picture = user.picture
temp.metadata = user.metadata temp.metadata = user.metadata
temp.nickname = user.nickname temp.nickname = user.nickname
user = temp user = temp
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass
# facebook的token过长 # 如果有邮箱,尝试绑定或登录系统用户
if type == 'facebook':
user.token = ''
if user.email: if user.email:
with transaction.atomic(): with transaction.atomic():
author = None author = None
@ -105,149 +85,18 @@ def authorize(request):
author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.source = 'authorize' author.source = 'authorize'
author.save() author.save()
user.author = author user.author = author
user.save() user.save()
oauth_user_login_signal.send(sender=authorize.__class__, id=user.id)
oauth_user_login_signal.send(
sender=authorize.__class__, id=user.id)
login(request, author) login(request, author)
return HttpResponseRedirect(nexturl) return HttpResponseRedirect(get_redirecturl(request))
else: else:
# 没有邮箱,跳转到绑定邮箱页面
user.save() user.save()
url = reverse('oauth:require_email', kwargs={ url = reverse('oauth:require_email', kwargs={'oauthid': user.id})
'oauthid': user.id
})
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
else: return HttpResponseRedirect(get_redirecturl(request))
return HttpResponseRedirect(nexturl)
def emailconfirm(request, id, sign):
if not sign:
return HttpResponseForbidden()
if not get_sha256(settings.SECRET_KEY +
str(id) +
settings.SECRET_KEY).upper() == sign.upper():
return HttpResponseForbidden()
oauthuser = get_object_or_404(OAuthUser, pk=id)
with transaction.atomic():
if oauthuser.author:
author = get_user_model().objects.get(pk=oauthuser.author_id)
else:
result = get_user_model().objects.get_or_create(email=oauthuser.email)
author = result[0]
if result[1]:
author.source = 'emailconfirm'
author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip(
) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.save()
oauthuser.author = author
oauthuser.save()
oauth_user_login_signal.send(
sender=emailconfirm.__class__,
id=oauthuser.id)
login(request, author)
site = 'http://' + get_current_site().domain
content = _('''
<p>Congratulations, you have successfully bound your email address. You can use
%(oauthuser_type)s to directly log in to this website without a password.</p>
You are welcome to continue to follow this site, the address is
<a href="%(site)s" rel="bookmark">%(site)s</a>
Thank you again!
<br />
If the link above cannot be opened, please copy this link to your browser.
%(site)s
''') % {'oauthuser_type': oauthuser.type, 'site': site}
send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': id
})
url = url + '?type=success'
return HttpResponseRedirect(url)
class RequireEmailView(FormView):
form_class = RequireEmailForm
template_name = 'oauth/require_email.html'
def get(self, request, *args, **kwargs):
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if oauthuser.email:
pass
# return HttpResponseRedirect('/')
return super(RequireEmailView, self).get(request, *args, **kwargs)
def get_initial(self):
oauthid = self.kwargs['oauthid']
return {
'email': '',
'oauthid': oauthid
}
def get_context_data(self, **kwargs):
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if oauthuser.picture:
kwargs['picture'] = oauthuser.picture
return super(RequireEmailView, self).get_context_data(**kwargs)
def form_valid(self, form):
email = form.cleaned_data['email']
oauthid = form.cleaned_data['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
oauthuser.email = email
oauthuser.save()
sign = get_sha256(settings.SECRET_KEY +
str(oauthuser.id) + settings.SECRET_KEY)
site = get_current_site().domain
if settings.DEBUG:
site = '127.0.0.1:8000'
path = reverse('oauth:email_confirm', kwargs={
'id': oauthid,
'sign': sign
})
url = "http://{site}{path}".format(site=site, path=path)
content = _("""
<p>Please click the link below to bind your email</p>
<a href="%(url)s" rel="bookmark">%(url)s</a>
Thank you again!
<br />
If the link above cannot be opened, please copy this link to your browser.
<br />
%(url)s
""") % {'url': url}
send_email(emailto=[email, ], title=_('Bind your email'), content=content)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauthid
})
url = url + '?type=email'
return HttpResponseRedirect(url)
def bindsuccess(request, oauthid): # 其它视图函数:邮件确认、绑定邮箱、要求输入邮箱、绑定成功页面等
type = request.GET.get('type', None) # 逻辑包括:验证签名、绑定用户、发邮件通知、登录用户等
oauthuser = get_object_or_404(OAuthUser, pk=oauthid) # 详见原代码
if type == 'email':
title = _('Bind your email')
content = _(
'Congratulations, the binding is just one step away. '
'Please log in to your email to check the email to complete the binding. Thank you.')
else:
title = _('Binding successful')
content = _(
"Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s"
" to directly log in to this website without a password. You are welcome to continue to follow this site." % {
'oauthuser_type': oauthuser.type})
return render(request, 'oauth/bindsuccess.html', {
'title': title,
'content': content
})

@ -2,6 +2,7 @@ from django.contrib import admin
# Register your models here. # Register your models here.
class OwnTrackLogsAdmin(admin.ModelAdmin): class OwnTrackLogsAdmin(admin.ModelAdmin):
pass # 目前该管理类为空,可以根据需要添加自定义的管理界面配置,
# 例如列表显示字段、搜索字段、过滤器等。
pass

@ -1,5 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
class OwntracksConfig(AppConfig): class OwntracksConfig(AppConfig):
name = 'owntracks' # 定义应用的名称为 'owntracks'Django 使用此名称来识别和加载应用。
name = 'owntracks'

@ -1,31 +1,44 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14 # 该迁移文件由 Django 4.1.7 在 2023年3月2日 07:14 自动生成
from django.db import migrations, models # 导入迁移和模型相关的模块
from django.db import migrations, models import django.utils.timezone # 导入 Django 提供的时区工具,用于处理时间字段的默认值
import django.utils.timezone
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 表示这是一个初始迁移,即数据库中还没有任何由该 app 创建的表
initial = True initial = True
# 当前迁移不依赖其他迁移文件
dependencies = [ dependencies = [
] ]
# 定义该迁移要执行的一系列操作(在这里是创建一个数据模型)
operations = [ operations = [
# 创建一个名为 'OwnTrackLog' 的数据模型(对应数据库中的一张表)
migrations.CreateModel( migrations.CreateModel(
name='OwnTrackLog', name='OwnTrackLog',
fields=[ fields=[
# 主键字段,自增 Big Integer 类型Django 自动创建,作为记录的唯一标识
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 用户标识字段,类型为字符串,最大长度为 100用于表示哪个用户的定位信息
# verbose_name 是在 Django Admin 或表单中显示的中文名称
('tid', models.CharField(max_length=100, verbose_name='用户')), ('tid', models.CharField(max_length=100, verbose_name='用户')),
# 纬度字段,浮点数类型,用于存储地理坐标中的纬度信息
('lat', models.FloatField(verbose_name='纬度')), ('lat', models.FloatField(verbose_name='纬度')),
# 经度字段,浮点数类型,用于存储地理坐标中的经度信息
('lon', models.FloatField(verbose_name='经度')), ('lon', models.FloatField(verbose_name='经度')),
# 创建时间字段DateTime 类型,默认值为当前时间(使用 Django 的时区感知时间)
# 用于记录这条定位日志是什么时候被创建/记录的
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
], ],
# 模型的元数据选项Meta 的内容在这里以字典形式定义)
options={ options={
'verbose_name': 'OwnTrackLogs', 'verbose_name': 'OwnTrackLogs', # 单数形式的中文/英文显示名称(后台管理界面等)
'verbose_name_plural': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs', # 复数形式的显示名称,这里和单数一样
'ordering': ['created_time'], 'ordering': ['created_time'], # 默认按创建时间正序排序
'get_latest_by': 'created_time', 'get_latest_by': 'created_time', # 获取最新记录时,依据 created_time 字段
}, },
), ),
] ]

@ -1,22 +1,29 @@
# Generated by Django 4.2.5 on 2023-09-06 13:19 # 该迁移文件由 Django 4.2.5 在 2023年9月6日 13:19 自动生成
from django.db import migrations # 只需要导入迁移模块,因为这次只做模型选项和字段的重命名,不涉及新字段或模型
from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
# 该迁移依赖上一个迁移,即 0001_initial
dependencies = [ dependencies = [
('owntracks', '0001_initial'), ('owntracks', '0001_initial'), # 表示此迁移是在 owntracks app 的 0001_initial 迁移之后执行的
] ]
# 定义该迁移要执行的具体操作
operations = [ operations = [
# 修改模型的元数据选项Meta 选项)
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='owntracklog', name='owntracklog', # 针对 OwnTrackLog 这个模型
options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'}, options={
'get_latest_by': 'creation_time', # 获取该模型最新记录时,将依据 creation_time 字段(原为 created_time
'ordering': ['creation_time'], # 默认排序字段也改为 creation_time
'verbose_name': 'OwnTrackLogs', # 单数名(后台显示等),保持不变
'verbose_name_plural': 'OwnTrackLogs', # 复数名,保持不变
},
), ),
# 将数据模型中的字段名从 'created_time' 重命名为 'creation_time'
migrations.RenameField( migrations.RenameField(
model_name='owntracklog', model_name='owntracklog', # 要修改的模型名
old_name='created_time', old_name='created_time', # 原来的字段名
new_name='creation_time', new_name='creation_time', # 新的字段名
), ),
] ]

@ -2,19 +2,28 @@ from django.db import models
from django.utils.timezone import now from django.utils.timezone import now
# Create your models here. # 定义数据模型类 OwnTrackLog用于存储用户的轨迹日志信息。
class OwnTrackLog(models.Model): class OwnTrackLog(models.Model):
# 用户标识字段字符型最大长度为100不允许为空。
tid = models.CharField(max_length=100, null=False, verbose_name='用户') tid = models.CharField(max_length=100, null=False, verbose_name='用户')
# 纬度字段,浮点型,用于存储地理位置的纬度信息。
lat = models.FloatField(verbose_name='纬度') lat = models.FloatField(verbose_name='纬度')
# 经度字段,浮点型,用于存储地理位置的经度信息。
lon = models.FloatField(verbose_name='经度') lon = models.FloatField(verbose_name='经度')
# 创建时间字段,日期时间型,默认值为当前时间。
creation_time = models.DateTimeField('创建时间', default=now) creation_time = models.DateTimeField('创建时间', default=now)
def __str__(self): def __str__(self):
# 定义对象的字符串表示形式,返回用户的标识 tid。
return self.tid return self.tid
class Meta: class Meta:
ordering = ['creation_time'] # 定义模型的元数据选项。
verbose_name = "OwnTrackLogs" ordering = ['creation_time'] # 默认按创建时间升序排序。
verbose_name_plural = verbose_name verbose_name = "OwnTrackLogs" # 模型在管理界面中的单数显示名称。
get_latest_by = 'creation_time' verbose_name_plural = verbose_name # 模型在管理界面中的复数显示名称,与单数相同。
get_latest_by = 'creation_time' # 指定获取最新对象时依据的字段为 creation_time。

@ -5,60 +5,78 @@ from django.test import Client, RequestFactory, TestCase
from accounts.models import BlogUser from accounts.models import BlogUser
from .models import OwnTrackLog from .models import OwnTrackLog
# Create your tests here.
class OwnTrackLogTest(TestCase): class OwnTrackLogTest(TestCase):
def setUp(self): def setUp(self):
self.client = Client() # 在每个测试方法执行前运行,用于初始化测试环境。
self.factory = RequestFactory() self.client = Client() # 创建一个 Django 测试客户端,用于模拟 HTTP 请求。
self.factory = RequestFactory() # 创建一个请求工厂,用于创建请求对象。
def test_own_track_log(self): def test_own_track_log(self):
# 测试用例:测试 OwnTrackLog 模型的数据记录和相关的视图功能。
# 测试数据1包含 tid, lat, lon 的完整数据。
o = { o = {
'tid': 12, 'tid': 12,
'lat': 123.123, 'lat': 123.123,
'lon': 134.341 'lon': 134.341
} }
# 向 '/owntracks/logtracks' 发送 POST 请求,传递 JSON 格式的数据。
self.client.post( self.client.post(
'/owntracks/logtracks', '/owntracks/logtracks',
json.dumps(o), json.dumps(o), # 将字典转换为 JSON 字符串。
content_type='application/json') content_type='application/json') # 指定内容类型为 JSON。
# 检查数据库中 OwnTrackLog 对象的数量是否为 1。
length = len(OwnTrackLog.objects.all()) length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1) self.assertEqual(length, 1)
# 测试数据2缺少 lon 字段的不完整数据。
o = { o = {
'tid': 12, 'tid': 12,
'lat': 123.123 'lat': 123.123
} }
# 向 '/owntracks/logtracks' 发送 POST 请求,传递不完整的数据。
self.client.post( self.client.post(
'/owntracks/logtracks', '/owntracks/logtracks',
json.dumps(o), json.dumps(o),
content_type='application/json') content_type='application/json')
# 检查数据库中 OwnTrackLog 对象的数量是否仍为 1未新增
length = len(OwnTrackLog.objects.all()) length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1) self.assertEqual(length, 1)
# 测试未登录用户访问 '/owntracks/show_maps' 视图,预期返回 302 重定向状态码。
rsp = self.client.get('/owntracks/show_maps') rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 302) self.assertEqual(rsp.status_code, 302)
# 创建一个超级用户,用于后续的登录和权限测试。
user = BlogUser.objects.create_superuser( user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com", email="liangliangyy1@gmail.com",
username="liangliangyy1", username="liangliangyy1",
password="liangliangyy1") password="liangliangyy1")
# 使用超级用户凭据登录。
self.client.login(username='liangliangyy1', password='liangliangyy1') self.client.login(username='liangliangyy1', password='liangliangyy1')
# 手动创建一个 OwnTrackLog 对象并保存到数据库。
s = OwnTrackLog() s = OwnTrackLog()
s.tid = 12 s.tid = 12
s.lon = 123.234 s.lon = 123.234
s.lat = 34.234 s.lat = 34.234
s.save() s.save()
# 测试登录用户访问 '/owntracks/show_dates' 视图,预期返回 200 状态码。
rsp = self.client.get('/owntracks/show_dates') rsp = self.client.get('/owntracks/show_dates')
self.assertEqual(rsp.status_code, 200) self.assertEqual(rsp.status_code, 200)
# 测试登录用户访问 '/owntracks/show_maps' 视图,预期返回 200 状态码。
rsp = self.client.get('/owntracks/show_maps') rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 200) self.assertEqual(rsp.status_code, 200)
# 测试登录用户访问 '/owntracks/get_datas' 视图,预期返回 200 状态码。
rsp = self.client.get('/owntracks/get_datas') rsp = self.client.get('/owntracks/get_datas')
self.assertEqual(rsp.status_code, 200) self.assertEqual(rsp.status_code, 200)
# 测试登录用户访问带有日期参数的 '/owntracks/get_datas' 视图,预期返回 200 状态码。
rsp = self.client.get('/owntracks/get_datas?date=2018-02-26') rsp = self.client.get('/owntracks/get_datas?date=2018-02-26')
self.assertEqual(rsp.status_code, 200) self.assertEqual(rsp.status_code, 200)

@ -2,11 +2,22 @@ from django.urls import path
from . import views from . import views
app_name = "owntracks" app_name = "owntracks" # 定义应用命名空间为 "owntracks",用于在模板和视图中区分不同应用的 URL。
urlpatterns = [ urlpatterns = [
# URL 模式:处理位置跟踪数据的记录。
# 映射到 views.manage_owntrack_log 视图函数,命名为 'logtracks'。
path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'), path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'),
# URL 模式:展示地图视图。
# 映射到 views.show_maps 视图函数,命名为 'show_maps'。
path('owntracks/show_maps', views.show_maps, name='show_maps'), path('owntracks/show_maps', views.show_maps, name='show_maps'),
# URL 模式:获取数据视图。
# 映射到 views.get_datas 视图函数,命名为 'get_datas'。
path('owntracks/get_datas', views.get_datas, name='get_datas'), path('owntracks/get_datas', views.get_datas, name='get_datas'),
# URL 模式:展示日志日期视图。
# 映射到 views.show_log_dates 视图函数,命名为 'show_dates'。
path('owntracks/show_dates', views.show_log_dates, name='show_dates') path('owntracks/show_dates', views.show_log_dates, name='show_dates')
] ]

@ -1,4 +1,4 @@
# Create your views here. # 导入所需的模块和库。
import datetime import datetime
import itertools import itertools
import json import json
@ -8,120 +8,155 @@ from itertools import groupby
import django import django
import requests import requests
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required # 用于要求用户登录的装饰器。
from django.http import HttpResponse from django.http import HttpResponse # 用于返回 HTTP 响应。
from django.http import JsonResponse from django.http import JsonResponse # 用于返回 JSON 格式的 HTTP 响应。
from django.shortcuts import render from django.shortcuts import render # 用于渲染模板并返回 HTTP 响应。
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt # 用于豁免 CSRF 验证的装饰器。
from .models import OwnTrackLog from .models import OwnTrackLog # 导入自定义的 OwnTrackLog 模型。
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # 获取当前模块的日志记录器。
# 视图函数:处理位置跟踪数据的记录。
# 使用 @csrf_exempt 装饰器豁免 CSRF 验证,适用于 API 接口。
@csrf_exempt @csrf_exempt
def manage_owntrack_log(request): def manage_owntrack_log(request):
try: try:
# 从请求体中读取并解码 JSON 数据。
s = json.loads(request.read().decode('utf-8')) s = json.loads(request.read().decode('utf-8'))
tid = s['tid'] tid = s['tid'] # 提取用户标识。
lat = s['lat'] lat = s['lat'] # 提取纬度。
lon = s['lon'] lon = s['lon'] # 提取经度。
# 记录接收到的数据到日志。
logger.info( logger.info(
'tid:{tid}.lat:{lat}.lon:{lon}'.format( 'tid:{tid}.lat:{lat}.lon:{lon}'.format(
tid=tid, lat=lat, lon=lon)) tid=tid, lat=lat, lon=lon))
# 检查必需的字段是否存在且不为空。
if tid and lat and lon: if tid and lat and lon:
m = OwnTrackLog() m = OwnTrackLog() # 创建一个新的 OwnTrackLog 实例。
m.tid = tid m.tid = tid
m.lat = lat m.lat = lat
m.lon = lon m.lon = lon
m.save() m.save() # 保存实例到数据库。
return HttpResponse('ok') return HttpResponse('ok') # 返回成功响应。
else: else:
return HttpResponse('data error') return HttpResponse('data error') # 返回数据错误响应。
except Exception as e: except Exception as e:
# 记录任何异常到日志。
logger.error(e) logger.error(e)
return HttpResponse('error') return HttpResponse('error') # 返回一般错误响应。
# 视图函数:展示地图视图。
# 使用 @login_required 装饰器确保用户已登录,同时检查用户是否为超级用户。
@login_required @login_required
def show_maps(request): def show_maps(request):
if request.user.is_superuser: if request.user.is_superuser:
# 获取当前 UTC 时间的日期,作为默认日期。
defaultdate = str(datetime.datetime.now(timezone.utc).date()) defaultdate = str(datetime.datetime.now(timezone.utc).date())
# 从请求参数中获取日期,如果未提供则使用默认日期。
date = request.GET.get('date', defaultdate) date = request.GET.get('date', defaultdate)
# 构建传递给模板的上下文,包含日期信息。
context = { context = {
'date': date 'date': date
} }
# 渲染 'owntracks/show_maps.html' 模板,并传递上下文。
return render(request, 'owntracks/show_maps.html', context) return render(request, 'owntracks/show_maps.html', context)
else: else:
# 如果用户不是超级用户,返回 403 Forbidden 响应。
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
return HttpResponseForbidden() return HttpResponseForbidden()
# 视图函数:展示日志日期视图。
# 使用 @login_required 装饰器确保用户已登录。
@login_required @login_required
def show_log_dates(request): def show_log_dates(request):
# 从 OwnTrackLog 对象中获取所有创建时间,并提取日期部分。
dates = OwnTrackLog.objects.values_list('creation_time', flat=True) dates = OwnTrackLog.objects.values_list('creation_time', flat=True)
# 对日期进行排序并去重,转换为 'YYYY-MM-DD' 格式的字符串列表。
results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates)))) results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates))))
# 构建传递给模板的上下文,包含日期结果列表。
context = { context = {
'results': results 'results': results
} }
# 渲染 'owntracks/show_log_dates.html' 模板,并传递上下文。
return render(request, 'owntracks/show_log_dates.html', context) return render(request, 'owntracks/show_log_dates.html', context)
# 函数:将 GPS 坐标转换为高德地图坐标。
# 该函数通过调用高德地图的坐标转换 API 实现坐标转换。
def convert_to_amap(locations): def convert_to_amap(locations):
convert_result = [] convert_result = [] # 用于存储转换后的坐标结果。
it = iter(locations) it = iter(locations) # 创建一个迭代器以便分批处理位置数据。
item = list(itertools.islice(it, 30)) item = list(itertools.islice(it, 30)) # 每次处理最多 30 个位置。
while item: while item:
# 将位置数据拼接为 'lon,lat' 格式的字符串,使用分号分隔。
datas = ';'.join( datas = ';'.join(
set(map(lambda x: str(x.lon) + ',' + str(x.lat), item))) set(map(lambda x: str(x.lon) + ',' + str(x.lat), item)))
key = '8440a376dfc9743d8924bf0ad141f28e' key = '8440a376dfc9743d8924bf0ad141f28e' # 高德地图 API 的密钥,请根据实际情况替换。
api = 'http://restapi.amap.com/v3/assistant/coordinate/convert' api = 'http://restapi.amap.com/v3/assistant/coordinate/convert' # 高德地图坐标转换 API 的 URL。
query = { query = {
'key': key, 'key': key,
'locations': datas, 'locations': datas,
'coordsys': 'gps' 'coordsys': 'gps' # 指定坐标系统为 GPS。
} }
rsp = requests.get(url=api, params=query) rsp = requests.get(url=api, params=query) # 发送 GET 请求到高德地图 API。
result = json.loads(rsp.text) result = json.loads(rsp.text) # 解析 API 响应的 JSON 数据。
if "locations" in result: if "locations" in result:
# 如果响应中包含转换后的坐标,将其添加到结果列表中。
convert_result.append(result['locations']) convert_result.append(result['locations'])
item = list(itertools.islice(it, 30)) item = list(itertools.islice(it, 30)) # 处理下一批 30 个位置。
# 将所有转换后的坐标结果拼接为一个字符串,使用分号分隔。
return ";".join(convert_result) return ";".join(convert_result)
@login_required # 视图函数:获取数据视图。
# 该视图根据请求参数获取特定日期范围内的轨迹数据,并返回 JSON 格式的响应。
def get_datas(request): def get_datas(request):
now = django.utils.timezone.now().replace(tzinfo=timezone.utc) now = django.utils.timezone.now().replace(tzinfo=timezone.utc) # 获取当前的 UTC 时间。
querydate = django.utils.timezone.datetime( querydate = django.utils.timezone.datetime(
now.year, now.month, now.day, 0, 0, 0) now.year, now.month, now.day, 0, 0, 0) # 构建当天的起始时间00:00:00
# 如果请求中包含日期参数,则根据参数构建查询日期。
if request.GET.get('date', None): if request.GET.get('date', None):
date = list(map(lambda x: int(x), request.GET.get('date').split('-'))) date = list(map(lambda x: int(x), request.GET.get('date').split('-')))
querydate = django.utils.timezone.datetime( querydate = django.utils.timezone.datetime(
date[0], date[1], date[2], 0, 0, 0) date[0], date[1], date[2], 0, 0, 0)
nextdate = querydate + datetime.timedelta(days=1) nextdate = querydate + datetime.timedelta(days=1) # 计算查询日期的下一天,作为结束时间。
# 从 OwnTrackLog 对象中筛选出创建时间在查询日期范围内的数据。
models = OwnTrackLog.objects.filter( models = OwnTrackLog.objects.filter(
creation_time__range=(querydate, nextdate)) creation_time__range=(querydate, nextdate))
result = list() result = list() # 用于存储最终的结果数据。
if models and len(models): if models and len(models):
# 按用户标识 (tid) 对数据进行分组和排序。
for tid, item in groupby( for tid, item in groupby(
sorted(models, key=lambda k: k.tid), key=lambda k: k.tid): sorted(models, key=lambda k: k.tid), key=lambda k: k.tid):
d = dict() d = dict() # 创建一个字典,用于存储单个用户的数据。
d["name"] = tid d["name"] = tid # 用户标识。
paths = list() paths = list() # 用于存储用户的轨迹路径。
# 使用高德转换后的经纬度 # 目前代码中提供了使用高德转换后的经纬度和直接使用 GPS 原始经纬度的两种方式,
# 其中使用高德转换坐标的部分被注释。
# 使用高德转换后的经纬度(被注释)。
# locations = convert_to_amap( # locations = convert_to_amap(
# sorted(item, key=lambda x: x.creation_time)) # sorted(item, key=lambda x: x.creation_time))
# for i in locations.split(';'): # for i in locations.split(';'):
# paths.append(i.split(',')) # paths.append(i.split(','))
# 使用GPS原始经纬度
# 使用 GPS 原始经纬度。
for location in sorted(item, key=lambda x: x.creation_time): for location in sorted(item, key=lambda x: x.creation_time):
paths.append([str(location.lon), str(location.lat)]) paths.append([str(location.lon), str(location.lat)]) # 添加经纬度到路径列表。
d["path"] = paths d["path"] = paths # 将路径列表添加到用户数据字典中。
result.append(d) result.append(d) # 将用户数据字典添加到结果列表中。
return JsonResponse(result, safe=False) # 将结果以 JSON 格式返回,设置 safe=False 以允许非字典类型的对象被序列化。
return JsonResponse(result, safe=False)

@ -1,37 +1,43 @@
# ============================
# 插件1文章结尾版权声明插件
# ============================
from djangoblog.plugin_manage.base_plugin import BasePlugin from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ArticleCopyrightPlugin(BasePlugin): class ArticleCopyrightPlugin(BasePlugin):
"""
功能在文章正文末尾添加版权声明标明文章作者提醒转载需注明出处
"""
PLUGIN_NAME = '文章结尾版权声明' PLUGIN_NAME = '文章结尾版权声明'
PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。' PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。'
PLUGIN_VERSION = '0.2.0' PLUGIN_VERSION = '0.2.0'
PLUGIN_AUTHOR = 'liangliangyy' PLUGIN_AUTHOR = 'liangliangyy'
# 2. 实现 register_hooks 方法,专门用于注册钩子
def register_hooks(self): def register_hooks(self):
# 在这里将插件的方法注册到指定的钩子上 # 将本插件的版权添加方法注册到文章内容钩子上
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_copyright_to_content) hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_copyright_to_content)
def add_copyright_to_content(self, content, *args, **kwargs): def add_copyright_to_content(self, content, *args, **kwargs):
""" """
这个方法会被注册到 'the_content' 过滤器钩子上 给文章内容追加版权声明信息
它接收原始内容并返回添加了版权信息的新内容 :param content: 原始文章内容
:param kwargs: 可能包含 article文章对象is_summary是否摘要模式
:return: 添加了版权声明的新内容
""" """
article = kwargs.get('article') article = kwargs.get('article')
if not article: if not article:
return content return content # 没有文章对象,直接返回原文
# 如果是摘要模式(首页),不添加版权声明 is_summary = kwargs.get('is_summary', False) # 是否为摘要(如首页列表页)
is_summary = kwargs.get('is_summary', False)
if is_summary: if is_summary:
return content return content # 摘要模式下不显示版权信息
# 拼接版权声明 HTML
copyright_info = f"\n<hr><p>本文由 {article.author.username} 原创,转载请注明出处。</p>" copyright_info = f"\n<hr><p>本文由 {article.author.username} 原创,转载请注明出处。</p>"
return content + copyright_info return content + copyright_info
# 3. 实例化插件。 # 实例化插件,自动调用 register_hooks 方法
# 这会自动调用 BasePlugin.__init__然后 BasePlugin.__init__ 会调用我们上面定义的 register_hooks 方法。 plugin = ArticleCopyrightPlugin()
plugin = ArticleCopyrightPlugin()

@ -1,3 +1,6 @@
# ============================
# 插件2外部链接处理器插件
# ============================
import re import re
from urllib.parse import urlparse from urllib.parse import urlparse
from djangoblog.plugin_manage.base_plugin import BasePlugin from djangoblog.plugin_manage.base_plugin import BasePlugin
@ -6,43 +9,47 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ExternalLinksPlugin(BasePlugin): class ExternalLinksPlugin(BasePlugin):
"""
功能自动为文章中的外部链接添加 target="_blank" rel="noopener noreferrer"
提高安全性防止标签页劫持
"""
PLUGIN_NAME = '外部链接处理器' PLUGIN_NAME = '外部链接处理器'
PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。' PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。'
PLUGIN_VERSION = '0.1.0' PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy' PLUGIN_AUTHOR = 'liangliangyy'
def register_hooks(self): def register_hooks(self):
# 注册处理函数到文章内容钩子
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.process_external_links) hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.process_external_links)
def process_external_links(self, content, *args, **kwargs): def process_external_links(self, content, *args, **kwargs):
"""
查找并处理文章中的所有 <a> 标签为外部链接添加安全属性
"""
from djangoblog.utils import get_current_site from djangoblog.utils import get_current_site
site_domain = get_current_site().domain site_domain = get_current_site().domain # 当前网站域名
# 正则表达式查找所有 <a> 标签 # 正则匹配 <a href="...">...</a>
link_pattern = re.compile(r'(<a\s+(?:[^>]*?\s+)?href=")([^"]*)(".*?/a>)', re.IGNORECASE) link_pattern = re.compile(r'(<a\s+(?:[^>]*?\s+)?href=")([^"]*)(".*?/a>)', re.IGNORECASE)
def replacer(match): def replacer(match):
# match.group(1) 是 <a ... href=" prefix = match.group(1) # <a ... href="
# match.group(2) 是链接 URL href = match.group(2) # 链接地址
# match.group(3) 是 ">...</a> suffix = match.group(3) # ">...</a>
href = match.group(2)
# 如果链接已经有 target 属性,不处理 # 如果已经有 target 属性,不处理
if 'target=' in match.group(0).lower(): if 'target=' in match.group(0).lower():
return match.group(0) return match.group(0)
# 解析链接
parsed_url = urlparse(href) parsed_url = urlparse(href)
# 判断是否为外部链接(有域名且非本站)
# 如果链接是外部的 (有域名且域名不等于当前网站域名)
if parsed_url.netloc and parsed_url.netloc != site_domain: if parsed_url.netloc and parsed_url.netloc != site_domain:
# 添加 target 和 rel 属性 # 添加安全属性
return f'{match.group(1)}{href}" target="_blank" rel="noopener noreferrer"{match.group(3)}' return f'{prefix}{href}" target="_blank" rel="noopener noreferrer"{suffix}'
return match.group(0) # 内部链接,不处理
# 否则返回原样
return match.group(0)
# 替换所有符合条件的链接
return link_pattern.sub(replacer, content) return link_pattern.sub(replacer, content)
plugin = ExternalLinksPlugin() plugin = ExternalLinksPlugin()

@ -1,3 +1,6 @@
# ============================
# 插件3图片性能优化插件
# ============================
import re import re
import hashlib import hashlib
from urllib.parse import urlparse from urllib.parse import urlparse
@ -7,170 +10,130 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ImageOptimizationPlugin(BasePlugin): class ImageOptimizationPlugin(BasePlugin):
"""
功能为文章中的 标签添加懒加载异步解码响应式alt等属性
优化页面加载性能和用户体验
"""
PLUGIN_NAME = '图片性能优化插件' PLUGIN_NAME = '图片性能优化插件'
PLUGIN_DESCRIPTION = '自动为文章中的图片添加懒加载、异步解码等性能优化属性,显著提升页面加载速度。' PLUGIN_DESCRIPTION = '自动为文章中的图片添加懒加载、异步解码等性能优化属性,显著提升页面加载速度。'
PLUGIN_VERSION = '1.0.0' PLUGIN_VERSION = '1.0.0'
PLUGIN_AUTHOR = 'liangliangyy' PLUGIN_AUTHOR = 'liangliangyy'
def __init__(self): def __init__(self):
# 插件配置 # 插件配置参数
self.config = { self.config = {
'enable_lazy_loading': True, # 启用懒加载 'enable_lazy_loading': True, # 是否启用懒加载
'enable_async_decoding': True, # 启用异步解码 'enable_async_decoding': True, # 是否启用异步解码
'add_loading_placeholder': True, # 添加加载占位符 'add_loading_placeholder': True, # 是否添加加载样式
'optimize_external_images': True, # 优化外部图片 'optimize_external_images': True, # 是否优化外部图片
'add_responsive_attributes': True, # 添加响应式属性 'add_responsive_attributes': True, # 是否添加响应式属性
'skip_first_image': True, # 跳过第一张图片LCP优化 'skip_first_image': True, # 是否跳过第一张图片(优化LCP
} }
super().__init__() super().__init__()
def register_hooks(self): def register_hooks(self):
# 注册图片优化函数到文章内容钩子
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.optimize_images) hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.optimize_images)
def optimize_images(self, content, *args, **kwargs): def optimize_images(self, content, *args, **kwargs):
"""
优化文章中的图片标签
"""
if not content: if not content:
return content return content
# 正则表达式匹配 img 标签 # 匹配所有 标签
img_pattern = re.compile( img_pattern = re.compile(r']*?)(?:\s*/)?>', re.IGNORECASE | re.DOTALL)
r'<img\s+([^>]*?)(?:\s*/)?>',
re.IGNORECASE | re.DOTALL
)
image_count = 0 image_count = 0
def replace_img_tag(match): def replace_img_tag(match):
nonlocal image_count nonlocal image_count
image_count += 1 image_count += 1
# 获取原始属性
original_attrs = match.group(1) original_attrs = match.group(1)
# 解析现有属性
attrs = self._parse_img_attributes(original_attrs) attrs = self._parse_img_attributes(original_attrs)
# 应用优化
optimized_attrs = self._apply_optimizations(attrs, image_count) optimized_attrs = self._apply_optimizations(attrs, image_count)
# 重构 img 标签
return self._build_img_tag(optimized_attrs) return self._build_img_tag(optimized_attrs)
# 替换所有 img 标签
optimized_content = img_pattern.sub(replace_img_tag, content) optimized_content = img_pattern.sub(replace_img_tag, content)
return optimized_content return optimized_content
def _parse_img_attributes(self, attr_string): def _parse_img_attributes(self, attr_string):
""" # 解析如 src="x" alt="y" 这类属性为字典
解析 img 标签的属性
"""
attrs = {} attrs = {}
# 正则表达式匹配属性
attr_pattern = re.compile(r'(\w+)=(["\'])(.*?)\2') attr_pattern = re.compile(r'(\w+)=(["\'])(.*?)\2')
for match in attr_pattern.finditer(attr_string): for match in attr_pattern.finditer(attr_string):
attr_name = match.group(1).lower() key = match.group(1).lower()
attr_value = match.group(3) val = match.group(3)
attrs[attr_name] = attr_value attrs[key] = val
return attrs return attrs
def _apply_optimizations(self, attrs, image_index): def _apply_optimizations(self, attrs, image_index):
""" # 懒加载:跳过第一张图片
应用各种图片优化
"""
# 1. 懒加载优化跳过第一张图片以优化LCP
if self.config['enable_lazy_loading']: if self.config['enable_lazy_loading']:
if not (self.config['skip_first_image'] and image_index == 1): if not (self.config['skip_first_image'] and image_index == 1):
if 'loading' not in attrs: if 'loading' not in attrs:
attrs['loading'] = 'lazy' attrs['loading'] = 'lazy'
# 2. 异步解码 # 异步解码
if self.config['enable_async_decoding']: if self.config['enable_async_decoding']:
if 'decoding' not in attrs: if 'decoding' not in attrs:
attrs['decoding'] = 'async' attrs['decoding'] = 'async'
# 3. 添加样式优化 # 样式优化:限制最大宽度
current_style = attrs.get('style', '') current_style = attrs.get('style', '')
# 确保图片不会超出容器
if 'max-width' not in current_style: if 'max-width' not in current_style:
if current_style and not current_style.endswith(';'): if current_style and not current_style.endswith(';'):
current_style += ';' current_style += ';'
current_style += 'max-width:100%;height:auto;' current_style += 'max-width:100%;height:auto;'
attrs['style'] = current_style attrs['style'] = current_style
# 4. 添加 alt 属性SEO和可访问性 # Alt 属性提升可访问性和SEO
if 'alt' not in attrs: if 'alt' not in attrs:
# 尝试从图片URL生成有意义的alt文本
src = attrs.get('src', '') src = attrs.get('src', '')
if src: if src:
# 从文件名生成alt文本
filename = src.split('/')[-1].split('.')[0] filename = src.split('/')[-1].split('.')[0]
# 移除常见的无意义字符 clean_name = re.sub(r'[0-9a-f]{8,}', '', filename)
clean_name = re.sub(r'[0-9a-f]{8,}', '', filename) # 移除长hash
clean_name = re.sub(r'[_-]+', ' ', clean_name).strip() clean_name = re.sub(r'[_-]+', ' ', clean_name).strip()
attrs['alt'] = clean_name if clean_name else '文章图片' attrs['alt'] = clean_name if clean_name else '文章图片'
else: else:
attrs['alt'] = '文章图片' attrs['alt'] = '文章图片'
# 5. 外部图片优化 # 外部图片添加referrer和跨域属性
if self.config['optimize_external_images'] and 'src' in attrs: if self.config['optimize_external_images'] and 'src' in attrs:
src = attrs['src'] src = attrs['src']
parsed_url = urlparse(src) parsed_url = urlparse(src)
# 如果是外部图片,添加 referrerpolicy
if parsed_url.netloc and parsed_url.netloc != self._get_current_domain(): if parsed_url.netloc and parsed_url.netloc != self._get_current_domain():
attrs['referrerpolicy'] = 'no-referrer-when-downgrade' attrs['referrerpolicy'] = 'no-referrer-when-downgrade'
# 为外部图片添加crossorigin属性以支持性能监控
if 'crossorigin' not in attrs: if 'crossorigin' not in attrs:
attrs['crossorigin'] = 'anonymous' attrs['crossorigin'] = 'anonymous'
# 6. 响应式图片属性(如果配置启用) # 响应式属性
if self.config['add_responsive_attributes']: if self.config['add_responsive_attributes']:
# 添加 sizes 属性(如果没有的话)
if 'sizes' not in attrs and 'srcset' not in attrs: if 'sizes' not in attrs and 'srcset' not in attrs:
attrs['sizes'] = '(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw' attrs['sizes'] = '(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
# 7. 添加图片唯一标识符用于性能追踪 # 图片唯一ID用于性能追踪
if 'data-img-id' not in attrs and 'src' in attrs: if 'data-img-id' not in attrs and 'src' in attrs:
img_hash = hashlib.md5(attrs['src'].encode()).hexdigest()[:8] img_hash = hashlib.md5(attrs['src'].encode()).hexdigest()[:8]
attrs['data-img-id'] = f'img-{img_hash}' attrs['data-img-id'] = f'img-{img_hash}'
# 8. 为第一张图片添加高优先级提示LCP优化 # 第一张图片优化:提高加载优先级
if image_index == 1 and self.config['skip_first_image']: if image_index == 1 and self.config['skip_first_image']:
attrs['fetchpriority'] = 'high' attrs['fetchpriority'] = 'high'
# 移除懒加载以确保快速加载
if 'loading' in attrs: if 'loading' in attrs:
del attrs['loading'] del attrs['loading']
return attrs return attrs
def _build_img_tag(self, attrs): def _build_img_tag(self, attrs):
""" # 重新构建优化后的 标签
重新构建 img 标签
"""
attr_strings = [] attr_strings = []
# 确保 src 属性在最前面
if 'src' in attrs: if 'src' in attrs:
attr_strings.append(f'src="{attrs["src"]}"') attr_strings.append(f'src="{attrs["src"]}"')
# 添加其他属性
for key, value in attrs.items(): for key, value in attrs.items():
if key != 'src': # src 已经添加过了 if key != 'src':
attr_strings.append(f'{key}="{value}"') attr_strings.append(f'{key}="{value}"')
return f''
return f'<img {" ".join(attr_strings)}>'
def _get_current_domain(self): def _get_current_domain(self):
""" # 获取当前站点域名
获取当前网站域名
"""
try: try:
from djangoblog.utils import get_current_site from djangoblog.utils import get_current_site
return get_current_site().domain return get_current_site().domain
@ -178,5 +141,4 @@ class ImageOptimizationPlugin(BasePlugin):
return '' return ''
# 实例化插件 plugin = ImageOptimizationPlugin()
plugin = ImageOptimizationPlugin()

@ -1,3 +1,6 @@
# ============================
# 插件4阅读时间预测插件
# ============================
import math import math
import re import re
from djangoblog.plugin_manage.base_plugin import BasePlugin from djangoblog.plugin_manage.base_plugin import BasePlugin
@ -6,46 +9,50 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ReadingTimePlugin(BasePlugin): class ReadingTimePlugin(BasePlugin):
"""
功能根据文章内容的字数估算阅读时间并在文章开头显示预计阅读时间
提升用户对内容长度的预期仅对文章详情页生效
"""
PLUGIN_NAME = '阅读时间预测' PLUGIN_NAME = '阅读时间预测'
PLUGIN_DESCRIPTION = '估算文章阅读时间并显示在文章开头。' PLUGIN_DESCRIPTION = '估算文章阅读时间并显示在文章开头。'
PLUGIN_VERSION = '0.1.0' PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy' PLUGIN_AUTHOR = 'liangliangyy'
def register_hooks(self): def register_hooks(self):
# 注册到文章内容钩子,在渲染文章内容时调用
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_reading_time) hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_reading_time)
def add_reading_time(self, content, *args, **kwargs): def add_reading_time(self, content, *args, **kwargs):
""" """
计算阅读时间并添加到内容开头 计算阅读时间并插入到文章内容最前面仅非摘要模式如非首页
只在文章详情页显示首页文章列表页不显示 :param content: 原始文章内容
:param kwargs: 可能包含 is_summary是否为摘要模式如首页
:return: 添加了阅读时间提示的文章内容
""" """
# 检查是否为摘要模式(首页/文章列表页)
# 通过kwargs中的is_summary参数判断
is_summary = kwargs.get('is_summary', False) is_summary = kwargs.get('is_summary', False)
if is_summary: if is_summary:
# 如果是摘要模式(首页),直接返回原内容,不添加阅读时间 # 如果是摘要模式(如首页文章列表),不显示阅读时间
return content return content
# 移除HTML标签和空白字符以获得纯文本 # 去掉所有 HTML 标签,只保留纯文本内容
clean_content = re.sub(r'<[^>]*>', '', content) clean_content = re.sub(r'<[^>]*>', '', content)
clean_content = clean_content.strip() clean_content = clean_content.strip()
# 中文和英文单词混合计数的一个简单方法 # 匹配中文字符或连续的英文字符/数字(简单模拟单词统计)
# 匹配中文字符或连续的非中文字符(视为单词)
words = re.findall(r'[\u4e00-\u9fa5]|\w+', clean_content) words = re.findall(r'[\u4e00-\u9fa5]|\w+', clean_content)
word_count = len(words) word_count = len(words)
# 按平均每分钟200字的速度计算 # 按每分钟 200 字计算阅读时间
reading_speed = 200 reading_speed = 200
reading_minutes = math.ceil(word_count / reading_speed) reading_minutes = math.ceil(word_count / reading_speed)
# 如果阅读时间少于1分钟则显示为1分钟 # 最少显示 1 分钟
if reading_minutes < 1: if reading_minutes < 1:
reading_minutes = 1 reading_minutes = 1
# 拼接阅读时间提示 HTML
reading_time_html = f'<p style="color: #888;"><em>预计阅读时间:{reading_minutes} 分钟</em></p>' reading_time_html = f'<p style="color: #888;"><em>预计阅读时间:{reading_minutes} 分钟</em></p>'
return reading_time_html + content return reading_time_html + content
plugin = ReadingTimePlugin() plugin = ReadingTimePlugin()

@ -1,3 +1,6 @@
# ============================
# 插件5SEO 优化器插件
# ============================
import json import json
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.template.defaultfilters import truncatewords from django.template.defaultfilters import truncatewords
@ -8,12 +11,17 @@ from djangoblog.utils import get_blog_setting
class SeoOptimizerPlugin(BasePlugin): class SeoOptimizerPlugin(BasePlugin):
"""
功能为文章详情页分类页等动态生成 SEO 相关的 meta 标签与 JSON-LD 结构化数据
优化搜索引擎收录效果和展示内容
"""
PLUGIN_NAME = 'SEO 优化器' PLUGIN_NAME = 'SEO 优化器'
PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。' PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。'
PLUGIN_VERSION = '0.2.0' PLUGIN_VERSION = '0.2.0'
PLUGIN_AUTHOR = 'liuangliangyy' PLUGIN_AUTHOR = 'liuangliangyy'
def register_hooks(self): def register_hooks(self):
# 注册到 head_meta 钩子,一般用于向 <head> 中插入 SEO 相关标签
hooks.register('head_meta', self.dispatch_seo_generation) hooks.register('head_meta', self.dispatch_seo_generation)
def _get_article_seo_data(self, context, request, blog_setting): def _get_article_seo_data(self, context, request, blog_setting):
@ -21,9 +29,11 @@ class SeoOptimizerPlugin(BasePlugin):
if not isinstance(article, Article): if not isinstance(article, Article):
return None return None
# 构造文章描述和关键词
description = strip_tags(article.body)[:150] description = strip_tags(article.body)[:150]
keywords = ",".join([tag.name for tag in article.tags.all()]) or blog_setting.site_keywords keywords = ",".join([tag.name for tag in article.tags.all()]) or blog_setting.site_keywords
# OpenGraph 和基础 Meta 标签
meta_tags = f''' meta_tags = f'''
<meta property="og:type" content="article"/> <meta property="og:type" content="article"/>
<meta property="og:title" content="{article.title}"/> <meta property="og:title" content="{article.title}"/>
@ -38,6 +48,7 @@ class SeoOptimizerPlugin(BasePlugin):
meta_tags += f'<meta property="article:tag" content="{tag.name}"/>' meta_tags += f'<meta property="article:tag" content="{tag.name}"/>'
meta_tags += f'<meta property="og:site_name" content="{blog_setting.site_name}"/>' meta_tags += f'<meta property="og:site_name" content="{blog_setting.site_name}"/>'
# JSON-LD 结构化数据Schema.org
structured_data = { structured_data = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
@ -65,7 +76,6 @@ class SeoOptimizerPlugin(BasePlugin):
category_name = context.get('tag_name') category_name = context.get('tag_name')
if not category_name: if not category_name:
return None return None
category = Category.objects.filter(name=category_name).first() category = Category.objects.filter(name=category_name).first()
if not category: if not category:
return None return None
@ -74,10 +84,11 @@ class SeoOptimizerPlugin(BasePlugin):
description = strip_tags(category.name) or blog_setting.site_description description = strip_tags(category.name) or blog_setting.site_description
keywords = category.name keywords = category.name
# BreadcrumbList structured data for category page # Breadcrumb 结构化数据
breadcrumb_items = [{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}] breadcrumb_items = [
breadcrumb_items.append({"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()}) {"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')},
{"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()}
]
structured_data = { structured_data = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "BreadcrumbList", "@type": "BreadcrumbList",
@ -93,7 +104,7 @@ class SeoOptimizerPlugin(BasePlugin):
} }
def _get_default_seo_data(self, context, request, blog_setting): def _get_default_seo_data(self, context, request, blog_setting):
# Homepage and other default pages # 默认数据,例如首页
structured_data = { structured_data = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
@ -121,18 +132,20 @@ class SeoOptimizerPlugin(BasePlugin):
view_name = request.resolver_match.view_name view_name = request.resolver_match.view_name
blog_setting = get_blog_setting() blog_setting = get_blog_setting()
seo_data = None seo_data = None
if view_name == 'blog:detailbyid': if view_name == 'blog:detailbyid':
seo_data = self._get_article_seo_data(context, request, blog_setting) seo_data = self._get_article_seo_data(context, request, blog_setting)
elif view_name == 'blog:category_detail': elif view_name == 'blog:category_detail':
seo_data = self._get_category_seo_data(context, request, blog_setting) seo_data = self._get_category_seo_data(context, request, blog_setting)
if not seo_data: if not seo_data:
seo_data = self._get_default_seo_data(context, request, blog_setting) seo_data = self._get_default_seo_data(context, request, blog_setting)
# 构建 JSON-LD 脚本标签
json_ld_script = f'<script type="application/ld+json">{json.dumps(seo_data.get("json_ld", {}), ensure_ascii=False, indent=4)}</script>' json_ld_script = f'<script type="application/ld+json">{json.dumps(seo_data.get("json_ld", {}), ensure_ascii=False, indent=4)}</script>'
# 拼接所有 SEO 相关内容
seo_html = f""" seo_html = f"""
<title>{seo_data.get("title", "")}</title> <title>{seo_data.get("title", "")}</title>
<meta name="description" content="{seo_data.get("description", "")}"> <meta name="description" content="{seo_data.get("description", "")}">
@ -140,8 +153,7 @@ class SeoOptimizerPlugin(BasePlugin):
{seo_data.get("meta_tags", "")} {seo_data.get("meta_tags", "")}
{json_ld_script} {json_ld_script}
""" """
# 将SEO内容追加到现有的metas内容上
return metas + seo_html return metas + seo_html
plugin = SeoOptimizerPlugin()
plugin = SeoOptimizerPlugin()

@ -1,18 +1,26 @@
# ============================
# 插件6文章浏览次数统计插件
# ============================
from djangoblog.plugin_manage.base_plugin import BasePlugin from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage import hooks
class ViewCountPlugin(BasePlugin): class ViewCountPlugin(BasePlugin):
"""
功能在每次获取文章内容时统计该文章的浏览次数用于分析文章热度
"""
PLUGIN_NAME = '文章浏览次数统计' PLUGIN_NAME = '文章浏览次数统计'
PLUGIN_DESCRIPTION = '统计文章的浏览次数' PLUGIN_DESCRIPTION = '统计文章的浏览次数'
PLUGIN_VERSION = '0.1.0' PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy' PLUGIN_AUTHOR = 'liangliangyy'
def register_hooks(self): def register_hooks(self):
# 注册到 after_article_body_get 钩子,通常在文章内容加载后触发
hooks.register('after_article_body_get', self.record_view) hooks.register('after_article_body_get', self.record_view)
def record_view(self, article, *args, **kwargs): def record_view(self, article, *args, **kwargs):
# 调用 article 对象的 viewed() 方法来增加浏览量(需模型方法支持)
article.viewed() article.viewed()
plugin = ViewCountPlugin() plugin = ViewCountPlugin()

@ -1,32 +1,35 @@
from werobot.session import SessionStorage from werobot.session import SessionStorage
from werobot.utils import json_loads, json_dumps from werobot.utils import json_loads, json_dumps
from djangoblog.utils import cache # 假设这是一个封装了 Django 缓存的工具模块
from djangoblog.utils import cache
class MemcacheStorage(SessionStorage): class MemcacheStorage(SessionStorage):
def __init__(self, prefix='ws_'): def __init__(self, prefix='ws_'):
self.prefix = prefix self.prefix = prefix # 会话键前缀,避免与其他缓存冲突
self.cache = cache self.cache = cache # Django 缓存实例,如 Redis 或 Memcached
@property @property
def is_available(self): def is_available(self):
# 检查当前存储是否可用,通过设置和获取一个测试值
value = "1" value = "1"
self.set('checkavaliable', value=value) self.set('checkavaliable', value=value)
return value == self.get('checkavaliable') return value == self.get('checkavaliable')
def key_name(self, s): def key_name(self, s):
return '{prefix}{s}'.format(prefix=self.prefix, s=s) # 为每个会话 ID 添加前缀,生成唯一的缓存键
return f'{self.prefix}{s}'
def get(self, id): def get(self, id):
# 根据 ID 获取会话数据,如果不存在则返回空字典字符串 '{}'
id = self.key_name(id) id = self.key_name(id)
session_json = self.cache.get(id) or '{}' session_json = self.cache.get(id) or '{}'
return json_loads(session_json) return json_loads(session_json) # 反序列化为 Python 字典
def set(self, id, value): def set(self, id, value):
# 将会话数据序列化后存入缓存
id = self.key_name(id) id = self.key_name(id)
self.cache.set(id, json_dumps(value)) self.cache.set(id, json_dumps(value))
def delete(self, id): def delete(self, id):
# 删除指定的会话数据
id = self.key_name(id) id = self.key_name(id)
self.cache.delete(id) self.cache.delete(id)

@ -1,19 +1,23 @@
from django.contrib import admin from django.contrib import admin
# Register your models here.
# 假设 commands 和 EmailSendLog 是来自 .models 的模型,这里为了示例直接使用
# 实际使用时请确保 from .models import commands, EmailSendLog
class CommandsAdmin(admin.ModelAdmin): class CommandsAdmin(admin.ModelAdmin):
# 在后台列表页显示这些字段
list_display = ('title', 'command', 'describe') list_display = ('title', 'command', 'describe')
# 邮件发送日志的后台管理
class EmailSendLogAdmin(admin.ModelAdmin): class EmailSendLogAdmin(admin.ModelAdmin):
# 列表页显示字段
list_display = ('title', 'emailto', 'send_result', 'creation_time') list_display = ('title', 'emailto', 'send_result', 'creation_time')
readonly_fields = ( # 这些字段为只读,不允许在后台修改
'title', readonly_fields = ('title', 'emailto', 'send_result', 'creation_time', 'content')
'emailto',
'send_result',
'creation_time',
'content')
# 禁止通过后台添加新的日志条目,只能通过程序逻辑创建
def has_add_permission(self, request): def has_add_permission(self, request):
return False return False
# 注册模型与对应的管理类(通常在文件末尾,这里假设已导入 models
# admin.site.register(commands, CommandsAdmin)
# admin.site.register(EmailSendLog, EmailSendLogAdmin)

@ -1,27 +1,75 @@
# 从 Haystack 的查询模块导入 SearchQuerySet用于全文检索
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
# 从本地 blog 应用的 models 模块中导入 Article文章和 Category分类模型
from blog.models import Article, Category from blog.models import Article, Category
class BlogApi: class BlogApi:
def __init__(self): def __init__(self):
"""
初始化 BlogApi
self.searchqueryset 是一个 Haystack SearchQuerySet 对象用于执行搜索
self.__max_takecount__ 是一个私有属性表示每次查询或获取时最多返回的文章数量这里设为 8
"""
# 创建一个 SearchQuerySet 实例,用于后续的搜索操作
self.searchqueryset = SearchQuerySet() self.searchqueryset = SearchQuerySet()
# 执行一个空的自动查询(暂时没有实际作用,可能为预留或初始化)
self.searchqueryset.auto_query('') self.searchqueryset.auto_query('')
# 定义每次查询返回的最大文章数
self.__max_takecount__ = 8 self.__max_takecount__ = 8
def search_articles(self, query): def search_articles(self, query):
"""
根据关键字 query 搜索相关的文章
参数:
query (str): 用户输入的搜索关键词
返回:
SearchQuerySet: 包含匹配文章的查询集最多返回 __max_takecount__ 条结果
"""
# 使用 Haystack 根据 query 自动构建搜索
sqs = self.searchqueryset.auto_query(query) sqs = self.searchqueryset.auto_query(query)
# 加载所有关联的模型数据(比如加载完整的 Article 对象而不仅是搜索快照)
sqs = sqs.load_all() sqs = sqs.load_all()
# 返回前 __max_takecount__ 条搜索结果
return sqs[:self.__max_takecount__] return sqs[:self.__max_takecount__]
def get_category_lists(self): def get_category_lists(self):
"""
获取所有的文章分类列表
返回:
QuerySet: 包含所有 Category 对象的查询集
"""
return Category.objects.all() return Category.objects.all()
def get_category_articles(self, categoryname): def get_category_articles(self, categoryname):
"""
根据分类名称获取该分类下的文章列表
参数:
categoryname (str): 分类名称
返回:
QuerySet or None: 该分类下的文章查询集最多 __max_takecount__ 如果分类不存在则返回 None
"""
# 从 Article 表中筛选出 category__name 等于传入的 categoryname 的文章
articles = Article.objects.filter(category__name=categoryname) articles = Article.objects.filter(category__name=categoryname)
if articles: if articles:
# 如果有文章,返回前 __max_takecount__ 条
return articles[:self.__max_takecount__] return articles[:self.__max_takecount__]
# 如果该分类下没有文章,返回 None
return None return None
def get_recent_articles(self): def get_recent_articles(self):
return Article.objects.all()[:self.__max_takecount__] """
获取最近发布的文章列表默认最新的几篇文章
返回:
QuerySet: 最新的文章查询集最多返回 __max_takecount__
"""
# 从 Article 表中获取所有文章,但只返回前 __max_takecount__ 条,通常可以按时间倒序优化
return Article.objects.all()[:self.__max_takecount__]

@ -1,64 +1,117 @@
# 导入 Python 标准库中的日志模块,用于记录错误和运行信息
import logging import logging
# 导入 os 模块,用于访问环境变量和执行系统命令
import os import os
import openai # 从本地的 servermanager 应用的 models 模块中导入 commands 模型(应该是一个存储命令的数据表)
from servermanager.models import commands from servermanager.models import commands
# 创建一个日志记录器,用于当前模块的日志输出
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 从环境变量中获取 OPENAI_API_KEY这是调用 OpenAI API 所必需的密钥
openai.api_key = os.environ.get('OPENAI_API_KEY') openai.api_key = os.environ.get('OPENAI_API_KEY')
# 如果环境变量中设置了 HTTP_PROXY则将其作为 OpenAI 的代理设置
if os.environ.get('HTTP_PROXY'): if os.environ.get('HTTP_PROXY'):
openai.proxy = os.environ.get('HTTP_PROXY') openai.proxy = os.environ.get('HTTP_PROXY')
class ChatGPT: class ChatGPT:
@staticmethod @staticmethod
def chat(prompt): def chat(prompt):
"""
调用 OpenAI ChatCompletion 接口 GPT-3.5-turbo 模型发送用户提示并获取回复
参数:
prompt (str): 用户输入的对话内容或问题
返回:
str: GPT 模型返回的回复内容如果发生异常则返回错误提示信息
"""
try: try:
completion = openai.ChatCompletion.create(model="gpt-3.5-turbo", # 调用 OpenAI 的 ChatCompletion.create 方法,使用 gpt-3.5-turbo 模型
messages=[{"role": "user", "content": prompt}]) completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo", # 指定使用的模型
messages=[{"role": "user", "content": prompt}] # 构造对话消息,角色为用户,内容为 prompt
)
# 从返回结果中提取第一个选择的回复内容
return completion.choices[0].message.content return completion.choices[0].message.content
except Exception as e: except Exception as e:
# 如果出现任何异常如网络错误、API Key 错误等),记录错误日志
logger.error(e) logger.error(e)
# 返回用户友好的错误提示
return "服务器出错了" return "服务器出错了"
# 定义一个处理系统命令的类
class CommandHandler: class CommandHandler:
def __init__(self): def __init__(self):
"""
初始化 CommandHandler从数据库中加载所有的命令记录
"""
# 从数据库中获取所有的命令对象,应该是存储在 commands 表中的数据
self.commands = commands.objects.all() self.commands = commands.objects.all()
def run(self, title): def run(self, title):
""" """
运行命令 根据命令标题 title 查找对应的命令并执行该命令
:param title: 命令
:return: 返回命令执行结果 参数:
title (str): 命令的名称或标题
返回:
str: 命令执行后的输出内容如果未找到对应命令返回提示信息
""" """
# 从所有命令中筛选出 title不区分大小写与传入参数一致的命令对象
cmd = list( cmd = list(
filter( filter(
lambda x: x.title.upper() == title.upper(), lambda x: x.title.upper() == title.upper(), # 不区分大小写匹配命令标题
self.commands)) self.commands
)
)
if cmd: if cmd:
# 如果找到了命令,取出第一个匹配项的 command 字段(应该是实际的 shell 命令)
return self.__run_command__(cmd[0].command) return self.__run_command__(cmd[0].command)
else: else:
# 如果未找到命令,返回提示让用户输入 helpme 获取帮助
return "未找到相关命令请输入hepme获得帮助。" return "未找到相关命令请输入hepme获得帮助。"
def __run_command__(self, cmd): def __run_command__(self, cmd):
"""
内部方法用于实际执行传入的系统命令并返回结果
参数:
cmd (str): 要执行的系统命令字符串
返回:
str: 命令执行的输出内容如果执行出错返回错误提示
"""
try: try:
# 使用 os.popen 执行命令并读取命令的标准输出
res = os.popen(cmd).read() res = os.popen(cmd).read()
return res return res
except BaseException: except BaseException:
return '命令执行出错!' # 捕获所有可能的异常(如命令不存在、权限问题等)
return '命令执行出错!' # 返回用户友好的错误信息
def get_help(self): def get_help(self):
rsp = '' """
获取所有可用命令的帮助信息包括命令标题和描述
返回:
str: 格式化后的命令帮助信息每行包含一个命令及其描述
"""
rsp = '' # 初始化返回的字符串
for cmd in self.commands: for cmd in self.commands:
# 遍历所有命令,格式化为 "命令标题:命令描述" 并追加到返回字符串中
rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe) rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe)
return rsp return rsp
# 当该脚本被直接运行时(而不是作为模块导入),执行以下测试代码
if __name__ == '__main__': if __name__ == '__main__':
# 创建一个 ChatGPT 类的实例
chatbot = ChatGPT() chatbot = ChatGPT()
# 设定一个示例 prompt要求写一篇关于 AI 的 1000 字论文
prompt = "写一篇1000字关于AI的论文" prompt = "写一篇1000字关于AI的论文"
print(chatbot.chat(prompt)) # 调用 chat 方法并打印返回的 GPT 回复
print(chatbot.chat(prompt))

@ -1,5 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
class ServermanagerConfig(AppConfig): class ServermanagerConfig(AppConfig):
name = 'servermanager' # 定义本 Django app 的名称,需与项目中的 app 文件夹名称一致
name = 'servermanager'

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save