diff --git a/.gitmodules.save b/.gitmodules.save deleted file mode 100644 index 88c7c4f..0000000 --- a/.gitmodules.save +++ /dev/null @@ -1,13 +0,0 @@ -R -^R - - - - - - -:wq - -x - - diff --git a/accounts--dyh/accounts质量分析.docx b/accounts--dyh/accounts质量分析.docx deleted file mode 100644 index 50a57f2..0000000 Binary files a/accounts--dyh/accounts质量分析.docx and /dev/null differ diff --git a/accounts--dyh/admin.py b/accounts--dyh/admin.py deleted file mode 100644 index ae53414..0000000 --- a/accounts--dyh/admin.py +++ /dev/null @@ -1,103 +0,0 @@ -# 导入Django表单模块 -from django import forms -# 导入Django默认用户管理类 -from django.contrib.auth.admin import UserAdmin -# 导入用户修改表单 -from django.contrib.auth.forms import UserChangeForm -# 导入用户名字段 -from django.contrib.auth.forms import UsernameField -# 导入国际化翻译函数 -from django.utils.translation import gettext_lazy as _ - -# 注册模型到管理后台 -# 导入自定义用户模型 -from .models import BlogUser - - -class BlogUserCreationForm(forms.ModelForm): - """自定义用户创建表单,用于管理员后台创建用户""" - - # 密码字段1 - 输入密码 - password1 = forms.CharField( - label=_('password'), # 字段标签:密码 - widget=forms.PasswordInput # 使用密码输入控件 - ) - # 密码字段2 - 确认密码 - password2 = forms.CharField( - label=_('Enter password again'), # 字段标签:再次输入密码 - widget=forms.PasswordInput # 使用密码输入控件 - ) - - class Meta: - # 指定使用的模型 - model = BlogUser - # 表单包含的字段:仅邮箱 - fields = ('email',) - - def clean_password2(self): - """清理和验证密码确认字段""" - # 从已清理数据中获取密码1 - password1 = self.cleaned_data.get("password1") - # 从已清理数据中获取密码2 - password2 = self.cleaned_data.get("password2") - # 检查两个密码是否存在且匹配 - if password1 and password2 and password1 != password2: - # 如果不匹配,抛出验证错误 - raise forms.ValidationError(_("passwords do not match")) - # 返回验证通过的密码2 - return password2 - - def save(self, commit=True): - """保存用户实例,处理密码哈希""" - # 调用父类save方法但不立即提交到数据库 - user = super().save(commit=False) - # 使用Django的密码哈希方法设置密码 - user.set_password(self.cleaned_data["password1"]) - # 如果设置为立即提交 - if commit: - # 设置用户来源为管理员站点 - user.source = 'adminsite' - # 保存用户到数据库 - user.save() - # 返回用户实例 - return user - - -class BlogUserChangeForm(UserChangeForm): - """自定义用户信息修改表单""" - - class Meta: - # 指定使用的模型 - model = BlogUser - # 包含所有字段 - fields = '__all__' - # 字段类映射,用户名使用特定字段类 - field_classes = {'username': UsernameField} - - def __init__(self, *args, **kwargs): - """初始化表单""" - # 调用父类初始化方法 - super().__init__(*args, **kwargs) - - -class BlogUserAdmin(UserAdmin): - """自定义用户管理类,配置Django管理后台的用户界面""" - - # 指定修改用户时使用的表单 - form = BlogUserChangeForm - # 指定创建用户时使用的表单 - add_form = BlogUserCreationForm - # 列表页面显示的字段 - list_display = ( - 'id', # 用户ID - 'nickname', # 昵称 - 'username', # 用户名 - 'email', # 邮箱 - 'last_login', # 最后登录时间 - 'date_joined', # 注册时间 - 'source' # 用户来源 - ) - # 列表中可作为链接点击的字段 - list_display_links = ('id', 'username') - # 默认排序字段:按ID降序排列 - ordering = ('-id',) \ No newline at end of file diff --git a/accounts--dyh/apps.py b/accounts--dyh/apps.py deleted file mode 100644 index 75f1ad1..0000000 --- a/accounts--dyh/apps.py +++ /dev/null @@ -1,9 +0,0 @@ -# 导入Django应用配置基类 -from django.apps import AppConfig - - -class AccountsConfig(AppConfig): - """账户应用的配置类""" - - # 应用的名称(Python路径) - name = 'accounts' \ No newline at end of file diff --git a/accounts--dyh/forms.py b/accounts--dyh/forms.py deleted file mode 100644 index a7bada7..0000000 --- a/accounts--dyh/forms.py +++ /dev/null @@ -1,194 +0,0 @@ -# 导入Django表单模块 -from django import forms -# 导入用户模型获取函数和密码验证工具 -from django.contrib.auth import get_user_model, password_validation -# 导入Django内置认证表单 -from django.contrib.auth.forms import AuthenticationForm, UserCreationForm -# 导入验证异常 -from django.core.exceptions import ValidationError -# 导入表单控件 -from django.forms import widgets -# 导入国际化翻译函数 -from django.utils.translation import gettext_lazy as _ -# 导入工具函数 -from . import utils -# 导入用户模型 -from .models import BlogUser - - -class LoginForm(AuthenticationForm): - """用户登录表单,继承自Django内置认证表单""" - - def __init__(self, *args, **kwargs): - """初始化表单,自定义字段控件""" - # 调用父类初始化方法 - super(LoginForm, self).__init__(*args, **kwargs) - # 自定义用户名字段控件:文本输入框,带占位符和CSS类 - self.fields['username'].widget = widgets.TextInput( - attrs={ - 'placeholder': "username", # 输入框占位符文本 - "class": "form-control" # CSS类名,用于样式 - } - ) - # 自定义密码字段控件:密码输入框,带占位符和CSS类 - self.fields['password'].widget = widgets.PasswordInput( - attrs={ - 'placeholder': "password", # 输入框占位符文本 - "class": "form-control" # CSS类名,用于样式 - } - ) - - -class RegisterForm(UserCreationForm): - """用户注册表单,继承自Django内置用户创建表单""" - - def __init__(self, *args, **kwargs): - """初始化表单,自定义所有字段的控件""" - # 调用父类初始化方法 - super(RegisterForm, self).__init__(*args, **kwargs) - # 自定义用户名字段控件 - self.fields['username'].widget = widgets.TextInput( - attrs={ - 'placeholder': "username", # 占位符:用户名 - "class": "form-control" # CSS类 - } - ) - # 自定义邮箱字段控件 - self.fields['email'].widget = widgets.EmailInput( - attrs={ - 'placeholder': "email", # 占位符:邮箱 - "class": "form-control" # CSS类 - } - ) - # 自定义密码字段控件 - self.fields['password1'].widget = widgets.PasswordInput( - attrs={ - 'placeholder': "password", # 占位符:密码 - "class": "form-control" # CSS类 - } - ) - # 自定义密码确认字段控件 - self.fields['password2'].widget = widgets.PasswordInput( - attrs={ - 'placeholder': "repeat password", # 占位符:重复密码 - "class": "form-control" # CSS类 - } - ) - - def clean_email(self): - """清理和验证邮箱字段,确保邮箱唯一性""" - # 从已清理数据中获取邮箱 - email = self.cleaned_data['email'] - # 检查数据库中是否已存在该邮箱 - if get_user_model().objects.filter(email=email).exists(): - # 如果邮箱已存在,抛出验证错误 - raise ValidationError(_("email already exists")) - # 返回验证通过的邮箱 - return email - - class Meta: - """表单元数据配置""" - # 指定表单关联的模型 - model = get_user_model() - # 表单包含的字段:用户名和邮箱 - fields = ("username", "email") - - -class ForgetPasswordForm(forms.Form): - """忘记密码重置表单""" - - # 新密码字段1 - new_password1 = forms.CharField( - label=_("New password"), # 字段标签:新密码 - widget=forms.PasswordInput( # 使用密码输入控件 - attrs={ - "class": "form-control", # CSS类 - 'placeholder': _("New password") # 占位符:新密码 - } - ), - ) - - # 新密码字段2 - 确认密码 - new_password2 = forms.CharField( - label="确认密码", # 字段标签:确认密码(硬编码中文) - widget=forms.PasswordInput( # 使用密码输入控件 - attrs={ - "class": "form-control", # CSS类 - 'placeholder': _("Confirm password") # 占位符:确认密码 - } - ), - ) - - # 邮箱字段 - email = forms.EmailField( - label='邮箱', # 字段标签:邮箱(硬编码中文) - widget=forms.TextInput( # 使用文本输入控件 - attrs={ - 'class': 'form-control', # CSS类 - 'placeholder': _("Email") # 占位符:邮箱 - } - ), - ) - - # 验证码字段 - code = forms.CharField( - label=_('Code'), # 字段标签:验证码 - widget=forms.TextInput( # 使用文本输入控件 - attrs={ - 'class': 'form-control', # CSS类 - 'placeholder': _("Code") # 占位符:验证码 - } - ), - ) - - def clean_new_password2(self): - """清理和验证密码确认字段""" - # 从原始数据中获取新密码1(不使用cleaned_data因为可能还未验证) - password1 = self.data.get("new_password1") - # 从原始数据中获取新密码2 - password2 = self.data.get("new_password2") - # 检查两个密码是否存在且匹配 - if password1 and password2 and password1 != password2: - # 如果不匹配,抛出验证错误 - raise ValidationError(_("passwords do not match")) - # 使用Django内置密码验证器验证密码强度 - password_validation.validate_password(password2) - # 返回验证通过的密码 - return password2 - - def clean_email(self): - """清理和验证邮箱字段,确保邮箱已注册""" - # 从已清理数据中获取邮箱 - user_email = self.cleaned_data.get("email") - # 检查数据库中是否存在该邮箱的用户 - if not BlogUser.objects.filter(email=user_email).exists(): - # 安全提示:这里会暴露邮箱是否注册,可根据安全需求修改 - # 如果邮箱不存在,抛出验证错误 - raise ValidationError(_("email does not exist")) - # 返回验证通过的邮箱 - return user_email - - 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 - - -class ForgetPasswordCodeForm(forms.Form): - """获取忘记密码验证码的表单""" - - # 邮箱字段 - email = forms.EmailField( - label=_('Email'), # 字段标签:邮箱 - ) \ No newline at end of file diff --git a/accounts--dyh/models.py b/accounts--dyh/models.py deleted file mode 100644 index 0ec9c5c..0000000 --- a/accounts--dyh/models.py +++ /dev/null @@ -1,72 +0,0 @@ -# 导入Django抽象用户基类 -from django.contrib.auth.models import AbstractUser -# 导入Django数据库模型 -from django.db import models -# 导入URL反向解析函数 -from django.urls import reverse -# 导入当前时间获取函数 -from django.utils.timezone import now -# 导入国际化翻译函数 -from django.utils.translation import gettext_lazy as _ -# 导入获取当前站点的工具函数 -from djangoblog.utils import get_current_site - - -# 在这里创建模型 - -class BlogUser(AbstractUser): - """自定义用户模型,继承自Django抽象用户基类""" - - # 昵称字段,最大长度100字符,允许为空 - nickname = models.CharField( - _('nick name'), # 字段显示名称:昵称 - max_length=100, # 最大长度 - blank=True # 允许为空 - ) - # 创建时间字段,默认值为当前时间 - creation_time = models.DateTimeField( - _('creation time'), # 字段显示名称:创建时间 - default=now # 默认值:当前时间 - ) - # 最后修改时间字段,默认值为当前时间 - last_modify_time = models.DateTimeField( - _('last modify time'), # 字段显示名称:最后修改时间 - default=now # 默认值:当前时间 - ) - # 用户来源字段,最大长度100字符,允许为空 - source = models.CharField( - _('create source'), # 字段显示名称:创建来源 - max_length=100, # 最大长度 - blank=True # 允许为空 - ) - - def get_absolute_url(self): - """获取用户的绝对URL,用于生成用户详情页链接""" - # 使用reverse反向解析URL,传入用户名作为参数 - return reverse( - 'blog:author_detail', # URL模式名称 - kwargs={'author_name': self.username} # URL参数:作者用户名 - ) - - def __str__(self): - """对象的字符串表示,返回邮箱地址""" - return self.email - - def get_full_url(self): - """获取用户的完整URL(包含域名)""" - # 获取当前站点域名 - site = get_current_site().domain - # 构建完整URL:https://域名 + 用户详情页路径 - url = "https://{site}{path}".format( - site=site, # 站点域名 - path=self.get_absolute_url() # 用户详情页路径 - ) - # 返回完整URL - return url - - class Meta: - """模型的元数据配置""" - ordering = ['-id'] # 默认排序:按ID降序排列 - verbose_name = _('user') # 单数显示名称:用户 - verbose_name_plural = verbose_name # 复数显示名称:与单数相同 - get_latest_by = 'id' # 获取最新记录的依据字段:ID \ No newline at end of file diff --git a/accounts--dyh/tests.py b/accounts--dyh/tests.py deleted file mode 100644 index 32c2b7e..0000000 --- a/accounts--dyh/tests.py +++ /dev/null @@ -1,294 +0,0 @@ -# 导入Django测试客户端、请求工厂、测试用例 -from django.test import Client, RequestFactory, TestCase -# 导入URL反向解析 -from django.urls import reverse -# 导入时区工具 -from django.utils import timezone -# 导入延迟翻译函数 -from django.utils.translation import gettext_lazy as _ - -# 导入账户模型 -from accounts.models import BlogUser -# 导入博客模型 -from blog.models import Article, Category -# 导入项目工具函数 -from djangoblog.utils import * -# 导入当前应用的工具函数 -from . import utils - - -# 在这里创建测试 - -class AccountTest(TestCase): - """账户功能测试类""" - - def setUp(self): - """测试前置设置,每个测试方法执行前都会调用""" - # 创建测试客户端 - self.client = Client() - # 创建请求工厂 - self.factory = RequestFactory() - # 创建测试用户 - self.blog_user = BlogUser.objects.create_user( - username="test", # 用户名 - email="admin@admin.com", # 邮箱 - password="12345678" # 密码 - ) - # 设置新测试密码 - self.new_test = "xxx123--=" - - def test_validate_account(self): - """测试账户验证功能""" - # 获取当前站点 - site = get_current_site().domain - # 创建超级用户 - user = BlogUser.objects.create_superuser( - email="liangliangyy1@gmail.com", # 邮箱 - username="liangliangyy1", # 用户名 - password="qwer!@#$ggg" # 密码 - ) - # 从数据库获取刚创建的用户 - testuser = BlogUser.objects.get(username='liangliangyy1') - - # 尝试登录 - loginresult = self.client.login( - username='liangliangyy1', # 用户名 - password='qwer!@#$ggg' # 密码 - ) - # 断言登录成功 - self.assertEqual(loginresult, True) - # 访问管理员页面 - response = self.client.get('/admin/') - # 断言页面访问成功 - self.assertEqual(response.status_code, 200) - - # 创建分类 - category = Category() - category.name = "categoryaaa" # 分类名称 - category.creation_time = timezone.now() # 创建时间 - category.last_modify_time = timezone.now() # 最后修改时间 - category.save() - - # 创建文章 - article = Article() - article.title = "nicetitleaaa" # 文章标题 - article.body = "nicecontentaaa" # 文章内容 - article.author = user # 文章作者 - article.category = category # 文章分类 - article.type = 'a' # 文章类型 - article.status = 'p' # 文章状态:发布 - article.save() - - # 访问文章管理页面 - response = self.client.get(article.get_admin_url()) - # 断言页面访问成功 - self.assertEqual(response.status_code, 200) - - def test_validate_register(self): - """测试用户注册功能""" - # 断言注册前邮箱不存在 - self.assertEquals( - 0, len(BlogUser.objects.filter(email='user123@user.com'))) - # 发送注册POST请求 - response = self.client.post(reverse('account:register'), { - 'username': 'user1233', # 用户名 - 'email': 'user123@user.com', # 邮箱 - 'password1': 'password123!q@wE#R$T', # 密码 - 'password2': 'password123!q@wE#R$T', # 确认密码 - }) - # 断言注册后邮箱存在 - 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))) - # 构建验证URL路径 - path = reverse('accounts:result') - # 构建完整验证URL - url = '{path}?type=validation&id={id}&sign={sign}'.format( - path=path, # 路径 - id=user.id, # 用户ID - sign=sign # 签名 - ) - # 访问验证URL - response = self.client.get(url) - # 断言页面访问成功 - self.assertEqual(response.status_code, 200) - - # 登录用户 - self.client.login(username='user1233', password='password123!q@wE#R$T') - # 获取用户并设置为管理员 - user = BlogUser.objects.filter(email='user123@user.com')[0] - user.is_superuser = True # 设置为超级用户 - user.is_staff = True # 设置为工作人员 - user.save() - # 删除侧边栏缓存 - delete_sidebar_cache() - - # 创建分类 - category = Category() - category.name = "categoryaaa" # 分类名称 - category.creation_time = timezone.now() # 创建时间 - category.last_modify_time = timezone.now() # 最后修改时间 - category.save() - - # 创建文章 - article = Article() - article.category = category # 文章分类 - article.title = "nicetitle333" # 文章标题 - article.body = "nicecontentttt" # 文章内容 - article.author = user # 文章作者 - article.type = 'a' # 文章类型 - article.status = 'p' # 文章状态:发布 - article.save() - - # 访问文章管理页面 - response = self.client.get(article.get_admin_url()) - # 断言页面访问成功 - self.assertEqual(response.status_code, 200) - - # 登出用户 - response = self.client.get(reverse('account:logout')) - # 断言登出成功(重定向状态码) - self.assertIn(response.status_code, [301, 302, 200]) - - # 再次访问文章管理页面(应该被重定向到登录页) - response = self.client.get(article.get_admin_url()) - # 断言被重定向 - self.assertIn(response.status_code, [301, 302, 200]) - - # 使用错误密码尝试登录 - response = self.client.post(reverse('account:login'), { - 'username': 'user1233', # 用户名 - 'password': 'password123' # 错误密码 - }) - # 断言登录失败(重定向状态码) - self.assertIn(response.status_code, [301, 302, 200]) - - # 再次访问文章管理页面(应该仍然被重定向) - response = self.client.get(article.get_admin_url()) - # 断言被重定向 - self.assertIn(response.status_code, [301, 302, 200]) - - def test_verify_email_code(self): - """测试邮箱验证码功能""" - # 测试邮箱 - to_email = "admin@admin.com" - # 生成验证码 - code = generate_code() - # 设置验证码到缓存 - utils.set_code(to_email, code) - # 发送验证邮件 - utils.send_verify_email(to_email, code) - - # 验证正确验证码 - err = utils.verify("admin@admin.com", code) - # 断言验证成功(返回None) - self.assertEqual(err, None) - - # 验证错误验证码 - err = utils.verify("admin@123.com", code) - # 断言验证失败(返回错误信息字符串) - self.assertEqual(type(err), str) - - def test_forget_password_email_code_success(self): - """测试成功获取忘记密码验证码""" - # 发送获取验证码的POST请求 - resp = self.client.post( - path=reverse("account:forget_password_code"), # URL路径 - data=dict(email="admin@admin.com") # 请求数据:邮箱 - ) - - # 断言响应状态码为200 - self.assertEqual(resp.status_code, 200) - # 断言响应内容为"ok" - self.assertEqual(resp.content.decode("utf-8"), "ok") - - def test_forget_password_email_code_fail(self): - """测试获取忘记密码验证码失败情况""" - # 发送空数据的POST请求 - resp = self.client.post( - path=reverse("account:forget_password_code"), # URL路径 - data=dict() # 空数据 - ) - # 断言返回错误信息 - self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") - - # 发送无效邮箱的POST请求 - resp = self.client.post( - path=reverse("account:forget_password_code"), # URL路径 - data=dict(email="admin@com") # 无效邮箱格式 - ) - # 断言返回错误信息 - self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") - - def test_forget_password_email_success(self): - """测试成功重置密码""" - # 生成验证码 - code = generate_code() - # 设置验证码到缓存 - utils.set_code(self.blog_user.email, code) - # 准备请求数据 - data = dict( - new_password1=self.new_test, # 新密码 - new_password2=self.new_test, # 确认密码 - email=self.blog_user.email, # 邮箱 - code=code, # 验证码 - ) - # 发送重置密码的POST请求 - resp = self.client.post( - path=reverse("account:forget_password"), # URL路径 - data=data # 请求数据 - ) - # 断言重定向响应(状态码302) - self.assertEqual(resp.status_code, 302) - - # 验证用户密码是否修改成功 - blog_user = BlogUser.objects.filter( - email=self.blog_user.email, - ).first() # 类型注解:BlogUser - # 断言用户存在 - self.assertNotEqual(blog_user, None) - # 断言新密码验证通过 - self.assertEqual(blog_user.check_password(data["new_password1"]), True) - - def test_forget_password_email_not_user(self): - """测试重置密码时邮箱不存在的情况""" - # 准备请求数据(不存在的邮箱) - data = dict( - new_password1=self.new_test, # 新密码 - new_password2=self.new_test, # 确认密码 - email="123@123.com", # 不存在的邮箱 - code="123456", # 验证码 - ) - # 发送重置密码的POST请求 - resp = self.client.post( - path=reverse("account:forget_password"), # URL路径 - data=data # 请求数据 - ) - - # 断言返回表单页面(状态码200) - self.assertEqual(resp.status_code, 200) - - def test_forget_password_email_code_error(self): - """测试重置密码时验证码错误的情况""" - # 生成验证码 - code = generate_code() - # 设置验证码到缓存 - utils.set_code(self.blog_user.email, code) - # 准备请求数据(错误的验证码) - data = dict( - new_password1=self.new_test, # 新密码 - new_password2=self.new_test, # 确认密码 - email=self.blog_user.email, # 邮箱 - code="111111", # 错误的验证码 - ) - # 发送重置密码的POST请求 - resp = self.client.post( - path=reverse("account:forget_password"), # URL路径 - data=data # 请求数据 - ) - - # 断言返回表单页面(状态码200) - self.assertEqual(resp.status_code, 200) \ No newline at end of file diff --git a/accounts--dyh/urls.py b/accounts--dyh/urls.py deleted file mode 100644 index 4374124..0000000 --- a/accounts--dyh/urls.py +++ /dev/null @@ -1,53 +0,0 @@ -# 导入URL路径函数 -from django.urls import path -# 导入正则URL路径函数(兼容老版本) -from django.urls import re_path - -# 导入当前应用的视图 -from . import views -# 导入登录表单 -from .forms import LoginForm - -# 应用命名空间,用于URL反向解析 -app_name = "accounts" - -# URL模式列表 -urlpatterns = [ - # 登录URL - 使用正则表达式匹配 /login/ 路径 - re_path(r'^login/$', - # 使用LoginView视图类,登录成功后重定向到首页 - views.LoginView.as_view(success_url='/'), - name='login', # URL名称:login - # 传入额外参数:指定认证表单类 - kwargs={'authentication_form': LoginForm}), - - # 注册URL - 使用正则表达式匹配 /register/ 路径 - re_path(r'^register/$', - # 使用RegisterView视图类,注册成功后重定向到首页 - views.RegisterView.as_view(success_url="/"), - name='register'), # URL名称:register - - # 登出URL - 使用正则表达式匹配 /logout/ 路径 - re_path(r'^logout/$', - # 使用LogoutView视图类 - views.LogoutView.as_view(), - name='logout'), # URL名称:logout - - # 账户结果页面URL - 使用path匹配固定路径 - path(r'account/result.html', - # 使用account_result函数视图 - views.account_result, - name='result'), # URL名称:result - - # 忘记密码URL - 使用正则表达式匹配 /forget_password/ 路径 - re_path(r'^forget_password/$', - # 使用ForgetPasswordView视图类 - views.ForgetPasswordView.as_view(), - name='forget_password'), # URL名称:forget_password - - # 获取忘记密码验证码URL - 使用正则表达式匹配 /forget_password_code/ 路径 - re_path(r'^forget_password_code/$', - # 使用ForgetPasswordEmailCode视图类 - views.ForgetPasswordEmailCode.as_view(), - name='forget_password_code'), # URL名称:forget_password_code -] \ No newline at end of file diff --git a/accounts--dyh/user_login_backend.py b/accounts--dyh/user_login_backend.py deleted file mode 100644 index 7179809..0000000 --- a/accounts--dyh/user_login_backend.py +++ /dev/null @@ -1,42 +0,0 @@ -# 导入获取用户模型的函数 -from django.contrib.auth import get_user_model -# 导入模型后端认证基类 -from django.contrib.auth.backends import ModelBackend - - -class EmailOrUsernameModelBackend(ModelBackend): - """ - 自定义认证后端,允许使用用户名或邮箱登录 - 继承自Django的ModelBackend - """ - - def authenticate(self, request, username=None, password=None, **kwargs): - """用户认证方法""" - # 检查用户名中是否包含@符号(判断是否为邮箱) - if '@' in username: - # 如果是邮箱,设置查询参数为邮箱 - kwargs = {'email': username} - else: - # 如果是用户名,设置查询参数为用户名 - kwargs = {'username': username} - try: - # 根据查询参数获取用户对象 - user = get_user_model().objects.get(**kwargs) - # 检查密码是否正确 - if user.check_password(password): - # 密码正确,返回用户对象 - return user - # 捕获用户不存在的异常 - except get_user_model().DoesNotExist: - # 用户不存在,返回None - return None - - def get_user(self, username): - """根据用户ID获取用户对象""" - try: - # 根据主键(用户ID)获取用户对象 - return get_user_model().objects.get(pk=username) - # 捕获用户不存在的异常 - except get_user_model().DoesNotExist: - # 用户不存在,返回None - return None \ No newline at end of file diff --git a/accounts--dyh/utils.py b/accounts--dyh/utils.py deleted file mode 100644 index 607b172..0000000 --- a/accounts--dyh/utils.py +++ /dev/null @@ -1,70 +0,0 @@ -# 导入类型提示模块 -import typing -# 导入时间间隔类 -from datetime import timedelta - -# 导入Django缓存框架 -from django.core.cache import cache -# 导入国际化翻译函数 -from django.utils.translation import gettext -# 导入延迟翻译函数 -from django.utils.translation import gettext_lazy as _ - -# 导入发送邮件的工具函数 -from djangoblog.utils import send_email - -# 验证码有效期:5分钟 -_code_ttl = timedelta(minutes=5) - - -def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): - """发送验证邮件,用于密码重置等场景 - - Args: - to_mail: 接收邮件的邮箱地址 - subject: 邮件主题,默认为"验证邮箱" - code: 验证码内容 - """ - # 构建邮件HTML内容,包含验证码信息 - html_content = _( - # 翻译文本:您正在重置密码,验证码是:{code},5分钟内有效,请妥善保管 - "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " - "properly" - ) % {'code': code} # 将code插入到格式化字符串中 - # 调用发送邮件函数 - send_email([to_mail], subject, html_content) - - -def verify(email: str, code: str) -> typing.Optional[str]: - """验证验证码是否有效 - - Args: - email: 请求验证的邮箱地址 - code: 用户输入的验证码 - - Return: - 如果验证失败返回错误信息字符串,验证成功返回None - - Note: - 这里的错误处理不太合理,应该采用raise抛出异常 - 否则调用方也需要对error进行处理 - """ - # 从缓存中获取该邮箱对应的验证码 - cache_code = get_code(email) - # 比较缓存中的验证码和用户输入的验证码 - if cache_code != code: - # 如果不匹配,返回错误信息 - return gettext("Verification code error") - # 验证成功,返回None - - -def set_code(email: str, code: str): - """将验证码设置到缓存中""" - # 使用cache.set方法,key为邮箱,value为验证码,设置过期时间 - cache.set(email, code, _code_ttl.seconds) - - -def get_code(email: str) -> typing.Optional[str]: - """从缓存中获取验证码""" - # 使用cache.get方法,根据邮箱获取验证码 - return cache.get(email) \ No newline at end of file diff --git a/accounts--dyh/views.py b/accounts--dyh/views.py deleted file mode 100644 index 35ceaf3..0000000 --- a/accounts--dyh/views.py +++ /dev/null @@ -1,327 +0,0 @@ -# 导入日志模块 -import logging -# 导入延迟翻译函数 -from django.utils.translation import gettext_lazy as _ -# 导入Django设置 -from django.conf import settings -# 导入Django认证框架 -from django.contrib import auth -# 导入重定向字段名常量 -from django.contrib.auth import REDIRECT_FIELD_NAME -# 导入获取用户模型的函数 -from django.contrib.auth import get_user_model -# 导入登出函数 -from django.contrib.auth import logout -# 导入Django内置认证表单 -from django.contrib.auth.forms import AuthenticationForm -# 导入密码哈希函数 -from django.contrib.auth.hashers import make_password -# 导入HTTP响应重定向和禁止访问响应 -from django.http import HttpResponseRedirect, HttpResponseForbidden -# 导入HTTP请求类型 -from django.http.request import HttpRequest -# 导入HTTP响应类型 -from django.http.response import HttpResponse -# 导入快捷函数:获取对象或404错误 -from django.shortcuts import get_object_or_404 -# 导入快捷函数:渲染模板 -from django.shortcuts import render -# 导入URL反向解析 -from django.urls import reverse -# 导入方法装饰器 -from django.utils.decorators import method_decorator -# 导入URL安全验证函数 -from django.utils.http import url_has_allowed_host_and_scheme -# 导入基于类的视图基类 -from django.views import View -# 导入禁止缓存装饰器 -from django.views.decorators.cache import never_cache -# 导入CSRF保护装饰器 -from django.views.decorators.csrf import csrf_protect -# 导入敏感参数保护装饰器 -from django.views.decorators.debug import sensitive_post_parameters -# 导入通用视图类 -from django.views.generic import FormView, RedirectView - -# 导入项目工具函数 -from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache -# 导入当前应用的工具函数 -from . import utils -# 导入自定义表单 -from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm -# 导入用户模型 -from .models import BlogUser - -# 获取当前模块的日志器 -logger = logging.getLogger(__name__) - - -# 在这里创建视图 - -class RegisterView(FormView): - """用户注册视图""" - - # 指定使用的表单类 - form_class = RegisterForm - # 指定使用的模板 - template_name = 'account/registration_form.html' - - @method_decorator(csrf_protect) # CSRF保护装饰器 - def dispatch(self, *args, **kwargs): - """处理请求分发""" - # 调用父类的dispatch方法 - return super(RegisterView, self).dispatch(*args, **kwargs) - - def form_valid(self, form): - """处理表单验证通过的情况""" - # 检查表单是否有效 - if form.is_valid(): - # 保存表单数据但不提交到数据库(commit=False) - user = form.save(False) - # 设置用户为未激活状态(需要邮箱验证) - user.is_active = False - # 设置用户来源为注册页面 - user.source = 'Register' - # 保存用户到数据库 - user.save(True) - # 获取当前站点域名 - site = get_current_site().domain - # 生成验证签名:对密钥+用户ID进行双重SHA256哈希 - sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) - - # 如果是调试模式,使用本地地址 - if settings.DEBUG: - site = '127.0.0.1:8000' - # 获取结果页面的URL路径 - path = reverse('account:result') - # 构建完整的验证URL - url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( - site=site, # 站点地址 - path=path, # 结果页面路径 - id=user.id, # 用户ID - sign=sign # 验证签名 - ) - - # 构建邮件内容 - content = """ -

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

- - {url} - - 再次感谢您! -
- 如果上面链接无法打开,请将此链接复制至浏览器。 - {url} - """.format(url=url) - # 发送验证邮件 - send_email( - emailto=[user.email], # 收件人邮箱 - title='验证您的电子邮箱', # 邮件标题 - content=content # 邮件内容 - ) - - # 构建注册结果页面URL - url = reverse('accounts:result') + '?type=register&id=' + str(user.id) - # 重定向到结果页面 - return HttpResponseRedirect(url) - else: - # 表单无效,重新渲染表单并显示错误 - return self.render_to_response({'form': form}) - - -class LogoutView(RedirectView): - """用户登出视图""" - - # 登出后重定向的URL - url = '/login/' - - @method_decorator(never_cache) # 禁止缓存装饰器 - def dispatch(self, request, *args, **kwargs): - """处理请求分发""" - # 调用父类的dispatch方法 - return super(LogoutView, self).dispatch(request, *args, **kwargs) - - def get(self, request, *args, **kwargs): - """处理GET请求(登出操作)""" - # 调用Django登出函数 - logout(request) - # 删除侧边栏缓存 - delete_sidebar_cache() - # 调用父类的get方法进行重定向 - return super(LogoutView, self).get(request, *args, **kwargs) - - -class LoginView(FormView): - """用户登录视图""" - - # 指定使用的表单类 - form_class = LoginForm - # 指定使用的模板 - template_name = 'account/login.html' - # 登录成功后的重定向URL - success_url = '/' - # 重定向字段名 - redirect_field_name = REDIRECT_FIELD_NAME - # 登录会话保持时间:一个月(秒数) - login_ttl = 2626560 - - # 方法装饰器:保护敏感参数、CSRF保护、禁止缓存 - @method_decorator(sensitive_post_parameters('password')) - @method_decorator(csrf_protect) - @method_decorator(never_cache) - def dispatch(self, request, *args, **kwargs): - """处理请求分发""" - # 调用父类的dispatch方法 - return super(LoginView, self).dispatch(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - # 从GET参数中获取重定向URL - redirect_to = self.request.GET.get(self.redirect_field_name) - # 如果重定向URL为空,设置为首页 - if redirect_to is None: - redirect_to = '/' - # 将重定向URL添加到上下文数据中 - kwargs['redirect_to'] = redirect_to - # 调用父类方法获取基础上下文数据 - return super(LoginView, self).get_context_data(**kwargs) - - def form_valid(self, form): - """处理表单验证通过的情况""" - # 创建认证表单实例(使用POST数据) - form = AuthenticationForm(data=self.request.POST, request=self.request) - - # 检查表单是否有效 - if form.is_valid(): - # 删除侧边栏缓存 - delete_sidebar_cache() - # 记录日志 - logger.info(self.redirect_field_name) - - # 登录用户 - auth.login(self.request, form.get_user()) - # 如果用户选择了"记住我" - if self.request.POST.get("remember"): - # 设置会话过期时间为一个月 - self.request.session.set_expiry(self.login_ttl) - # 调用父类的form_valid方法(会处理重定向) - return super(LoginView, self).form_valid(form) - else: - # 表单无效,重新渲染表单并显示错误 - return self.render_to_response({'form': form}) - - def get_success_url(self): - """获取登录成功后的重定向URL""" - # 从POST数据中获取重定向URL - redirect_to = self.request.POST.get(self.redirect_field_name) - # 验证重定向URL是否安全(同源策略) - if not url_has_allowed_host_and_scheme( - url=redirect_to, - allowed_hosts=[self.request.get_host()]): - # 如果不安全,使用默认的成功URL - redirect_to = self.success_url - # 返回重定向URL - return redirect_to - - -def account_result(request): - """账户操作结果页面视图函数""" - # 从GET参数获取操作类型 - type = request.GET.get('type') - # 从GET参数获取用户ID - id = request.GET.get('id') - - # 根据ID获取用户对象,如果不存在返回404 - user = get_object_or_404(get_user_model(), id=id) - # 记录日志 - logger.info(type) - # 如果用户已激活,重定向到首页 - if user.is_active: - return HttpResponseRedirect('/') - # 检查操作类型是否为注册或验证 - if type and type in ['register', 'validation']: - # 如果是注册操作 - if type == 'register': - # 设置注册成功的内容和标题 - content = ''' - 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 - ''' - title = '注册成功' - else: - # 生成验证签名 - c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) - # 从GET参数获取签名 - sign = request.GET.get('sign') - # 验证签名是否正确 - if sign != c_sign: - # 签名错误,返回禁止访问 - return HttpResponseForbidden() - # 激活用户账户 - user.is_active = True - # 保存用户 - user.save() - # 设置验证成功的内容和标题 - content = ''' - 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 - ''' - title = '验证成功' - # 渲染结果页面 - return render(request, 'account/result.html', { - 'title': title, # 页面标题 - 'content': content # 页面内容 - }) - else: - # 无效的操作类型,重定向到首页 - return HttpResponseRedirect('/') - - -class ForgetPasswordView(FormView): - """忘记密码重置视图""" - - # 指定使用的表单类 - form_class = ForgetPasswordForm - # 指定使用的模板 - template_name = 'account/forget_password.html' - - def form_valid(self, form): - """处理表单验证通过的情况""" - # 检查表单是否有效 - if form.is_valid(): - # 根据邮箱获取用户对象 - blog_user = BlogUser.objects.filter( - email=form.cleaned_data.get("email") - ).get() - # 使用新密码的哈希值更新用户密码 - blog_user.password = make_password(form.cleaned_data["new_password2"]) - # 保存用户 - blog_user.save() - # 重定向到登录页面 - return HttpResponseRedirect('/login/') - else: - # 表单无效,重新渲染表单并显示错误 - return self.render_to_response({'form': form}) - - -class ForgetPasswordEmailCode(View): - """获取忘记密码验证码的API视图""" - - def post(self, request: HttpRequest): - """处理POST请求""" - # 创建表单实例并验证数据 - form = ForgetPasswordCodeForm(request.POST) - # 检查表单是否有效 - if not form.is_valid(): - # 表单无效,返回错误响应 - return HttpResponse("错误的邮箱") - # 从已验证数据中获取邮箱 - to_email = form.cleaned_data["email"] - - # 生成验证码 - code = generate_code() - # 发送验证邮件 - utils.send_verify_email(to_email, code) - # 将验证码保存到缓存 - utils.set_code(to_email, code) - - # 返回成功响应 - return HttpResponse("ok") \ No newline at end of file diff --git a/accounts--dyh/__init__.py b/blog-lsh(优化后)/__init__.py similarity index 100% rename from accounts--dyh/__init__.py rename to blog-lsh(优化后)/__init__.py diff --git a/blog-lsh/admin.py b/blog-lsh(优化后)/admin.py similarity index 52% rename from blog-lsh/admin.py rename to blog-lsh(优化后)/admin.py index 0f61314..46c3420 100644 --- a/blog-lsh/admin.py +++ b/blog-lsh(优化后)/admin.py @@ -1,8 +1,3 @@ -""" -LJX: Django后台管理配置模块 -负责blog应用中各模型在Django admin后台的显示和操作配置 -包括文章、分类、标签、友情链接、侧边栏等模型的后台管理界面设置 -""" from django import forms from django.contrib import admin from django.contrib.auth import get_user_model @@ -15,7 +10,6 @@ from .models import Article class ArticleForm(forms.ModelForm): - """LJX: 文章表单类,用于后台文章编辑""" # body = forms.CharField(widget=AdminPagedownWidget()) class Meta: @@ -24,26 +18,21 @@ class ArticleForm(forms.ModelForm): def makr_article_publish(modeladmin, request, queryset): - """LJX: 批量发布文章的管理动作""" queryset.update(status='p') def draft_article(modeladmin, request, queryset): - """LJX: 批量将文章设为草稿的管理动作""" queryset.update(status='d') def close_article_commentstatus(modeladmin, request, queryset): - """LJX: 批量关闭文章评论的管理动作""" queryset.update(comment_status='c') def open_article_commentstatus(modeladmin, request, queryset): - """LJX: 批量开启文章评论的管理动作""" queryset.update(comment_status='o') -# LJX: 设置管理动作的描述信息 makr_article_publish.short_description = _('Publish selected articles') draft_article.short_description = _('Draft selected articles') close_article_commentstatus.short_description = _('Close article comments') @@ -51,33 +40,31 @@ open_article_commentstatus.short_description = _('Open article comments') class ArticlelAdmin(admin.ModelAdmin): - """LJX: 文章模型的后台管理配置""" - list_per_page = 20 # LJX: 每页显示20条记录 - search_fields = ('body', 'title') # LJX: 可搜索的字段 - form = ArticleForm # LJX: 使用自定义表单 + list_per_page = 20 + search_fields = ('body', 'title') + form = ArticleForm list_display = ( 'id', 'title', 'author', - 'link_to_category', # LJX: 自定义字段显示分类链接 + 'link_to_category', 'creation_time', 'views', 'status', 'type', 'article_order') - list_display_links = ('id', 'title') # LJX: 可点击的字段 - list_filter = ('status', 'type', 'category') # LJX: 右侧过滤器 - filter_horizontal = ('tags',) # LJX: 水平选择器用于多对多字段 - exclude = ('creation_time', 'last_modify_time') # LJX: 排除的字段 - view_on_site = True # LJX: 显示"在站点查看"按钮 - actions = [ # LJX: 可用的批量动作 + list_display_links = ('id', 'title') + list_filter = ('status', 'type', 'category') + 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] def link_to_category(self, obj): - """LJX: 自定义方法,显示分类名称并链接到分类编辑页面""" info = (obj.category._meta.app_label, obj.category._meta.model_name) link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,)) return format_html(u'%s' % (link, obj.category.name)) @@ -85,20 +72,17 @@ class ArticlelAdmin(admin.ModelAdmin): link_to_category.short_description = _('category') def get_form(self, request, obj=None, **kwargs): - """LJX: 重写获取表单方法,限制作者只能选择超级用户""" form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs) form.base_fields['author'].queryset = get_user_model( ).objects.filter(is_superuser=True) return form def save_model(self, request, obj, form, change): - """LJX: 重写保存模型方法""" super(ArticlelAdmin, self).save_model(request, obj, form, change) def get_view_on_site_url(self, obj=None): - """LJX: 获取在站点查看的URL""" if obj: - url = obj.get_full_url() # LJX: 使用文章的完整URL + url = obj.get_full_url() return url else: from djangoblog.utils import get_current_site @@ -107,27 +91,22 @@ class ArticlelAdmin(admin.ModelAdmin): class TagAdmin(admin.ModelAdmin): - """LJX: 标签模型的后台管理配置""" - exclude = ('slug', 'last_mod_time', 'creation_time') # LJX: 排除自动生成的字段 + exclude = ('slug', 'last_mod_time', 'creation_time') class CategoryAdmin(admin.ModelAdmin): - """LJX: 分类模型的后台管理配置""" - list_display = ('name', 'parent_category', 'index') # LJX: 列表显示字段 - exclude = ('slug', 'last_mod_time', 'creation_time') # LJX: 排除自动生成的字段 + list_display = ('name', 'parent_category', 'index') + exclude = ('slug', 'last_mod_time', 'creation_time') class LinksAdmin(admin.ModelAdmin): - """LJX: 友情链接模型的后台管理配置""" - exclude = ('last_mod_time', 'creation_time') # LJX: 排除时间字段 + exclude = ('last_mod_time', 'creation_time') class SideBarAdmin(admin.ModelAdmin): - """LJX: 侧边栏模型的后台管理配置""" - list_display = ('name', 'content', 'is_enable', 'sequence') # LJX: 列表显示字段 - exclude = ('last_mod_time', 'creation_time') # LJX: 排除时间字段 + list_display = ('name', 'content', 'is_enable', 'sequence') + exclude = ('last_mod_time', 'creation_time') class BlogSettingsAdmin(admin.ModelAdmin): - """LJX: 博客设置模型的后台管理配置""" - pass \ No newline at end of file + pass diff --git a/blog-lsh(优化后)/apps.py b/blog-lsh(优化后)/apps.py new file mode 100644 index 0000000..7930587 --- /dev/null +++ b/blog-lsh(优化后)/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + name = 'blog' diff --git a/blog-lsh(优化后)/context_processors.py b/blog-lsh(优化后)/context_processors.py new file mode 100644 index 0000000..73e3088 --- /dev/null +++ b/blog-lsh(优化后)/context_processors.py @@ -0,0 +1,43 @@ +import logging + +from django.utils import timezone + +from djangoblog.utils import cache, get_blog_setting +from .models import Category, Article + +logger = logging.getLogger(__name__) + + +def seo_processor(requests): + key = 'seo_processor' + value = cache.get(key) + if value: + return value + else: + logger.info('set processor cache.') + setting = get_blog_setting() + value = { + 'SITE_NAME': setting.site_name, + 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, + 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, + 'SITE_SEO_DESCRIPTION': setting.site_seo_description, + 'SITE_DESCRIPTION': setting.site_description, + 'SITE_KEYWORDS': setting.site_keywords, + 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', + 'ARTICLE_SUB_LENGTH': setting.article_sub_length, + 'nav_category_list': Category.objects.all(), + 'nav_pages': Article.objects.filter( + type='p', + status='p'), + 'OPEN_SITE_COMMENT': setting.open_site_comment, + 'BEIAN_CODE': setting.beian_code, + 'ANALYTICS_CODE': setting.analytics_code, + "BEIAN_CODE_GONGAN": setting.gongan_beiancode, + "SHOW_GONGAN_CODE": setting.show_gongan_code, + "CURRENT_YEAR": timezone.now().year, + "GLOBAL_HEADER": setting.global_header, + "GLOBAL_FOOTER": setting.global_footer, + "COMMENT_NEED_REVIEW": setting.comment_need_review, + } + cache.set(key, value, 60 * 60 * 10) + return value diff --git a/blog-lsh/documents.py b/blog-lsh(优化后)/documents.py similarity index 56% rename from blog-lsh/documents.py rename to blog-lsh(优化后)/documents.py index 2d56e39..0f1db7b 100644 --- a/blog-lsh/documents.py +++ b/blog-lsh(优化后)/documents.py @@ -1,8 +1,3 @@ -""" -LJX: Elasticsearch文档定义模块 -定义Elasticsearch索引的文档结构和数据模型 -用于博客文章的全文搜索和性能监控数据的存储 -""" import time import elasticsearch.client @@ -12,11 +7,9 @@ from elasticsearch_dsl.connections import connections from blog.models import Article -# LJX: 检查是否启用Elasticsearch ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL') if ELASTICSEARCH_ENABLED: - # LJX: 创建Elasticsearch连接 connections.create_connection( hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']]) from elasticsearch import Elasticsearch @@ -26,9 +19,8 @@ if ELASTICSEARCH_ENABLED: c = IngestClient(es) try: - c.get_pipeline('geoip') # LJX: 检查geoip管道是否存在 + c.get_pipeline('geoip') except elasticsearch.exceptions.NotFoundError: - # LJX: 创建geoip处理管道,用于IP地址地理位置解析 c.put_pipeline('geoip', body='''{ "description" : "Add geoip info", "processors" : [ @@ -42,84 +34,72 @@ if ELASTICSEARCH_ENABLED: class GeoIp(InnerDoc): - """LJX: IP地理位置信息内嵌文档""" - continent_name = Keyword() # LJX: 大洲名称 - country_iso_code = Keyword() # LJX: 国家ISO代码 - country_name = Keyword() # LJX: 国家名称 - location = GeoPoint() # LJX: 地理位置坐标 + continent_name = Keyword() + country_iso_code = Keyword() + country_name = Keyword() + location = GeoPoint() class UserAgentBrowser(InnerDoc): - """LJX: 用户代理浏览器信息""" - Family = Keyword() # LJX: 浏览器家族 - Version = Keyword() # LJX: 浏览器版本 + Family = Keyword() + Version = Keyword() class UserAgentOS(UserAgentBrowser): - """LJX: 用户代理操作系统信息""" pass class UserAgentDevice(InnerDoc): - """LJX: 用户代理设备信息""" - Family = Keyword() # LJX: 设备家族 - Brand = Keyword() # LJX: 设备品牌 - Model = Keyword() # LJX: 设备型号 + Family = Keyword() + Brand = Keyword() + Model = Keyword() class UserAgent(InnerDoc): - """LJX: 完整的用户代理信息""" - browser = Object(UserAgentBrowser, required=False) # LJX: 浏览器信息 - os = Object(UserAgentOS, required=False) # LJX: 操作系统信息 - device = Object(UserAgentDevice, required=False) # LJX: 设备信息 - string = Text() # LJX: 原始用户代理字符串 - is_bot = Boolean() # LJX: 是否是爬虫 + browser = Object(UserAgentBrowser, required=False) + os = Object(UserAgentOS, required=False) + device = Object(UserAgentDevice, required=False) + string = Text() + is_bot = Boolean() class ElapsedTimeDocument(Document): - """LJX: 性能监控耗时文档,记录页面加载时间等性能数据""" - url = Keyword() # LJX: 请求URL - time_taken = Long() # LJX: 耗时(毫秒) - log_datetime = Date() # LJX: 日志时间 - ip = Keyword() # LJX: IP地址 - geoip = Object(GeoIp, required=False) # LJX: 地理位置信息 - useragent = Object(UserAgent, required=False) # LJX: 用户代理信息 + url = Keyword() + time_taken = Long() + log_datetime = Date() + ip = Keyword() + geoip = Object(GeoIp, required=False) + useragent = Object(UserAgent, required=False) class Index: - """LJX: 索引配置""" - name = 'performance' # LJX: 索引名称 + name = 'performance' settings = { - "number_of_shards": 1, # LJX: 分片数量 - "number_of_replicas": 0 # LJX: 副本数量 + "number_of_shards": 1, + "number_of_replicas": 0 } class Meta: - doc_type = 'ElapsedTime' # LJX: 文档类型 + doc_type = 'ElapsedTime' class ElaspedTimeDocumentManager: - """LJX: 性能监控文档管理器""" @staticmethod def build_index(): - """LJX: 构建性能监控索引""" from elasticsearch import Elasticsearch client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) res = client.indices.exists(index="performance") if not res: - ElapsedTimeDocument.init() # LJX: 初始化索引 + ElapsedTimeDocument.init() @staticmethod def delete_index(): - """LJX: 删除性能监控索引""" 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): - """LJX: 创建性能监控记录""" ElaspedTimeDocumentManager.build_index() - # LJX: 构建用户代理信息对象 ua = UserAgent() ua.browser = UserAgentBrowser() ua.browser.Family = useragent.browser.family @@ -136,13 +116,12 @@ class ElaspedTimeDocumentManager: ua.string = useragent.ua_string ua.is_bot = useragent.is_bot - # LJX: 创建文档并保存,使用geoip管道处理IP地址 doc = ElapsedTimeDocument( meta={ 'id': int( round( time.time() * - 1000)) # LJX: 使用时间戳作为文档ID + 1000)) }, url=url, time_taken=time_taken, @@ -152,64 +131,57 @@ class ElaspedTimeDocumentManager: class ArticleDocument(Document): - """LJX: 博客文章搜索文档,用于全文搜索""" - body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # LJX: 文章内容,使用IK分词器 - title = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # LJX: 文章标题 - author = Object(properties={ # LJX: 作者信息 + body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') + title = Text(analyzer='ik_max_word', search_analyzer='ik_smart') + author = Object(properties={ 'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), 'id': Integer() }) - category = Object(properties={ # LJX: 分类信息 + category = Object(properties={ 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), 'id': Integer() }) - tags = Object(properties={ # LJX: 标签信息 + tags = Object(properties={ 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), 'id': Integer() }) - # LJX: 文章元数据字段 - pub_time = Date() # LJX: 发布时间 - status = Text() # LJX: 状态 - comment_status = Text() # LJX: 评论状态 - type = Text() # LJX: 类型 - views = Integer() # LJX: 浏览量 - article_order = Integer() # LJX: 文章排序 + pub_time = Date() + status = Text() + comment_status = Text() + type = Text() + views = Integer() + article_order = Integer() class Index: - """LJX: 文章索引配置""" - name = 'blog' # LJX: 索引名称 + name = 'blog' settings = { "number_of_shards": 1, "number_of_replicas": 0 } class Meta: - doc_type = 'Article' # LJX: 文档类型 + doc_type = 'Article' class ArticleDocumentManager(): - """LJX: 文章文档管理器,处理文章搜索索引的创建和更新""" def __init__(self): - self.create_index() # LJX: 初始化时创建索引 + self.create_index() def create_index(self): - """LJX: 创建文章索引""" ArticleDocument.init() def delete_index(self): - """LJX: 删除文章索引""" 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): - """LJX: 将文章对象转换为搜索文档对象""" return [ ArticleDocument( meta={ - 'id': article.id}, # LJX: 使用文章ID作为文档ID + 'id': article.id}, body=article.body, title=article.title, author={ @@ -221,7 +193,7 @@ class ArticleDocumentManager(): tags=[ { 'name': t.name, - 'id': t.id} for t in article.tags.all()], # LJX: 转换标签列表 + 'id': t.id} for t in article.tags.all()], pub_time=article.pub_time, status=article.status, comment_status=article.comment_status, @@ -230,14 +202,12 @@ class ArticleDocumentManager(): article_order=article.article_order) for article in articles] def rebuild(self, articles=None): - """LJX: 重建搜索索引""" ArticleDocument.init() - articles = articles if articles else Article.objects.all() # LJX: 如果没有指定文章,则使用所有文章 + articles = articles if articles else Article.objects.all() docs = self.convert_to_doc(articles) for doc in docs: - doc.save() # LJX: 保存所有文档到索引 + doc.save() def update_docs(self, docs): - """LJX: 更新搜索文档""" for doc in docs: - doc.save() \ No newline at end of file + doc.save() diff --git a/blog-lsh(优化后)/forms.py b/blog-lsh(优化后)/forms.py new file mode 100644 index 0000000..715be76 --- /dev/null +++ b/blog-lsh(优化后)/forms.py @@ -0,0 +1,19 @@ +import logging + +from django import forms +from haystack.forms import SearchForm + +logger = logging.getLogger(__name__) + + +class BlogSearchForm(SearchForm): + querydata = forms.CharField(required=True) + + def search(self): + datas = super(BlogSearchForm, self).search() + if not self.is_valid(): + return self.no_query_found() + + if self.cleaned_data['querydata']: + logger.info(self.cleaned_data['querydata']) + return datas diff --git a/blog-lsh/__init__.py b/blog-lsh(优化后)/management/__init__.py similarity index 100% rename from blog-lsh/__init__.py rename to blog-lsh(优化后)/management/__init__.py diff --git a/blog-lsh/management/__init__.py b/blog-lsh(优化后)/management/commands/__init__.py similarity index 100% rename from blog-lsh/management/__init__.py rename to blog-lsh(优化后)/management/commands/__init__.py diff --git a/blog-lsh/management/commands/build_index.py b/blog-lsh(优化后)/management/commands/build_index.py similarity index 100% rename from blog-lsh/management/commands/build_index.py rename to blog-lsh(优化后)/management/commands/build_index.py diff --git a/blog-lsh/management/commands/build_search_words.py b/blog-lsh(优化后)/management/commands/build_search_words.py similarity index 100% rename from blog-lsh/management/commands/build_search_words.py rename to blog-lsh(优化后)/management/commands/build_search_words.py diff --git a/blog-lsh/management/commands/clear_cache.py b/blog-lsh(优化后)/management/commands/clear_cache.py similarity index 100% rename from blog-lsh/management/commands/clear_cache.py rename to blog-lsh(优化后)/management/commands/clear_cache.py diff --git a/blog-lsh/management/commands/create_testdata.py b/blog-lsh(优化后)/management/commands/create_testdata.py similarity index 100% rename from blog-lsh/management/commands/create_testdata.py rename to blog-lsh(优化后)/management/commands/create_testdata.py diff --git a/blog-lsh/management/commands/ping_baidu.py b/blog-lsh(优化后)/management/commands/ping_baidu.py similarity index 100% rename from blog-lsh/management/commands/ping_baidu.py rename to blog-lsh(优化后)/management/commands/ping_baidu.py diff --git a/blog-lsh/management/commands/sync_user_avatar.py b/blog-lsh(优化后)/management/commands/sync_user_avatar.py similarity index 100% rename from blog-lsh/management/commands/sync_user_avatar.py rename to blog-lsh(优化后)/management/commands/sync_user_avatar.py diff --git a/blog-lsh(优化后)/middleware.py b/blog-lsh(优化后)/middleware.py new file mode 100644 index 0000000..94dd70c --- /dev/null +++ b/blog-lsh(优化后)/middleware.py @@ -0,0 +1,42 @@ +import logging +import time + +from ipware import get_client_ip +from user_agents import parse + +from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager + +logger = logging.getLogger(__name__) + + +class OnlineMiddleware(object): + def __init__(self, get_response=None): + self.get_response = get_response + super().__init__() + + def __call__(self, request): + ''' page render time ''' + start_time = time.time() + response = self.get_response(request) + http_user_agent = request.META.get('HTTP_USER_AGENT', '') + ip, _ = get_client_ip(request) + user_agent = parse(http_user_agent) + if not response.streaming: + try: + cast_time = time.time() - start_time + if ELASTICSEARCH_ENABLED: + time_taken = round((cast_time) * 1000, 2) + url = request.path + from django.utils import timezone + ElaspedTimeDocumentManager.create( + url=url, + time_taken=time_taken, + log_datetime=timezone.now(), + useragent=user_agent, + ip=ip) + response.content = response.content.replace( + b'', str.encode(str(cast_time)[:5])) + except Exception as e: + logger.error("Error OnlineMiddleware: %s" % e) + + return response diff --git a/blog-lsh/migrations/0001_initial.py b/blog-lsh(优化后)/migrations/0001_initial.py similarity index 100% rename from blog-lsh/migrations/0001_initial.py rename to blog-lsh(优化后)/migrations/0001_initial.py diff --git a/blog-lsh/migrations/0002_blogsettings_global_footer_and_more.py b/blog-lsh(优化后)/migrations/0002_blogsettings_global_footer_and_more.py similarity index 100% rename from blog-lsh/migrations/0002_blogsettings_global_footer_and_more.py rename to blog-lsh(优化后)/migrations/0002_blogsettings_global_footer_and_more.py diff --git a/blog-lsh/migrations/0003_blogsettings_comment_need_review.py b/blog-lsh(优化后)/migrations/0003_blogsettings_comment_need_review.py similarity index 100% rename from blog-lsh/migrations/0003_blogsettings_comment_need_review.py rename to blog-lsh(优化后)/migrations/0003_blogsettings_comment_need_review.py diff --git a/blog-lsh/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py b/blog-lsh(优化后)/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py similarity index 100% rename from blog-lsh/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py rename to blog-lsh(优化后)/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py diff --git a/blog-lsh/migrations/0005_alter_article_options_alter_category_options_and_more.py b/blog-lsh(优化后)/migrations/0005_alter_article_options_alter_category_options_and_more.py similarity index 100% rename from blog-lsh/migrations/0005_alter_article_options_alter_category_options_and_more.py rename to blog-lsh(优化后)/migrations/0005_alter_article_options_alter_category_options_and_more.py diff --git a/blog-lsh/migrations/0006_alter_blogsettings_options.py b/blog-lsh(优化后)/migrations/0006_alter_blogsettings_options.py similarity index 100% rename from blog-lsh/migrations/0006_alter_blogsettings_options.py rename to blog-lsh(优化后)/migrations/0006_alter_blogsettings_options.py diff --git a/blog-lsh/management/commands/__init__.py b/blog-lsh(优化后)/migrations/__init__.py similarity index 100% rename from blog-lsh/management/commands/__init__.py rename to blog-lsh(优化后)/migrations/__init__.py diff --git a/blog-lsh(优化后)/models.py b/blog-lsh(优化后)/models.py new file mode 100644 index 0000000..083788b --- /dev/null +++ b/blog-lsh(优化后)/models.py @@ -0,0 +1,376 @@ +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.urls import reverse +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from mdeditor.fields import MDTextField +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): + id = models.AutoField(primary_key=True) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_modify_time = models.DateTimeField(_('modify time'), default=now) + + def save(self, *args, **kwargs): + is_update_views = isinstance( + self, + Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views'] + if is_update_views: + 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): + site = get_current_site().domain + url = "https://{site}{path}".format(site=site, + path=self.get_absolute_url()) + return url + + class Meta: + abstract = True + + @abstractmethod + def get_absolute_url(self): + pass + + +class Article(BaseModel): + """文章""" + STATUS_CHOICES = ( + ('d', _('Draft')), + ('p', _('Published')), + ) + COMMENT_STATUS = ( + ('o', _('Open')), + ('c', _('Close')), + ) + 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): + return self.body + + def __str__(self): + 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): + """文章分类""" + 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): + return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) + + @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() diff --git a/blog-lsh(优化后)/search_indexes.py b/blog-lsh(优化后)/search_indexes.py new file mode 100644 index 0000000..7f1dfac --- /dev/null +++ b/blog-lsh(优化后)/search_indexes.py @@ -0,0 +1,13 @@ +from haystack import indexes + +from blog.models import Article + + +class ArticleIndex(indexes.SearchIndex, indexes.Indexable): + text = indexes.CharField(document=True, use_template=True) + + def get_model(self): + return Article + + def index_queryset(self, using=None): + return self.get_model().objects.filter(status='p') diff --git a/blog-lsh/static/account/css/account.css b/blog-lsh(优化后)/static/account/css/account.css similarity index 100% rename from blog-lsh/static/account/css/account.css rename to blog-lsh(优化后)/static/account/css/account.css diff --git a/blog-lsh/static/account/js/account.js b/blog-lsh(优化后)/static/account/js/account.js similarity index 100% rename from blog-lsh/static/account/js/account.js rename to blog-lsh(优化后)/static/account/js/account.js diff --git a/blog-lsh/static/assets/css/bootstrap.min.css b/blog-lsh(优化后)/static/assets/css/bootstrap.min.css similarity index 100% rename from blog-lsh/static/assets/css/bootstrap.min.css rename to blog-lsh(优化后)/static/assets/css/bootstrap.min.css diff --git a/blog-lsh/static/assets/css/docs.min.css b/blog-lsh(优化后)/static/assets/css/docs.min.css similarity index 100% rename from blog-lsh/static/assets/css/docs.min.css rename to blog-lsh(优化后)/static/assets/css/docs.min.css diff --git a/blog-lsh/static/assets/css/ie10-viewport-bug-workaround.css b/blog-lsh(优化后)/static/assets/css/ie10-viewport-bug-workaround.css similarity index 100% rename from blog-lsh/static/assets/css/ie10-viewport-bug-workaround.css rename to blog-lsh(优化后)/static/assets/css/ie10-viewport-bug-workaround.css diff --git a/blog-lsh/static/assets/css/signin.css b/blog-lsh(优化后)/static/assets/css/signin.css similarity index 100% rename from blog-lsh/static/assets/css/signin.css rename to blog-lsh(优化后)/static/assets/css/signin.css diff --git a/blog-lsh/static/assets/css/todc-bootstrap.min.css b/blog-lsh(优化后)/static/assets/css/todc-bootstrap.min.css similarity index 100% rename from blog-lsh/static/assets/css/todc-bootstrap.min.css rename to blog-lsh(优化后)/static/assets/css/todc-bootstrap.min.css diff --git a/blog-lsh/static/assets/img/checkmark.png b/blog-lsh(优化后)/static/assets/img/checkmark.png similarity index 100% rename from blog-lsh/static/assets/img/checkmark.png rename to blog-lsh(优化后)/static/assets/img/checkmark.png diff --git a/blog-lsh/static/assets/js/ie-emulation-modes-warning.js b/blog-lsh(优化后)/static/assets/js/ie-emulation-modes-warning.js similarity index 100% rename from blog-lsh/static/assets/js/ie-emulation-modes-warning.js rename to blog-lsh(优化后)/static/assets/js/ie-emulation-modes-warning.js diff --git a/blog-lsh/static/assets/js/ie10-viewport-bug-workaround.js b/blog-lsh(优化后)/static/assets/js/ie10-viewport-bug-workaround.js similarity index 100% rename from blog-lsh/static/assets/js/ie10-viewport-bug-workaround.js rename to blog-lsh(优化后)/static/assets/js/ie10-viewport-bug-workaround.js diff --git a/blog-lsh/static/blog/css/ie.css b/blog-lsh(优化后)/static/blog/css/ie.css similarity index 100% rename from blog-lsh/static/blog/css/ie.css rename to blog-lsh(优化后)/static/blog/css/ie.css diff --git a/blog-lsh/static/blog/css/nprogress.css b/blog-lsh(优化后)/static/blog/css/nprogress.css similarity index 100% rename from blog-lsh/static/blog/css/nprogress.css rename to blog-lsh(优化后)/static/blog/css/nprogress.css diff --git a/blog-lsh/static/blog/css/oauth_style.css b/blog-lsh(优化后)/static/blog/css/oauth_style.css similarity index 100% rename from blog-lsh/static/blog/css/oauth_style.css rename to blog-lsh(优化后)/static/blog/css/oauth_style.css diff --git a/blog-lsh/static/blog/css/style.css b/blog-lsh(优化后)/static/blog/css/style.css similarity index 100% rename from blog-lsh/static/blog/css/style.css rename to blog-lsh(优化后)/static/blog/css/style.css diff --git a/blog-lsh/static/blog/fonts/fonts.css b/blog-lsh(优化后)/static/blog/fonts/fonts.css similarity index 100% rename from blog-lsh/static/blog/fonts/fonts.css rename to blog-lsh(优化后)/static/blog/fonts/fonts.css diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUuhp.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUuhp.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUuhp.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUuhp.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUuhp.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUuhp.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUuhp.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUuhp.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2 diff --git a/blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2 diff --git a/blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2 diff --git a/blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2 diff --git a/blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2 diff --git a/blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Zdc0.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Zdc0.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Zdc0.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Zdc0.woff2 diff --git a/blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2 diff --git a/blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2 diff --git a/blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFUZ0bbck.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFUZ0bbck.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFUZ0bbck.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFUZ0bbck.woff2 diff --git a/blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFVZ0b.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFVZ0b.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFVZ0b.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFVZ0b.woff2 diff --git a/blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFVp0bbck.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFVp0bbck.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFVp0bbck.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFVp0bbck.woff2 diff --git a/blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFW50bbck.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFW50bbck.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFW50bbck.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFW50bbck.woff2 diff --git a/blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFWJ0bbck.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFWJ0bbck.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFWJ0bbck.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFWJ0bbck.woff2 diff --git a/blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFWZ0bbck.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFWZ0bbck.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFWZ0bbck.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFWZ0bbck.woff2 diff --git a/blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFWp0bbck.woff2 b/blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFWp0bbck.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/mem8YaGs126MiZpBA-UFWp0bbck.woff2 rename to blog-lsh(优化后)/static/blog/fonts/mem8YaGs126MiZpBA-UFWp0bbck.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2 diff --git a/blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2 b/blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2 similarity index 100% rename from blog-lsh/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2 rename to blog-lsh(优化后)/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2 diff --git a/blog-lsh/static/blog/img/avatar.png b/blog-lsh(优化后)/static/blog/img/avatar.png similarity index 100% rename from blog-lsh/static/blog/img/avatar.png rename to blog-lsh(优化后)/static/blog/img/avatar.png diff --git a/blog-lsh/static/blog/img/icon-sn.svg b/blog-lsh(优化后)/static/blog/img/icon-sn.svg similarity index 100% rename from blog-lsh/static/blog/img/icon-sn.svg rename to blog-lsh(优化后)/static/blog/img/icon-sn.svg diff --git a/blog-lsh/static/blog/js/blog.js b/blog-lsh(优化后)/static/blog/js/blog.js similarity index 100% rename from blog-lsh/static/blog/js/blog.js rename to blog-lsh(优化后)/static/blog/js/blog.js diff --git a/blog-lsh/static/blog/js/html5.js b/blog-lsh(优化后)/static/blog/js/html5.js similarity index 100% rename from blog-lsh/static/blog/js/html5.js rename to blog-lsh(优化后)/static/blog/js/html5.js diff --git a/blog-lsh/static/blog/js/jquery-3.6.0.min.js b/blog-lsh(优化后)/static/blog/js/jquery-3.6.0.min.js similarity index 100% rename from blog-lsh/static/blog/js/jquery-3.6.0.min.js rename to blog-lsh(优化后)/static/blog/js/jquery-3.6.0.min.js diff --git a/blog-lsh/static/blog/js/navigation.js b/blog-lsh(优化后)/static/blog/js/navigation.js similarity index 100% rename from blog-lsh/static/blog/js/navigation.js rename to blog-lsh(优化后)/static/blog/js/navigation.js diff --git a/blog-lsh/static/blog/js/nprogress.js b/blog-lsh(优化后)/static/blog/js/nprogress.js similarity index 100% rename from blog-lsh/static/blog/js/nprogress.js rename to blog-lsh(优化后)/static/blog/js/nprogress.js diff --git a/blog-lsh/static/mathjax/js/mathjax-config.js b/blog-lsh(优化后)/static/mathjax/js/mathjax-config.js similarity index 100% rename from blog-lsh/static/mathjax/js/mathjax-config.js rename to blog-lsh(优化后)/static/mathjax/js/mathjax-config.js diff --git a/blog-lsh/static/pygments/default.css b/blog-lsh(优化后)/static/pygments/default.css old mode 100644 new mode 100755 similarity index 100% rename from blog-lsh/static/pygments/default.css rename to blog-lsh(优化后)/static/pygments/default.css diff --git a/blog-lsh/migrations/__init__.py b/blog-lsh(优化后)/templatetags/__init__.py similarity index 100% rename from blog-lsh/migrations/__init__.py rename to blog-lsh(优化后)/templatetags/__init__.py diff --git a/blog-lsh/templatetags/blog_tags.py b/blog-lsh(优化后)/templatetags/blog_tags.py similarity index 100% rename from blog-lsh/templatetags/blog_tags.py rename to blog-lsh(优化后)/templatetags/blog_tags.py diff --git a/blog-lsh/tests.py b/blog-lsh(优化后)/tests.py similarity index 78% rename from blog-lsh/tests.py rename to blog-lsh(优化后)/tests.py index d415fdb..ee13505 100644 --- a/blog-lsh/tests.py +++ b/blog-lsh(优化后)/tests.py @@ -1,7 +1,3 @@ -""" -LJX: 测试模块 -包含blog应用的单元测试和集成测试,确保代码质量和功能正确性 -""" import os from django.conf import settings @@ -24,16 +20,12 @@ from oauth.models import OAuthUser, OAuthConfig # Create your tests here. class ArticleTest(TestCase): - """LJX: 文章相关功能测试类""" def setUp(self): - """LJX: 测试初始化设置,创建测试客户端和工厂""" - self.client = Client() # LJX: Django测试客户端 - self.factory = RequestFactory() # LJX: 请求工厂,用于创建请求对象 + self.client = Client() + self.factory = RequestFactory() def test_validate_article(self): - """LJX: 测试文章验证和相关功能""" site = get_current_site().domain - # LJX: 创建测试用户 user = BlogUser.objects.get_or_create( email="liangliangyy@gmail.com", username="liangliangyy")[0] @@ -41,16 +33,10 @@ class ArticleTest(TestCase): user.is_staff = True user.is_superuser = True user.save() - - # LJX: 测试用户详情页访问 response = self.client.get(user.get_absolute_url()) self.assertEqual(response.status_code, 200) - - # LJX: 测试后台页面访问 response = self.client.get('/admin/servermanager/emailsendlog/') response = self.client.get('admin/admin/logentry/') - - # LJX: 创建测试侧边栏 s = SideBar() s.sequence = 1 s.name = 'test' @@ -58,19 +44,16 @@ class ArticleTest(TestCase): s.is_enable = True s.save() - # LJX: 创建测试分类 category = Category() category.name = "category" category.creation_time = timezone.now() category.last_mod_time = timezone.now() category.save() - # LJX: 创建测试标签 tag = Tag() tag.name = "nicetag" tag.save() - # LJX: 创建测试文章 article = Article() article.title = "nicetitle" article.body = "nicecontent" @@ -78,15 +61,13 @@ class ArticleTest(TestCase): article.category = category article.type = 'a' article.status = 'p' + article.save() - - # LJX: 测试标签关联 self.assertEqual(0, article.tags.count()) article.tags.add(tag) article.save() self.assertEqual(1, article.tags.count()) - # LJX: 创建多篇文章用于测试分页和搜索 for i in range(20): article = Article() article.title = "nicetitle" + str(i) @@ -98,46 +79,32 @@ class ArticleTest(TestCase): article.save() article.tags.add(tag) article.save() - - # LJX: 测试Elasticsearch搜索功能 from blog.documents import ELASTICSEARCH_ENABLED if ELASTICSEARCH_ENABLED: - call_command("build_index") # LJX: 构建搜索索引 + call_command("build_index") response = self.client.get('/search', {'q': 'nicetitle'}) self.assertEqual(response.status_code, 200) - # LJX: 测试文章详情页访问 response = self.client.get(article.get_absolute_url()) self.assertEqual(response.status_code, 200) - - # LJX: 测试搜索引擎通知 from djangoblog.spider_notify import SpiderNotify SpiderNotify.notify(article.get_absolute_url()) - - # LJX: 测试标签页访问 response = self.client.get(tag.get_absolute_url()) self.assertEqual(response.status_code, 200) - # LJX: 测试分类页访问 response = self.client.get(category.get_absolute_url()) self.assertEqual(response.status_code, 200) - # LJX: 测试搜索功能 response = self.client.get('/search', {'q': 'django'}) self.assertEqual(response.status_code, 200) - - # LJX: 测试模板标签 s = load_articletags(article) self.assertIsNotNone(s) - # LJX: 用户登录测试 self.client.login(username='liangliangyy', password='liangliangyy') - # LJX: 测试归档页面 response = self.client.get(reverse('blog:archives')) self.assertEqual(response.status_code, 200) - # LJX: 测试分页功能 p = Paginator(Article.objects.all(), settings.PAGINATE_BY) self.check_pagination(p, '', '') @@ -152,20 +119,16 @@ class ArticleTest(TestCase): p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY) self.check_pagination(p, '分类目录归档', category.slug) - # LJX: 测试搜索表单 f = BlogSearchForm() f.search() - - # LJX: 测试百度通知功能 + # self.client.login(username='liangliangyy', password='liangliangyy') from djangoblog.spider_notify import SpiderNotify SpiderNotify.baidu_notify([article.get_full_url()]) - # LJX: 测试Gravatar相关功能 from blog.templatetags.blog_tags import gravatar_url, gravatar u = gravatar_url('liangliangyy@gmail.com') u = gravatar('liangliangyy@gmail.com') - # LJX: 测试友情链接功能 link = Links( sequence=1, name="lylinux", @@ -174,46 +137,37 @@ class ArticleTest(TestCase): response = self.client.get('/links.html') self.assertEqual(response.status_code, 200) - # LJX: 测试Feed和Sitemap response = self.client.get('/feed/') self.assertEqual(response.status_code, 200) response = self.client.get('/sitemap.xml') self.assertEqual(response.status_code, 200) - # LJX: 测试后台删除操作 self.client.get("/admin/blog/article/1/delete/") self.client.get('/admin/servermanager/emailsendlog/') self.client.get('/admin/admin/logentry/') self.client.get('/admin/admin/logentry/1/change/') def check_pagination(self, p, type, value): - """LJX: 检查分页功能是否正常工作""" for page in range(1, p.num_pages + 1): s = load_pagination_info(p.page(page), type, value) self.assertIsNotNone(s) - if s['previous_url']: # LJX: 测试上一页链接 + if s['previous_url']: response = self.client.get(s['previous_url']) self.assertEqual(response.status_code, 200) - if s['next_url']: # LJX: 测试下一页链接 + if s['next_url']: response = self.client.get(s['next_url']) self.assertEqual(response.status_code, 200) def test_image(self): - """LJX: 测试图片上传和处理功能""" import requests - # LJX: 下载测试图片 rsp = requests.get( 'https://www.python.org/static/img/python-logo.png') imagepath = os.path.join(settings.BASE_DIR, 'python.png') with open(imagepath, 'wb') as file: file.write(rsp.content) - - # LJX: 测试未授权上传 rsp = self.client.post('/upload') self.assertEqual(rsp.status_code, 403) - - # LJX: 测试授权上传 sign = get_sha256(get_sha256(settings.SECRET_KEY)) with open(imagepath, 'rb') as file: imgfile = SimpleUploadedFile( @@ -222,21 +176,17 @@ class ArticleTest(TestCase): rsp = self.client.post( '/upload?sign=' + sign, form_data, follow=True) self.assertEqual(rsp.status_code, 200) - os.remove(imagepath) # LJX: 清理测试文件 - - # LJX: 测试工具函数 + os.remove(imagepath) from djangoblog.utils import save_user_avatar, send_email send_email(['qq@qq.com'], 'testTitle', 'testContent') save_user_avatar( 'https://www.python.org/static/img/python-logo.png') def test_errorpage(self): - """LJX: 测试错误页面处理""" rsp = self.client.get('/eee') self.assertEqual(rsp.status_code, 404) def test_commands(self): - """LJX: 测试自定义管理命令""" user = BlogUser.objects.get_or_create( email="liangliangyy@gmail.com", username="liangliangyy")[0] @@ -245,7 +195,6 @@ class ArticleTest(TestCase): user.is_superuser = True user.save() - # LJX: 测试OAuth配置 c = OAuthConfig() c.type = 'qq' c.appkey = 'appkey' @@ -273,7 +222,6 @@ class ArticleTest(TestCase): }''' u.save() - # LJX: 测试各种管理命令 from blog.documents import ELASTICSEARCH_ENABLED if ELASTICSEARCH_ENABLED: call_command("build_index") @@ -281,4 +229,4 @@ class ArticleTest(TestCase): call_command("create_testdata") call_command("clear_cache") call_command("sync_user_avatar") - call_command("build_search_words") \ No newline at end of file + call_command("build_search_words") diff --git a/blog-lsh(优化后)/urls.py b/blog-lsh(优化后)/urls.py new file mode 100644 index 0000000..adf2703 --- /dev/null +++ b/blog-lsh(优化后)/urls.py @@ -0,0 +1,62 @@ +from django.urls import path +from django.views.decorators.cache import cache_page + +from . import views + +app_name = "blog" +urlpatterns = [ + path( + r'', + views.IndexView.as_view(), + name='index'), + path( + r'page//', + views.IndexView.as_view(), + name='index_page'), + path( + r'article////.html', + views.ArticleDetailView.as_view(), + name='detailbyid'), + path( + r'category/.html', + views.CategoryDetailView.as_view(), + name='category_detail'), + path( + r'category//.html', + views.CategoryDetailView.as_view(), + name='category_detail_page'), + path( + r'author/.html', + views.AuthorDetailView.as_view(), + name='author_detail'), + path( + r'author//.html', + views.AuthorDetailView.as_view(), + name='author_detail_page'), + path( + r'tag/.html', + views.TagDetailView.as_view(), + name='tag_detail'), + path( + r'tag//.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'), +] diff --git a/blog-lsh/views.py b/blog-lsh(优化后)/views.py similarity index 52% rename from blog-lsh/views.py rename to blog-lsh(优化后)/views.py index 327cba5..d5dc7ec 100644 --- a/blog-lsh/views.py +++ b/blog-lsh(优化后)/views.py @@ -1,8 +1,3 @@ -""" -LJX: 视图处理模块 -定义blog应用的所有视图函数和类,处理HTTP请求并返回响应 -包含文章列表、详情、分类、标签、搜索等核心功能 -""" import logging import os import uuid @@ -30,135 +25,136 @@ logger = logging.getLogger(__name__) class ArticleListView(ListView): - """LJX: 文章列表基类视图,提供文章列表的通用功能""" - # LJX: template_name属性用于指定使用哪个模板进行渲染 + # template_name属性用于指定使用哪个模板进行渲染 template_name = 'blog/article_index.html' - # LJX: context_object_name属性用于给上下文变量取名(在模板中使用该名字) + # context_object_name属性用于给上下文变量取名(在模板中使用该名字) context_object_name = 'article_list' - # LJX: 页面类型,分类目录或标签列表等 + # 页面类型,分类目录或标签列表等 page_type = '' - paginate_by = settings.PAGINATE_BY # LJX: 每页显示文章数量 - page_kwarg = 'page' # LJX: URL中页码参数的名称 - link_type = LinkShowType.L # LJX: 友情链接显示类型 + paginate_by = settings.PAGINATE_BY + page_kwarg = 'page' + link_type = LinkShowType.L def get_view_cache_key(self): - """LJX: 获取视图缓存键""" return self.request.get['pages'] @property def page_number(self): - """LJX: 获取当前页码属性""" page_kwarg = self.page_kwarg - page = self.kwargs.get( # LJX: 从URL参数获取页码 - page_kwarg) or self.request.GET.get(page_kwarg) or 1 # LJX: 从GET参数获取或默认第一页 + page = self.kwargs.get( + page_kwarg) or self.request.GET.get(page_kwarg) or 1 return page def get_queryset_cache_key(self): - """LJX: 子类重写.获得queryset的缓存key""" + """ + 子类重写.获得queryset的缓存key + """ raise NotImplementedError() def get_queryset_data(self): - """LJX: 子类重写.获取queryset的数据""" + """ + 子类重写.获取queryset的数据 + """ raise NotImplementedError() def get_queryset_from_cache(self, cache_key): - '''LJX: 缓存页面数据,提高性能''' + ''' + 缓存页面数据 + :param cache_key: 缓存key + :return: + ''' value = cache.get(cache_key) if value: logger.info('get view cache.key:{key}'.format(key=cache_key)) - return value # LJX: 如果缓存中存在,直接返回 + return value else: - article_list = self.get_queryset_data() # LJX: 从数据库获取数据 - cache.set(cache_key, article_list) # LJX: 设置缓存 + article_list = self.get_queryset_data() + cache.set(cache_key, article_list) logger.info('set view cache.key:{key}'.format(key=cache_key)) return article_list def get_queryset(self): - '''LJX: 重写默认,从缓存获取数据''' + ''' + 重写默认,从缓存获取数据 + :return: + ''' key = self.get_queryset_cache_key() value = self.get_queryset_from_cache(key) return value def get_context_data(self, **kwargs): - """LJX: 添加上下文数据""" - kwargs['linktype'] = self.link_type # LJX: 添加链接类型到上下文 + kwargs['linktype'] = self.link_type return super(ArticleListView, self).get_context_data(**kwargs) class IndexView(ArticleListView): - '''LJX: 首页视图,显示最新的文章列表''' - # LJX: 友情链接类型 - 在首页显示 + ''' + 首页 + ''' + # 友情链接类型 link_type = LinkShowType.I def get_queryset_data(self): - """LJX: 获取首页文章数据 - 只获取已发布的普通文章""" - article_list = Article.objects.filter(type='a', status='p') # LJX: 过滤普通类型且已发布的文章 + article_list = Article.objects.filter(type='a', status='p') return article_list def get_queryset_cache_key(self): - """LJX: 生成首页缓存键,包含页码信息""" cache_key = 'index_{page}'.format(page=self.page_number) return cache_key class ArticleDetailView(DetailView): - '''LJX: 文章详情页面视图,显示单篇文章的完整内容''' - template_name = 'blog/article_detail.html' # LJX: 详情页模板 - model = Article # LJX: 关联的模型 - pk_url_kwarg = 'article_id' # LJX: URL中主键参数的名称 - context_object_name = "article" # LJX: 模板中使用的变量名 + ''' + 文章详情页面 + ''' + template_name = 'blog/article_detail.html' + model = Article + pk_url_kwarg = 'article_id' + context_object_name = "article" def get_context_data(self, **kwargs): - """LJX: 添加上下文数据,包括评论表单、评论列表等""" - comment_form = CommentForm() # LJX: 评论表单实例 + comment_form = CommentForm() - # LJX: 获取文章评论列表 article_comments = self.object.comment_list() - parent_comments = article_comments.filter(parent_comment=None) # LJX: 顶级评论(无父评论) - blog_setting = get_blog_setting() # LJX: 获取博客设置 - - # LJX: 评论分页处理 + parent_comments = article_comments.filter(parent_comment=None) + blog_setting = get_blog_setting() paginator = Paginator(parent_comments, blog_setting.article_comment_count) - page = self.request.GET.get('comment_page', '1') # LJX: 获取评论页码 + page = self.request.GET.get('comment_page', '1') if not page.isnumeric(): - page = 1 # LJX: 如果不是数字,默认第一页 + page = 1 else: page = int(page) if page < 1: - page = 1 # LJX: 页码不能小于1 + page = 1 if page > paginator.num_pages: - page = paginator.num_pages # LJX: 页码不能大于总页数 + page = paginator.num_pages - p_comments = paginator.page(page) # LJX: 获取指定页的评论 - next_page = p_comments.next_page_number() if p_comments.has_next() else None # LJX: 下一页页码 - prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None # LJX: 上一页页码 + p_comments = paginator.page(page) + 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 - # LJX: 添加上下一页评论的URL if next_page: kwargs[ 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container' if prev_page: kwargs[ 'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container' - - # LJX: 添加各种上下文数据 kwargs['form'] = comment_form kwargs['article_comments'] = article_comments kwargs['p_comments'] = p_comments kwargs['comment_count'] = len( article_comments) if article_comments else 0 - kwargs['next_article'] = self.object.next_article # LJX: 下一篇文章 - kwargs['prev_article'] = self.object.prev_article # LJX: 上一篇文章 + kwargs['next_article'] = self.object.next_article + kwargs['prev_article'] = self.object.prev_article context = super(ArticleDetailView, self).get_context_data(**kwargs) article = self.object - - # LJX: Action Hook, 通知插件"文章详情已获取" + # Action Hook, 通知插件"文章详情已获取" hooks.run_action('after_article_body_get', article=article, request=self.request) - # LJX: Filter Hook, 允许插件修改文章正文 + # # Filter Hook, 允许插件修改文章正文 article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article, request=self.request) @@ -166,25 +162,24 @@ class ArticleDetailView(DetailView): class CategoryDetailView(ArticleListView): - '''LJX: 分类目录列表视图,显示指定分类下的所有文章''' - page_type = "分类目录归档" # LJX: 页面类型描述 + ''' + 分类目录列表 + ''' + page_type = "分类目录归档" def get_queryset_data(self): - """LJX: 获取分类下的文章数据,包括子分类的文章""" - slug = self.kwargs['category_name'] # LJX: 从URL获取分类slug - category = get_object_or_404(Category, slug=slug) # LJX: 获取分类对象,不存在返回404 + slug = self.kwargs['category_name'] + category = get_object_or_404(Category, slug=slug) categoryname = category.name self.categoryname = categoryname categorynames = list( - map(lambda c: c.name, category.get_sub_categorys())) # LJX: 获取所有子分类名称 - # LJX: 获取分类及其子分类下的所有已发布文章 + 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): - """LJX: 生成分类页缓存键""" slug = self.kwargs['category_name'] category = get_object_or_404(Category, slug=slug) categoryname = category.name @@ -194,60 +189,59 @@ class CategoryDetailView(ArticleListView): return cache_key def get_context_data(self, **kwargs): - """LJX: 添加上下文数据""" + categoryname = self.categoryname try: - categoryname = categoryname.split('/')[-1] # LJX: 处理分类路径,取最后一部分 + categoryname = categoryname.split('/')[-1] except BaseException: pass kwargs['page_type'] = CategoryDetailView.page_type - kwargs['tag_name'] = categoryname # LJX: 实际上这里是分类名,变量名保持兼容 + kwargs['tag_name'] = categoryname return super(CategoryDetailView, self).get_context_data(**kwargs) class AuthorDetailView(ArticleListView): - '''LJX: 作者详情页视图,显示指定作者的所有文章''' - page_type = '作者文章归档' # LJX: 页面类型描述 + ''' + 作者详情页 + ''' + page_type = '作者文章归档' def get_queryset_cache_key(self): - """LJX: 生成作者页缓存键""" from uuslug import slugify - author_name = slugify(self.kwargs['author_name']) # LJX: 对作者名进行slugify处理 + author_name = slugify(self.kwargs['author_name']) cache_key = 'author_{author_name}_{page}'.format( author_name=author_name, page=self.page_number) return cache_key def get_queryset_data(self): - """LJX: 获取作者的文章数据""" author_name = self.kwargs['author_name'] article_list = Article.objects.filter( - author__username=author_name, type='a', status='p') # LJX: 过滤指定作者的已发布普通文章 + author__username=author_name, type='a', status='p') return article_list def get_context_data(self, **kwargs): - """LJX: 添加上下文数据""" author_name = self.kwargs['author_name'] kwargs['page_type'] = AuthorDetailView.page_type - kwargs['tag_name'] = author_name # LJX: 变量名保持兼容 + kwargs['tag_name'] = author_name return super(AuthorDetailView, self).get_context_data(**kwargs) class TagDetailView(ArticleListView): - '''LJX: 标签列表页面视图,显示指定标签的所有文章''' - page_type = '分类标签归档' # LJX: 页面类型描述 + ''' + 标签列表页面 + ''' + page_type = '分类标签归档' def get_queryset_data(self): - """LJX: 获取标签下的文章数据""" slug = self.kwargs['tag_name'] - tag = get_object_or_404(Tag, slug=slug) # LJX: 获取标签对象 + tag = get_object_or_404(Tag, slug=slug) tag_name = tag.name self.name = tag_name article_list = Article.objects.filter( - tags__name=tag_name, type='a', status='p') # LJX: 多对多关系查询 + tags__name=tag_name, type='a', status='p') return article_list def get_queryset_cache_key(self): - """LJX: 生成标签页缓存键""" slug = self.kwargs['tag_name'] tag = get_object_or_404(Tag, slug=slug) tag_name = tag.name @@ -257,7 +251,7 @@ class TagDetailView(ArticleListView): return cache_key def get_context_data(self, **kwargs): - """LJX: 添加上下文数据""" + # tag_name = self.kwargs['tag_name'] tag_name = self.name kwargs['page_type'] = TagDetailView.page_type kwargs['tag_name'] = tag_name @@ -265,134 +259,121 @@ class TagDetailView(ArticleListView): class ArchivesView(ArticleListView): - '''LJX: 文章归档页面视图,显示所有文章的按时间归档''' - page_type = '文章归档' # LJX: 页面类型描述 - paginate_by = None # LJX: 不分页,显示所有文章 - page_kwarg = None # LJX: 无页码参数 - template_name = 'blog/article_archives.html' # LJX: 归档页专用模板 + ''' + 文章归档页面 + ''' + page_type = '文章归档' + paginate_by = None + page_kwarg = None + template_name = 'blog/article_archives.html' def get_queryset_data(self): - """LJX: 获取所有已发布文章""" return Article.objects.filter(status='p').all() def get_queryset_cache_key(self): - """LJX: 生成归档页缓存键""" cache_key = 'archives' return cache_key class LinkListView(ListView): - """LJX: 友情链接列表视图,显示所有启用的友情链接""" - model = Links # LJX: 关联的模型 - template_name = 'blog/links_list.html' # LJX: 模板名称 + model = Links + template_name = 'blog/links_list.html' def get_queryset(self): - """LJX: 获取已启用的友情链接""" return Links.objects.filter(is_enable=True) class EsSearchView(SearchView): - """LJX: Elasticsearch搜索视图,处理全文搜索请求""" def get_context(self): - """LJX: 获取搜索上下文数据""" - paginator, page = self.build_page() # LJX: 构建分页 + paginator, page = self.build_page() context = { - "query": self.query, # LJX: 搜索关键词 - "form": self.form, # LJX: 搜索表单 - "page": page, # LJX: 当前页 - "paginator": paginator, # LJX: 分页器 - "suggestion": None, # LJX: 搜索建议 + "query": self.query, + "form": self.form, + "page": page, + "paginator": paginator, + "suggestion": None, } - # LJX: 如果有拼写建议,添加到上下文 if hasattr(self.results, "query") and self.results.query.backend.include_spelling: context["suggestion"] = self.results.query.get_spelling_suggestion() - context.update(self.extra_context()) # LJX: 添加额外上下文 + context.update(self.extra_context()) return context -@csrf_exempt # LJX: 免除CSRF保护,用于文件上传 +@csrf_exempt def fileupload(request): """ - LJX: 文件上传视图函数 该方法需自己写调用端来上传图片,该方法仅提供图床功能 + :param request: + :return: """ if request.method == 'POST': - sign = request.GET.get('sign', None) # LJX: 获取签名参数 + sign = request.GET.get('sign', None) if not sign: - return HttpResponseForbidden() # LJX: 无签名,拒绝访问 + return HttpResponseForbidden() if not sign == get_sha256(get_sha256(settings.SECRET_KEY)): - return HttpResponseForbidden() # LJX: 签名验证失败,拒绝访问 - + return HttpResponseForbidden() response = [] - for filename in request.FILES: # LJX: 遍历所有上传的文件 - timestr = timezone.now().strftime('%Y/%m/%d') # LJX: 按日期创建目录结构 - imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] # LJX: 图片文件扩展名 + for filename in request.FILES: + timestr = timezone.now().strftime('%Y/%m/%d') + imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] fname = u''.join(str(filename)) - isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 # LJX: 判断是否为图片文件 - # LJX: 构建保存路径 + isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr) if not os.path.exists(base_dir): - os.makedirs(base_dir) # LJX: 创建目录 - # LJX: 生成唯一文件名 + os.makedirs(base_dir) savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}")) if not savepath.startswith(base_dir): - return HttpResponse("only for post") # LJX: 路径安全检查 - # LJX: 保存文件 + return HttpResponse("only for post") with open(savepath, 'wb+') as wfile: for chunk in request.FILES[filename].chunks(): wfile.write(chunk) - # LJX: 如果是图片,进行压缩优化 if isimage: from PIL import Image image = Image.open(savepath) - image.save(savepath, quality=20, optimize=True) # LJX: 质量压缩到20%,优化存储 - url = static(savepath) # LJX: 生成静态文件URL + image.save(savepath, quality=20, optimize=True) + url = static(savepath) response.append(url) - return HttpResponse(response) # LJX: 返回文件URL列表 + return HttpResponse(response) else: - return HttpResponse("only for post") # LJX: 只支持POST请求 + return HttpResponse("only for post") def page_not_found_view( request, exception, template_name='blog/error_page.html'): - """LJX: 404页面未找到错误处理视图""" if exception: - logger.error(exception) # LJX: 记录错误日志 + logger.error(exception) url = request.get_full_path() return render(request, template_name, {'message': _('Sorry, the page you requested is not found, please click the home page to see other?'), - 'statuscode': '404'}, # LJX: 错误信息 - status=404) # LJX: 返回404状态码 + 'statuscode': '404'}, + status=404) def server_error_view(request, template_name='blog/error_page.html'): - """LJX: 500服务器错误处理视图""" return render(request, template_name, {'message': _('Sorry, the server is busy, please click the home page to see other?'), 'statuscode': '500'}, - status=500) # LJX: 返回500状态码 + status=500) def permission_denied_view( request, exception, template_name='blog/error_page.html'): - """LJX: 403权限拒绝错误处理视图""" if exception: logger.error(exception) return render( request, template_name, { 'message': _('Sorry, you do not have permission to access this page?'), - 'statuscode': '403'}, status=403) # LJX: 返回403状态码 + 'statuscode': '403'}, status=403) def clean_cache_view(request): - """LJX: 清理缓存视图,用于手动清理系统缓存""" - cache.clear() # LJX: 清除所有缓存 - return HttpResponse('ok') # LJX: 返回成功响应 \ No newline at end of file + cache.clear() + return HttpResponse('ok') diff --git a/blog-lsh/__pycache__/__init__.cpython-312.pyc b/blog-lsh/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index c02cb87..0000000 Binary files a/blog-lsh/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/__pycache__/admin.cpython-312.pyc b/blog-lsh/__pycache__/admin.cpython-312.pyc deleted file mode 100644 index 38e685e..0000000 Binary files a/blog-lsh/__pycache__/admin.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/__pycache__/apps.cpython-312.pyc b/blog-lsh/__pycache__/apps.cpython-312.pyc deleted file mode 100644 index 95b88b4..0000000 Binary files a/blog-lsh/__pycache__/apps.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/__pycache__/documents.cpython-312.pyc b/blog-lsh/__pycache__/documents.cpython-312.pyc deleted file mode 100644 index 5681bee..0000000 Binary files a/blog-lsh/__pycache__/documents.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/__pycache__/middleware.cpython-312.pyc b/blog-lsh/__pycache__/middleware.cpython-312.pyc deleted file mode 100644 index 36078af..0000000 Binary files a/blog-lsh/__pycache__/middleware.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/__pycache__/models.cpython-312.pyc b/blog-lsh/__pycache__/models.cpython-312.pyc deleted file mode 100644 index a7c3108..0000000 Binary files a/blog-lsh/__pycache__/models.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/__pycache__/urls.cpython-312.pyc b/blog-lsh/__pycache__/urls.cpython-312.pyc deleted file mode 100644 index f97d159..0000000 Binary files a/blog-lsh/__pycache__/urls.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/__pycache__/views.cpython-312.pyc b/blog-lsh/__pycache__/views.cpython-312.pyc deleted file mode 100644 index c95b610..0000000 Binary files a/blog-lsh/__pycache__/views.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/apps.py b/blog-lsh/apps.py deleted file mode 100644 index b26e48a..0000000 --- a/blog-lsh/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -LJX: Blog应用配置模块 -定义blog应用的配置信息,包括应用名称等基础设置 -""" -from django.apps import AppConfig - - -class BlogConfig(AppConfig): - """LJX: Blog应用配置类""" - name = 'blog' # LJX: 应用名称 \ No newline at end of file diff --git a/blog-lsh/context_processors.py b/blog-lsh/context_processors.py deleted file mode 100644 index 0af9401..0000000 --- a/blog-lsh/context_processors.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -LJX: 模板上下文处理器模块 -为所有模板提供全局的上下文变量,包括SEO信息、导航数据、网站设置等 -这些变量在所有模板中都可以直接使用 -""" -import logging - -from django.utils import timezone - -from djangoblog.utils import cache, get_blog_setting -from .models import Category, Article - -logger = logging.getLogger(__name__) - - -def seo_processor(requests): - """LJX: SEO上下文处理器,为模板提供SEO相关变量""" - key = 'seo_processor' - value = cache.get(key) - if value: - return value # LJX: 如果缓存中存在,直接返回 - else: - logger.info('set processor cache.') - setting = get_blog_setting() # LJX: 获取博客设置 - value = { - 'SITE_NAME': setting.site_name, # LJX: 网站名称 - 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, # LJX: 是否显示Google广告 - 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, # LJX: 广告代码 - 'SITE_SEO_DESCRIPTION': setting.site_seo_description, # LJX: SEO描述 - 'SITE_DESCRIPTION': setting.site_description, # LJX: 网站描述 - 'SITE_KEYWORDS': setting.site_keywords, # LJX: 网站关键词 - 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', # LJX: 网站基础URL - 'ARTICLE_SUB_LENGTH': setting.article_sub_length, # LJX: 文章摘要长度 - 'nav_category_list': Category.objects.all(), # LJX: 导航分类列表 - 'nav_pages': Article.objects.filter( # LJX: 导航页面 - type='p', - status='p'), - 'OPEN_SITE_COMMENT': setting.open_site_comment, # LJX: 是否开启评论 - 'BEIAN_CODE': setting.beian_code, # LJX: 备案号 - 'ANALYTICS_CODE': setting.analytics_code, # LJX: 统计代码 - "BEIAN_CODE_GONGAN": setting.gongan_beiancode, # LJX: 公安备案号 - "SHOW_GONGAN_CODE": setting.show_gongan_code, # LJX: 是否显示公安备案 - "CURRENT_YEAR": timezone.now().year, # LJX: 当前年份 - "GLOBAL_HEADER": setting.global_header, # LJX: 全局头部 - "GLOBAL_FOOTER": setting.global_footer, # LJX: 全局尾部 - "COMMENT_NEED_REVIEW": setting.comment_need_review, # LJX: 评论是否需要审核 - } - cache.set(key, value, 60 * 60 * 10) # LJX: 缓存10小时 - return value \ No newline at end of file diff --git a/blog-lsh/forms.py b/blog-lsh/forms.py deleted file mode 100644 index 82e6929..0000000 --- a/blog-lsh/forms.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -LJX: 表单定义模块 -定义博客搜索相关的表单类和验证逻辑 -""" -import logging - -from django import forms -from haystack.forms import SearchForm - -logger = logging.getLogger(__name__) - - -class BlogSearchForm(SearchForm): - """LJX: 博客搜索表单,继承自Haystack的SearchForm""" - querydata = forms.CharField(required=True) # LJX: 搜索查询字段,必须填写 - - def search(self): - """LJX: 执行搜索操作""" - datas = super(BlogSearchForm, self).search() # LJX: 调用父类搜索方法 - if not self.is_valid(): # LJX: 表单验证 - return self.no_query_found() # LJX: 如果没有查询条件,返回空结果 - - if self.cleaned_data['querydata']: - logger.info(self.cleaned_data['querydata']) # LJX: 记录搜索关键词 - return datas \ No newline at end of file diff --git a/blog-lsh/middleware.py b/blog-lsh/middleware.py deleted file mode 100644 index af8172d..0000000 --- a/blog-lsh/middleware.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -LJX: 中间件模块 -定义自定义中间件,用于处理请求和响应的额外逻辑 -包括性能监控、用户访问统计等功能 -""" -import logging -import time - -from ipware import get_client_ip -from user_agents import parse - -from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager - -logger = logging.getLogger(__name__) - - -class OnlineMiddleware(object): - """LJX: 在线中间件,用于监控页面渲染时间和用户访问信息""" - def __init__(self, get_response=None): - self.get_response = get_response - super().__init__() - - def __call__(self, request): - ''' LJX: 页面渲染时间监控 ''' - start_time = time.time() # LJX: 记录开始时间 - response = self.get_response(request) # LJX: 获取响应 - http_user_agent = request.META.get('HTTP_USER_AGENT', '') # LJX: 获取用户代理 - ip, _ = get_client_ip(request) # LJX: 获取客户端IP - user_agent = parse(http_user_agent) # LJX: 解析用户代理信息 - - if not response.streaming: # LJX: 如果不是流式响应 - try: - cast_time = time.time() - start_time # LJX: 计算渲染耗时 - if ELASTICSEARCH_ENABLED: - time_taken = round((cast_time) * 1000, 2) # LJX: 转换为毫秒 - url = request.path # LJX: 请求路径 - from django.utils import timezone - # LJX: 创建性能监控记录 - ElaspedTimeDocumentManager.create( - url=url, - time_taken=time_taken, - log_datetime=timezone.now(), - useragent=user_agent, - ip=ip) - # LJX: 在响应内容中替换加载时间占位符 - response.content = response.content.replace( - b'', str.encode(str(cast_time)[:5])) - except Exception as e: - logger.error("Error OnlineMiddleware: %s" % e) # LJX: 记录错误日志 - - return response \ No newline at end of file diff --git a/blog-lsh/migrations/__pycache__/0001_initial.cpython-312.pyc b/blog-lsh/migrations/__pycache__/0001_initial.cpython-312.pyc deleted file mode 100644 index 8a9660b..0000000 Binary files a/blog-lsh/migrations/__pycache__/0001_initial.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/migrations/__pycache__/0002_blogsettings_global_footer_and_more.cpython-312.pyc b/blog-lsh/migrations/__pycache__/0002_blogsettings_global_footer_and_more.cpython-312.pyc deleted file mode 100644 index b0f3a9e..0000000 Binary files a/blog-lsh/migrations/__pycache__/0002_blogsettings_global_footer_and_more.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/migrations/__pycache__/0003_blogsettings_comment_need_review.cpython-312.pyc b/blog-lsh/migrations/__pycache__/0003_blogsettings_comment_need_review.cpython-312.pyc deleted file mode 100644 index 844b4a3..0000000 Binary files a/blog-lsh/migrations/__pycache__/0003_blogsettings_comment_need_review.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/migrations/__pycache__/0004_rename_analyticscode_blogsettings_analytics_code_and_more.cpython-312.pyc b/blog-lsh/migrations/__pycache__/0004_rename_analyticscode_blogsettings_analytics_code_and_more.cpython-312.pyc deleted file mode 100644 index 33a5e45..0000000 Binary files a/blog-lsh/migrations/__pycache__/0004_rename_analyticscode_blogsettings_analytics_code_and_more.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/migrations/__pycache__/0005_alter_article_options_alter_category_options_and_more.cpython-312.pyc b/blog-lsh/migrations/__pycache__/0005_alter_article_options_alter_category_options_and_more.cpython-312.pyc deleted file mode 100644 index 28359fc..0000000 Binary files a/blog-lsh/migrations/__pycache__/0005_alter_article_options_alter_category_options_and_more.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/migrations/__pycache__/0006_alter_blogsettings_options.cpython-312.pyc b/blog-lsh/migrations/__pycache__/0006_alter_blogsettings_options.cpython-312.pyc deleted file mode 100644 index 4710bc6..0000000 Binary files a/blog-lsh/migrations/__pycache__/0006_alter_blogsettings_options.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/migrations/__pycache__/__init__.cpython-312.pyc b/blog-lsh/migrations/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 4fe1f59..0000000 Binary files a/blog-lsh/migrations/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/models.py b/blog-lsh/models.py deleted file mode 100644 index 74c8535..0000000 --- a/blog-lsh/models.py +++ /dev/null @@ -1,397 +0,0 @@ -""" -LJX: 数据模型定义模块 -定义博客系统的核心数据模型,包括文章、分类、标签、友情链接等 -使用Django的ORM进行数据库映射和操作 -""" -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.urls import reverse -from django.utils.timezone import now -from django.utils.translation import gettext_lazy as _ -from mdeditor.fields import MDTextField -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): - """LJX: 链接显示类型选择枚举""" - I = ('i', _('index')) # LJX: 首页显示 - L = ('l', _('list')) # LJX: 列表页显示 - P = ('p', _('post')) # LJX: 文章页显示 - A = ('a', _('all')) # LJX: 所有页面显示 - S = ('s', _('slide')) # LJX: 幻灯片显示 - - -class BaseModel(models.Model): - """LJX: 基础模型类,提供公共字段和方法""" - id = models.AutoField(primary_key=True) # LJX: 自增主键 - creation_time = models.DateTimeField(_('creation time'), default=now) # LJX: 创建时间 - last_modify_time = models.DateTimeField(_('modify time'), default=now) # LJX: 最后修改时间 - - def save(self, *args, **kwargs): - """LJX: 重写保存方法,添加自动处理逻辑""" - is_update_views = isinstance( - self, - Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views'] # LJX: 检查是否是更新浏览量 - if is_update_views: - Article.objects.filter(pk=self.pk).update(views=self.views) # LJX: 直接更新浏览量,避免递归 - else: - if 'slug' in self.__dict__: # LJX: 如果有slug字段,自动生成 - slug = getattr( - self, 'title') if 'title' in self.__dict__ else getattr( - self, 'name') # LJX: 根据title或name生成slug - setattr(self, 'slug', slugify(slug)) # LJX: 使用uuslug生成友好的URL - super().save(*args, **kwargs) # LJX: 调用父类保存方法 - - def get_full_url(self): - """LJX: 获取完整URL""" - site = get_current_site().domain # LJX: 获取当前站点域名 - url = "https://{site}{path}".format(site=site, - path=self.get_absolute_url()) # LJX: 拼接完整URL - return url - - class Meta: - abstract = True # LJX: 抽象基类,不会创建数据库表 - - @abstractmethod - def get_absolute_url(self): - """LJX: 抽象方法,子类必须实现获取绝对URL的方法""" - pass - - -class Article(BaseModel): - """LJX: 文章模型,博客系统的核心数据模型""" - STATUS_CHOICES = ( # LJX: 文章状态选择 - ('d', _('Draft')), # LJX: 草稿 - ('p', _('Published')), # LJX: 已发布 - ) - COMMENT_STATUS = ( # LJX: 评论状态选择 - ('o', _('Open')), # LJX: 开启评论 - ('c', _('Close')), # LJX: 关闭评论 - ) - TYPE = ( # LJX: 文章类型选择 - ('a', _('Article')), # LJX: 普通文章 - ('p', _('Page')), # LJX: 页面 - ) - - # LJX: 文章核心字段 - title = models.CharField(_('title'), max_length=200, unique=True) # LJX: 标题,唯一 - body = MDTextField(_('body')) # LJX: 内容,使用Markdown编辑器 - pub_time = models.DateTimeField( # LJX: 发布时间 - _('publish time'), blank=False, null=False, default=now) - status = models.CharField( # LJX: 状态 - _('status'), - max_length=1, - choices=STATUS_CHOICES, - default='p') # LJX: 默认已发布 - comment_status = models.CharField( # LJX: 评论状态 - _('comment status'), - max_length=1, - choices=COMMENT_STATUS, - default='o') # LJX: 默认开启评论 - type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') # LJX: 类型,默认普通文章 - views = models.PositiveIntegerField(_('views'), default=0) # LJX: 浏览量 - author = models.ForeignKey( # LJX: 作者,外键关联用户模型 - settings.AUTH_USER_MODEL, - verbose_name=_('author'), - blank=False, - null=False, - on_delete=models.CASCADE) # LJX: 级联删除 - article_order = models.IntegerField( # LJX: 文章排序 - _('order'), blank=False, null=False, default=0) - show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) # LJX: 是否显示目录 - category = models.ForeignKey( # LJX: 分类,外键关联分类模型 - 'Category', - verbose_name=_('category'), - on_delete=models.CASCADE, - blank=False, - null=False) - tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) # LJX: 标签,多对多关系 - - def body_to_string(self): - """LJX: 将文章内容转换为字符串""" - return self.body - - def __str__(self): - """LJX: 字符串表示,返回文章标题""" - return self.title - - class Meta: - ordering = ['-article_order', '-pub_time'] # LJX: 默认按排序和发布时间降序 - verbose_name = _('article') # LJX: 单数名称 - verbose_name_plural = verbose_name # LJX: 复数名称 - get_latest_by = 'id' # LJX: 最新记录按ID - - def get_absolute_url(self): - """LJX: 获取文章绝对URL,用于生成文章详情页链接""" - return reverse('blog:detailbyid', kwargs={ - 'article_id': self.id, - 'year': self.creation_time.year, # LJX: 包含年月日用于SEO友好的URL - 'month': self.creation_time.month, - 'day': self.creation_time.day - }) - - @cache_decorator(60 * 60 * 10) # LJX: 缓存10小时 - def get_category_tree(self): - """LJX: 获取分类树,返回分类的层级结构""" - tree = self.category.get_category_tree() # LJX: 调用分类的获取分类树方法 - names = list(map(lambda c: (c.name, c.get_absolute_url()), tree)) # LJX: 转换为名称和URL的元组列表 - return names - - def save(self, *args, **kwargs): - """LJX: 重写保存方法""" - super().save(*args, **kwargs) - - def viewed(self): - """LJX: 增加文章浏览量""" - self.views += 1 - self.save(update_fields=['views']) # LJX: 只更新views字段 - - def comment_list(self): - """LJX: 获取文章评论列表,使用缓存提高性能""" - 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 # LJX: 如果缓存中存在,直接返回 - else: - comments = self.comment_set.filter(is_enable=True).order_by('-id') # LJX: 获取已启用的评论,按ID降序 - cache.set(cache_key, comments, 60 * 100) # LJX: 缓存100分钟 - logger.info('set article comments:{id}'.format(id=self.id)) - return comments - - def get_admin_url(self): - """LJX: 获取文章在Admin后台的URL""" - info = (self._meta.app_label, self._meta.model_name) - return reverse('admin:%s_%s_change' % info, args=(self.pk,)) - - @cache_decorator(expiration=60 * 100) # LJX: 缓存100分钟 - def next_article(self): - """LJX: 获取下一篇文章""" - return Article.objects.filter( - id__gt=self.id, status='p').order_by('id').first() # LJX: ID大于当前文章的第一篇已发布文章 - - @cache_decorator(expiration=60 * 100) # LJX: 缓存100分钟 - def prev_article(self): - """LJX: 获取上一篇文章""" - return Article.objects.filter(id__lt=self.id, status='p').first() # LJX: ID小于当前文章的第一篇已发布文章 - - def get_first_image_url(self): - """LJX: 从文章内容中提取第一张图片的URL""" - match = re.search(r'!\[.*?\]\((.+?)\)', self.body) # LJX: 使用正则匹配Markdown图片语法 - if match: - return match.group(1) # LJX: 返回图片URL - return "" - -class Category(BaseModel): - """LJX: 文章分类模型,支持多级分类结构""" - name = models.CharField(_('category name'), max_length=30, unique=True) # LJX: 分类名称,唯一 - parent_category = models.ForeignKey( # LJX: 父级分类,支持分类层级 - 'self', - verbose_name=_('parent category'), - blank=True, - null=True, - on_delete=models.CASCADE) # LJX: 自关联外键 - slug = models.SlugField(default='no-slug', max_length=60, blank=True) # LJX: URL友好名称 - index = models.IntegerField(default=0, verbose_name=_('index')) # LJX: 排序索引 - - class Meta: - ordering = ['-index'] # LJX: 按索引降序排列 - verbose_name = _('category') # LJX: 单数名称 - verbose_name_plural = verbose_name # LJX: 复数名称 - - def get_absolute_url(self): - """LJX: 获取分类绝对URL""" - return reverse( - 'blog:category_detail', kwargs={ - 'category_name': self.slug}) # LJX: 使用slug作为URL参数 - - def __str__(self): - """LJX: 字符串表示,返回分类名称""" - return self.name - - @cache_decorator(60 * 60 * 10) # LJX: 缓存10小时 - def get_category_tree(self): - """LJX: 递归获得分类目录的父级,返回从当前分类到根分类的路径""" - categorys = [] # LJX: 存储分类路径 - - def parse(category): - """LJX: 递归解析分类父级""" - categorys.append(category) - if category.parent_category: # LJX: 如果存在父分类,继续递归 - parse(category.parent_category) - - parse(self) # LJX: 从当前分类开始解析 - return categorys - - @cache_decorator(60 * 60 * 10) # LJX: 缓存10小时 - def get_sub_categorys(self): - """LJX: 获得当前分类目录所有子集,包括所有下级分类""" - categorys = [] # LJX: 存储所有子分类 - all_categorys = Category.objects.all() # LJX: 获取所有分类 - - def parse(category): - """LJX: 递归解析子分类""" - if category not in categorys: - categorys.append(category) # LJX: 添加当前分类 - childs = all_categorys.filter(parent_category=category) # LJX: 查找直接子分类 - for child in childs: - if category not in categorys: - categorys.append(child) # LJX: 添加子分类 - parse(child) # LJX: 递归解析子分类的子分类 - - parse(self) # LJX: 从当前分类开始解析 - return categorys - - -class Tag(BaseModel): - """LJX: 文章标签模型,用于文章分类和检索""" - name = models.CharField(_('tag name'), max_length=30, unique=True) # LJX: 标签名称,唯一 - slug = models.SlugField(default='no-slug', max_length=60, blank=True) # LJX: URL友好名称 - - def __str__(self): - """LJX: 字符串表示,返回标签名称""" - return self.name - - def get_absolute_url(self): - """LJX: 获取标签绝对URL""" - return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) # LJX: 使用slug作为URL参数 - - @cache_decorator(60 * 60 * 10) # LJX: 缓存10小时 - def get_article_count(self): - """LJX: 获取使用该标签的文章数量""" - return Article.objects.filter(tags__name=self.name).distinct().count() # LJX: 去重计数 - - class Meta: - ordering = ['name'] # LJX: 按名称排序 - verbose_name = _('tag') # LJX: 单数名称 - verbose_name_plural = verbose_name # LJX: 复数名称 - - -class Links(models.Model): - """LJX: 友情链接模型,管理网站的外部链接""" - - name = models.CharField(_('link name'), max_length=30, unique=True) # LJX: 链接名称,唯一 - link = models.URLField(_('link')) # LJX: 链接地址 - sequence = models.IntegerField(_('order'), unique=True) # LJX: 显示顺序,唯一 - is_enable = models.BooleanField( # LJX: 是否启用显示 - _('is show'), default=True, blank=False, null=False) - show_type = models.CharField( # LJX: 显示类型 - _('show type'), - max_length=1, - choices=LinkShowType.choices, - default=LinkShowType.I) # LJX: 默认在首页显示 - creation_time = models.DateTimeField(_('creation time'), default=now) # LJX: 创建时间 - last_mod_time = models.DateTimeField(_('modify time'), default=now) # LJX: 最后修改时间 - - class Meta: - ordering = ['sequence'] # LJX: 按顺序排序 - verbose_name = _('link') # LJX: 单数名称 - verbose_name_plural = verbose_name # LJX: 复数名称 - - def __str__(self): - """LJX: 字符串表示,返回链接名称""" - return self.name - - -class SideBar(models.Model): - """LJX: 侧边栏模型,可以展示一些HTML内容""" - name = models.CharField(_('title'), max_length=100) # LJX: 侧边栏标题 - content = models.TextField(_('content')) # LJX: 侧边栏内容,支持HTML - sequence = models.IntegerField(_('order'), unique=True) # LJX: 显示顺序,唯一 - is_enable = models.BooleanField(_('is enable'), default=True) # LJX: 是否启用 - creation_time = models.DateTimeField(_('creation time'), default=now) # LJX: 创建时间 - last_mod_time = models.DateTimeField(_('modify time'), default=now) # LJX: 最后修改时间 - - class Meta: - ordering = ['sequence'] # LJX: 按顺序排序 - verbose_name = _('sidebar') # LJX: 单数名称 - verbose_name_plural = verbose_name # LJX: 复数名称 - - def __str__(self): - """LJX: 字符串表示,返回侧边栏名称""" - return self.name - - -class BlogSettings(models.Model): - """LJX: 博客设置模型,存储博客的全局配置信息""" - site_name = models.CharField( # LJX: 网站名称 - _('site name'), - max_length=200, - null=False, - blank=False, - default='') - site_description = models.TextField( # LJX: 网站描述 - _('site description'), - max_length=1000, - null=False, - blank=False, - default='') - site_seo_description = models.TextField( # LJX: 网站SEO描述 - _('site seo description'), max_length=1000, null=False, blank=False, default='') - site_keywords = models.TextField( # LJX: 网站关键词 - _('site keywords'), - max_length=1000, - null=False, - blank=False, - default='') - article_sub_length = models.IntegerField(_('article sub length'), default=300) # LJX: 文章摘要长度 - sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) # LJX: 侧边栏文章数量 - sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5) # LJX: 侧边栏评论数量 - article_comment_count = models.IntegerField(_('article comment count'), default=5) # LJX: 文章评论显示数量 - show_google_adsense = models.BooleanField(_('show adsense'), default=False) # LJX: 是否显示Google广告 - google_adsense_codes = models.TextField( # LJX: Google广告代码 - _('adsense code'), max_length=2000, null=True, blank=True, default='') - open_site_comment = models.BooleanField(_('open site comment'), default=True) # LJX: 是否开启全站评论 - global_header = models.TextField("公共头部", null=True, blank=True, default='') # LJX: 全局头部HTML - global_footer = models.TextField("公共尾部", null=True, blank=True, default='') # LJX: 全局尾部HTML - beian_code = models.CharField( # LJX: 备案号 - '备案号', - max_length=2000, - null=True, - blank=True, - default='') - analytics_code = models.TextField( # LJX: 网站统计代码 - "网站统计代码", - max_length=1000, - null=False, - blank=False, - default='') - show_gongan_code = models.BooleanField( # LJX: 是否显示公安备案号 - '是否显示公安备案号', default=False, null=False) - gongan_beiancode = models.TextField( # LJX: 公安备案号 - '公安备案号', - max_length=2000, - null=True, - blank=True, - default='') - comment_need_review = models.BooleanField( # LJX: 评论是否需要审核 - '评论是否需要审核', default=False, null=False) - - class Meta: - verbose_name = _('Website configuration') # LJX: 单数名称 - verbose_name_plural = verbose_name # LJX: 复数名称 - - def __str__(self): - """LJX: 字符串表示,返回网站名称""" - return self.site_name - - def clean(self): - """LJX: 数据清洗验证,确保只能有一个配置实例""" - if BlogSettings.objects.exclude(id=self.id).count(): - raise ValidationError(_('There can only be one configuration')) # LJX: 只能有一个配置 - - def save(self, *args, **kwargs): - """LJX: 重写保存方法,保存后清除缓存""" - super().save(*args, **kwargs) - from djangoblog.utils import cache - cache.clear() # LJX: 清除缓存,使配置立即生效 \ No newline at end of file diff --git a/blog-lsh/search_indexes.py b/blog-lsh/search_indexes.py deleted file mode 100644 index 09af7eb..0000000 --- a/blog-lsh/search_indexes.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -LJX: Haystack搜索索引配置模块 -定义Django Haystack的搜索索引配置,用于全文搜索功能 -""" -from haystack import indexes - -from blog.models import Article - - -class ArticleIndex(indexes.SearchIndex, indexes.Indexable): - """LJX: 文章搜索索引类,定义文章的搜索字段和索引行为""" - text = indexes.CharField(document=True, use_template=True) # LJX: 主搜索字段,使用模板定义 - - def get_model(self): - """LJX: 返回要索引的模型类""" - return Article - - def index_queryset(self, using=None): - """LJX: 返回要索引的查询集,只索引已发布的文章""" - return self.get_model().objects.filter(status='p') # LJX: 只索引已发布状态的文章 \ No newline at end of file diff --git a/blog-lsh/templatetags/__pycache__/__init__.cpython-312.pyc b/blog-lsh/templatetags/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 8e5af91..0000000 Binary files a/blog-lsh/templatetags/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/templatetags/__pycache__/blog_tags.cpython-312.pyc b/blog-lsh/templatetags/__pycache__/blog_tags.cpython-312.pyc deleted file mode 100644 index e47902d..0000000 Binary files a/blog-lsh/templatetags/__pycache__/blog_tags.cpython-312.pyc and /dev/null differ diff --git a/blog-lsh/urls.py b/blog-lsh/urls.py deleted file mode 100644 index 95aa89d..0000000 --- a/blog-lsh/urls.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -LJX: URL路由配置模块 -定义blog应用的所有URL路由规则,将URL映射到相应的视图函数或类 -使用Django的path函数定义RESTful风格的URL -""" -from django.urls import path -from django.views.decorators.cache import cache_page - -from . import views - -app_name = "blog" # LJX: 应用命名空间,用于URL反向解析 -urlpatterns = [ - # LJX: 首页路由 - 显示文章列表 - path( - r'', - views.IndexView.as_view(), # LJX: 使用类视图处理首页 - name='index'), # LJX: URL名称,用于反向解析 - # LJX: 首页分页路由 - 显示指定页码的文章列表 - path( - r'page//', # LJX: 页码参数,整数类型 - views.IndexView.as_view(), - name='index_page'), - # LJX: 文章详情页路由 - 使用年月日和文章ID构建SEO友好的URL - path( - r'article////.html', # LJX: 包含年月日的URL结构 - views.ArticleDetailView.as_view(), - name='detailbyid'), # LJX: 通过ID获取文章详情 - # LJX: 分类详情页路由 - 显示指定分类的文章列表 - path( - r'category/.html', # LJX: 使用slug作为分类标识 - views.CategoryDetailView.as_view(), - name='category_detail'), - # LJX: 分类分页路由 - 显示指定分类的指定页码 - path( - r'category//.html', - views.CategoryDetailView.as_view(), - name='category_detail_page'), - # LJX: 作者详情页路由 - 显示指定作者的文章列表 - path( - r'author/.html', # LJX: 作者用户名作为参数 - views.AuthorDetailView.as_view(), - name='author_detail'), - # LJX: 作者分页路由 - 显示指定作者的指定页码 - path( - r'author//.html', - views.AuthorDetailView.as_view(), - name='author_detail_page'), - # LJX: 标签详情页路由 - 显示指定标签的文章列表 - path( - r'tag/.html', # LJX: 使用slug作为标签标识 - views.TagDetailView.as_view(), - name='tag_detail'), - # LJX: 标签分页路由 - 显示指定标签的指定页码 - path( - r'tag//.html', - views.TagDetailView.as_view(), - name='tag_detail_page'), - # LJX: 文章归档页路由 - 显示所有文章的归档列表,使用缓存提高性能 - path( - 'archives.html', - cache_page( # LJX: 页面缓存,60*60秒=1小时 - 60 * 60)( - views.ArchivesView.as_view()), - name='archives'), - # LJX: 友情链接页路由 - 显示所有友情链接 - path( - 'links.html', - views.LinkListView.as_view(), - name='links'), - # LJX: 文件上传路由 - 处理图片等文件的上传 - path( - r'upload', - views.fileupload, # LJX: 使用函数视图处理文件上传 - name='upload'), - # LJX: 缓存清理路由 - 用于手动清理系统缓存 - path( - r'clean', - views.clean_cache_view, - name='clean'), -] \ No newline at end of file diff --git a/blog-lsh/templatetags/__init__.py b/comments/__init__.py similarity index 100% rename from blog-lsh/templatetags/__init__.py rename to comments/__init__.py diff --git a/comments/admin.py b/comments/admin.py new file mode 100644 index 0000000..a814f3f --- /dev/null +++ b/comments/admin.py @@ -0,0 +1,47 @@ +from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + + +def disable_commentstatus(modeladmin, request, queryset): + queryset.update(is_enable=False) + + +def enable_commentstatus(modeladmin, request, queryset): + queryset.update(is_enable=True) + + +disable_commentstatus.short_description = _('Disable comments') +enable_commentstatus.short_description = _('Enable comments') + + +class CommentAdmin(admin.ModelAdmin): + list_per_page = 20 + list_display = ( + 'id', + 'body', + 'link_to_userinfo', + 'link_to_article', + 'is_enable', + 'creation_time') + list_display_links = ('id', 'body', 'is_enable') + list_filter = ('is_enable',) + exclude = ('creation_time', 'last_modify_time') + actions = [disable_commentstatus, enable_commentstatus] + + def link_to_userinfo(self, obj): + info = (obj.author._meta.app_label, obj.author._meta.model_name) + link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + return format_html( + u'%s' % + (link, obj.author.nickname if obj.author.nickname else obj.author.email)) + + def link_to_article(self, obj): + info = (obj.article._meta.app_label, obj.article._meta.model_name) + link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) + return format_html( + u'%s' % (link, obj.article.title)) + + link_to_userinfo.short_description = _('User') + link_to_article.short_description = _('Article') diff --git a/comments/apps.py b/comments/apps.py new file mode 100644 index 0000000..ff01b77 --- /dev/null +++ b/comments/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + name = 'comments' diff --git a/comments/forms.py b/comments/forms.py new file mode 100644 index 0000000..e83737d --- /dev/null +++ b/comments/forms.py @@ -0,0 +1,13 @@ +from django import forms +from django.forms import ModelForm + +from .models import Comment + + +class CommentForm(ModelForm): + parent_comment_id = forms.IntegerField( + widget=forms.HiddenInput, required=False) + + class Meta: + model = Comment + fields = ['body'] diff --git a/comments/migrations/0001_initial.py b/comments/migrations/0001_initial.py new file mode 100644 index 0000000..61d1e53 --- /dev/null +++ b/comments/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('blog', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('body', models.TextField(max_length=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, verbose_name='是否显示')), + ('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='作者')), + ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')), + ], + options={ + 'verbose_name': '评论', + 'verbose_name_plural': '评论', + 'ordering': ['-id'], + 'get_latest_by': 'id', + }, + ), + ] diff --git a/comments/migrations/0002_alter_comment_is_enable.py b/comments/migrations/0002_alter_comment_is_enable.py new file mode 100644 index 0000000..17c44db --- /dev/null +++ b/comments/migrations/0002_alter_comment_is_enable.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2023-04-24 13:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('comments', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='comment', + name='is_enable', + field=models.BooleanField(default=False, verbose_name='是否显示'), + ), + ] diff --git a/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py b/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py new file mode 100644 index 0000000..a1ca970 --- /dev/null +++ b/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('blog', '0005_alter_article_options_alter_category_options_and_more'), + ('comments', '0002_alter_comment_is_enable'), + ] + + operations = [ + migrations.AlterModelOptions( + name='comment', + options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'}, + ), + migrations.RemoveField( + model_name='comment', + name='created_time', + ), + migrations.RemoveField( + model_name='comment', + name='last_mod_time', + ), + migrations.AddField( + model_name='comment', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='comment', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + ), + migrations.AlterField( + model_name='comment', + name='article', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'), + ), + migrations.AlterField( + model_name='comment', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), + ), + migrations.AlterField( + model_name='comment', + name='is_enable', + field=models.BooleanField(default=False, verbose_name='enable'), + ), + migrations.AlterField( + model_name='comment', + name='parent_comment', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'), + ), + ] diff --git a/comments/migrations/__init__.py b/comments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comments/models.py b/comments/models.py new file mode 100644 index 0000000..7c3bbc8 --- /dev/null +++ b/comments/models.py @@ -0,0 +1,39 @@ +from django.conf import settings +from django.db import models +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ + +from blog.models import Article + + +# Create your models here. + +class Comment(models.Model): + body = models.TextField('正文', max_length=300) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('author'), + on_delete=models.CASCADE) + article = models.ForeignKey( + Article, + verbose_name=_('article'), + on_delete=models.CASCADE) + parent_comment = models.ForeignKey( + 'self', + verbose_name=_('parent comment'), + blank=True, + null=True, + on_delete=models.CASCADE) + is_enable = models.BooleanField(_('enable'), + default=False, blank=False, null=False) + + class Meta: + ordering = ['-id'] + verbose_name = _('comment') + verbose_name_plural = verbose_name + get_latest_by = 'id' + + def __str__(self): + return self.body diff --git a/comments/templatetags/__init__.py b/comments/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comments/templatetags/comments_tags.py b/comments/templatetags/comments_tags.py new file mode 100644 index 0000000..fde02b4 --- /dev/null +++ b/comments/templatetags/comments_tags.py @@ -0,0 +1,30 @@ +from django import template + +register = template.Library() + + +@register.simple_tag +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) + for child in childs: + datas.append(child) + parse(child) + + parse(comment) + return datas + + +@register.inclusion_tag('comments/tags/comment_item.html') +def show_comment_item(comment, ischild): + """评论""" + depth = 1 if ischild else 2 + return { + 'comment_item': comment, + 'depth': depth + } diff --git a/comments/tests.py b/comments/tests.py new file mode 100644 index 0000000..2a7f55f --- /dev/null +++ b/comments/tests.py @@ -0,0 +1,109 @@ +from django.test import Client, RequestFactory, TransactionTestCase +from django.urls import reverse + +from accounts.models import BlogUser +from blog.models import Category, Article +from comments.models import Comment +from comments.templatetags.comments_tags import * +from djangoblog.utils import get_max_articleid_commentid + + +# Create your tests here. + +class CommentsTest(TransactionTestCase): + def setUp(self): + self.client = Client() + self.factory = RequestFactory() + from blog.models import BlogSettings + value = BlogSettings() + value.comment_need_review = True + value.save() + + self.user = BlogUser.objects.create_superuser( + email="liangliangyy1@gmail.com", + username="liangliangyy1", + password="liangliangyy1") + + def update_article_comment_status(self, article): + comments = article.comment_set.all() + for comment in comments: + comment.is_enable = True + comment.save() + + def test_validate_comment(self): + self.client.login(username='liangliangyy1', password='liangliangyy1') + + category = Category() + category.name = "categoryccc" + category.save() + + article = Article() + article.title = "nicetitleccc" + article.body = "nicecontentccc" + article.author = self.user + article.category = category + article.type = 'a' + article.status = 'p' + article.save() + + comment_url = reverse( + 'comments:postcomment', kwargs={ + 'article_id': article.id}) + + response = self.client.post(comment_url, + { + 'body': '123ffffffffff' + }) + + self.assertEqual(response.status_code, 302) + + article = Article.objects.get(pk=article.pk) + self.assertEqual(len(article.comment_list()), 0) + self.update_article_comment_status(article) + + self.assertEqual(len(article.comment_list()), 1) + + response = self.client.post(comment_url, + { + 'body': '123ffffffffff', + }) + + self.assertEqual(response.status_code, 302) + + article = Article.objects.get(pk=article.pk) + self.update_article_comment_status(article) + self.assertEqual(len(article.comment_list()), 2) + parent_comment_id = article.comment_list()[0].id + + response = self.client.post(comment_url, + { + 'body': ''' + # Title1 + + ```python + import os + ``` + + [url](https://www.lylinux.net/) + + [ddd](http://www.baidu.com) + + + ''', + 'parent_comment_id': parent_comment_id + }) + + 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) + comment = Comment.objects.get(id=parent_comment_id) + tree = parse_commenttree(article.comment_list(), comment) + self.assertEqual(len(tree), 1) + data = show_comment_item(comment, True) + self.assertIsNotNone(data) + s = get_max_articleid_commentid() + self.assertIsNotNone(s) + + from comments.utils import send_comment_email + send_comment_email(comment) diff --git a/comments/urls.py b/comments/urls.py new file mode 100644 index 0000000..7df3fab --- /dev/null +++ b/comments/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + +app_name = "comments" +urlpatterns = [ + path( + 'article//postcomment', + views.CommentPostView.as_view(), + name='postcomment'), +] diff --git a/comments/utils.py b/comments/utils.py new file mode 100644 index 0000000..f01dba7 --- /dev/null +++ b/comments/utils.py @@ -0,0 +1,38 @@ +import logging + +from django.utils.translation import gettext_lazy as _ + +from djangoblog.utils import get_current_site +from djangoblog.utils import send_email + +logger = logging.getLogger(__name__) + + +def send_comment_email(comment): + site = get_current_site().domain + subject = _('Thanks for your comment') + article_url = f"https://{site}{comment.article.get_absolute_url()}" + html_content = _("""

Thank you very much for your comments on this site

+ You can visit %(article_title)s + to review your comments, + Thank you again! +
+ 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} + tomail = comment.author.email + send_email([tomail], subject, html_content) + try: + if comment.parent_comment: + html_content = _("""Your comment on %(article_title)s
has + received a reply.
%(comment_body)s +
+ go check it out! +
+ 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, + 'comment_body': comment.parent_comment.body} + tomail = comment.parent_comment.author.email + send_email([tomail], subject, html_content) + except Exception as e: + logger.error(e) diff --git a/comments/views.py b/comments/views.py new file mode 100644 index 0000000..ad9b2b9 --- /dev/null +++ b/comments/views.py @@ -0,0 +1,63 @@ +# Create your views here. +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect +from django.views.generic.edit import FormView + +from accounts.models import BlogUser +from blog.models import Article +from .forms import CommentForm +from .models import Comment + + +class CommentPostView(FormView): + form_class = CommentForm + template_name = 'blog/article_detail.html' + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + return super(CommentPostView, self).dispatch(*args, **kwargs) + + def get(self, request, *args, **kwargs): + article_id = self.kwargs['article_id'] + article = get_object_or_404(Article, pk=article_id) + url = article.get_absolute_url() + return HttpResponseRedirect(url + "#comments") + + def form_invalid(self, form): + article_id = self.kwargs['article_id'] + article = get_object_or_404(Article, pk=article_id) + + return self.render_to_response({ + 'form': form, + 'article': article + }) + + def form_valid(self, form): + """提交的数据验证合法后的逻辑""" + user = self.request.user + author = BlogUser.objects.get(pk=user.pk) + article_id = self.kwargs['article_id'] + article = get_object_or_404(Article, pk=article_id) + + if article.comment_status == 'c' or article.status == 'c': + raise ValidationError("该文章评论已关闭.") + comment = form.save(False) + comment.article = article + from djangoblog.utils import get_blog_setting + settings = get_blog_setting() + if not settings.comment_need_review: + comment.is_enable = True + comment.author = author + + if form.cleaned_data['parent_comment_id']: + parent_comment = Comment.objects.get( + pk=form.cleaned_data['parent_comment_id']) + comment.parent_comment = parent_comment + + comment.save(True) + return HttpResponseRedirect( + "%s#div-comment-%d" % + (article.get_absolute_url(), comment.pk)) diff --git a/djangoblog/__init__.py b/djangoblog/__init__.py new file mode 100644 index 0000000..1e205f4 --- /dev/null +++ b/djangoblog/__init__.py @@ -0,0 +1 @@ +default_app_config = 'djangoblog.apps.DjangoblogAppConfig' diff --git a/djangoblog/admin_site.py b/djangoblog/admin_site.py new file mode 100644 index 0000000..f120405 --- /dev/null +++ b/djangoblog/admin_site.py @@ -0,0 +1,64 @@ +from django.contrib.admin import AdminSite +from django.contrib.admin.models import LogEntry +from django.contrib.sites.admin import SiteAdmin +from django.contrib.sites.models import Site + +from accounts.admin import * +from blog.admin import * +from blog.models import * +from comments.admin import * +from comments.models import * +from djangoblog.logentryadmin import LogEntryAdmin +from oauth.admin import * +from oauth.models import * +from owntracks.admin import * +from owntracks.models import * +from servermanager.admin import * +from servermanager.models import * + + +class DjangoBlogAdminSite(AdminSite): + site_header = 'djangoblog administration' + site_title = 'djangoblog site admin' + + def __init__(self, name='admin'): + super().__init__(name) + + def has_permission(self, request): + return request.user.is_superuser + + # def get_urls(self): + # 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.register(Article, ArticlelAdmin) +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(EmailSendLog, EmailSendLogAdmin) + +admin_site.register(BlogUser, BlogUserAdmin) + +admin_site.register(Comment, CommentAdmin) + +admin_site.register(OAuthUser, OAuthUserAdmin) +admin_site.register(OAuthConfig, OAuthConfigAdmin) + +admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) + +admin_site.register(Site, SiteAdmin) + +admin_site.register(LogEntry, LogEntryAdmin) diff --git a/djangoblog/apps.py b/djangoblog/apps.py new file mode 100644 index 0000000..d29e318 --- /dev/null +++ b/djangoblog/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + +class DjangoblogAppConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'djangoblog' + + def ready(self): + super().ready() + # Import and load plugins here + from .plugin_manage.loader import load_plugins + load_plugins() \ No newline at end of file diff --git a/djangoblog/blog_signals.py b/djangoblog/blog_signals.py new file mode 100644 index 0000000..393f441 --- /dev/null +++ b/djangoblog/blog_signals.py @@ -0,0 +1,122 @@ +import _thread +import logging + +import django.dispatch +from django.conf import settings +from django.contrib.admin.models import LogEntry +from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.core.mail import EmailMultiAlternatives +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__) + +oauth_user_login_signal = django.dispatch.Signal(['id']) +send_email_signal = django.dispatch.Signal( + ['emailto', 'title', 'content']) + + +@receiver(send_email_signal) +def send_email_signal_handler(sender, **kwargs): + emailto = kwargs['emailto'] + title = kwargs['title'] + content = kwargs['content'] + + msg = EmailMultiAlternatives( + title, + content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=emailto) + msg.content_subtype = "html" + + from servermanager.models import EmailSendLog + log = EmailSendLog() + log.title = title + log.content = content + log.emailto = ','.join(emailto) + + try: + result = msg.send() + log.send_result = result > 0 + except Exception as e: + logger.error(f"失败邮箱号: {emailto}, {e}") + log.send_result = False + log.save() + + +@receiver(oauth_user_login_signal) +def oauth_user_login_signal_handler(sender, **kwargs): + id = kwargs['id'] + oauthuser = OAuthUser.objects.get(id=id) + site = get_current_site().domain + if oauthuser.picture and not oauthuser.picture.find(site) >= 0: + from djangoblog.utils import save_user_avatar + oauthuser.picture = save_user_avatar(oauthuser.picture) + oauthuser.save() + + delete_sidebar_cache() + + +@receiver(post_save) +def model_post_save_callback( + sender, + instance, + created, + raw, + using, + update_fields, + **kwargs): + clearcache = False + if isinstance(instance, LogEntry): + return + if 'get_full_url' in dir(instance): + is_update_views = update_fields == {'views'} + if not settings.TESTING and not is_update_views: + try: + notify_url = instance.get_full_url() + SpiderNotify.baidu_notify([notify_url]) + except Exception as ex: + logger.error("notify sipder", ex) + if not is_update_views: + clearcache = True + + if isinstance(instance, Comment): + if instance.is_enable: + path = instance.article.get_absolute_url() + site = get_current_site().domain + if site.find(':') > 0: + site = site[0:site.find(':')] + + expire_view_cache( + path, + servername=site, + serverport=80, + key_prefix='blogdetail') + if cache.get('seo_processor'): + cache.delete('seo_processor') + comment_cache_key = 'article_comments_{id}'.format( + id=instance.article.id) + cache.delete(comment_cache_key) + delete_sidebar_cache() + delete_view_cache('article_comments', [str(instance.article.pk)]) + + _thread.start_new_thread(send_comment_email, (instance,)) + + if clearcache: + cache.clear() + + +@receiver(user_logged_in) +@receiver(user_logged_out) +def user_auth_callback(sender, request, user, **kwargs): + if user and user.username: + logger.info(user) + delete_sidebar_cache() + # cache.clear() diff --git a/djangoblog/elasticsearch_backend.py b/djangoblog/elasticsearch_backend.py new file mode 100644 index 0000000..4afe498 --- /dev/null +++ b/djangoblog/elasticsearch_backend.py @@ -0,0 +1,183 @@ +from django.utils.encoding import force_str +from elasticsearch_dsl import Q +from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query +from haystack.forms import ModelSearchForm +from haystack.models import SearchResult +from haystack.utils import log as logging + +from blog.documents import ArticleDocument, ArticleDocumentManager +from blog.models import Article + +logger = logging.getLogger(__name__) + + +class ElasticSearchBackend(BaseSearchBackend): + def __init__(self, connection_alias, **connection_options): + super( + ElasticSearchBackend, + self).__init__( + connection_alias, + **connection_options) + self.manager = ArticleDocumentManager() + self.include_spelling = True + + def _get_models(self, iterable): + models = iterable if iterable and iterable[0] else Article.objects.all() + docs = self.manager.convert_to_doc(models) + return docs + + def _create(self, models): + self.manager.create_index() + docs = self._get_models(models) + self.manager.rebuild(docs) + + def _delete(self, models): + for m in models: + m.delete() + return True + + def _rebuild(self, models): + models = models if models else Article.objects.all() + docs = self.manager.convert_to_doc(models) + self.manager.update_docs(docs) + + def update(self, index, iterable, commit=True): + + models = self._get_models(iterable) + self.manager.update_docs(models) + + def remove(self, obj_or_string): + models = self._get_models([obj_or_string]) + self._delete(models) + + def clear(self, models=None, commit=True): + self.remove(None) + + @staticmethod + def get_suggestion(query: str) -> str: + """获取推荐词, 如果没有找到添加原搜索词""" + + search = ArticleDocument.search() \ + .query("match", body=query) \ + .suggest('suggest_search', query, term={'field': 'body'}) \ + .execute() + + keywords = [] + for suggest in search.suggest.suggest_search: + if suggest["options"]: + keywords.append(suggest["options"][0]["text"]) + else: + keywords.append(suggest["text"]) + + return ' '.join(keywords) + + @log_query + def search(self, query_string, **kwargs): + logger.info('search query_string:' + query_string) + + start_offset = kwargs.get('start_offset') + end_offset = kwargs.get('end_offset') + + # 推荐词搜索 + if getattr(self, "is_suggest", None): + suggestion = self.get_suggestion(query_string) + else: + suggestion = query_string + + q = Q('bool', + should=[Q('match', body=suggestion), Q('match', title=suggestion)], + minimum_should_match="70%") + + search = ArticleDocument.search() \ + .query('bool', filter=[q]) \ + .filter('term', status='p') \ + .filter('term', type='a') \ + .source(False)[start_offset: end_offset] + + results = search.execute() + hits = results['hits'].total + raw_results = [] + for raw_result in results['hits']['hits']: + app_label = 'blog' + model_name = 'Article' + additional_fields = {} + + result_class = SearchResult + + result = result_class( + app_label, + model_name, + raw_result['_id'], + raw_result['_score'], + **additional_fields) + raw_results.append(result) + facets = {} + 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 diff --git a/djangoblog/feeds.py b/djangoblog/feeds.py new file mode 100644 index 0000000..8c4e851 --- /dev/null +++ b/djangoblog/feeds.py @@ -0,0 +1,40 @@ +from django.contrib.auth import get_user_model +from django.contrib.syndication.views import Feed +from django.utils import timezone +from django.utils.feedgenerator import Rss201rev2Feed + +from blog.models import Article +from djangoblog.utils import CommonMarkdown + + +class DjangoBlogFeed(Feed): + feed_type = Rss201rev2Feed + + description = '大巧无工,重剑无锋.' + title = "且听风吟 大巧无工,重剑无锋. " + link = "/feed/" + + def author_name(self): + return get_user_model().objects.first().nickname + + def author_link(self): + return get_user_model().objects.first().get_absolute_url() + + def items(self): + return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5] + + def item_title(self, item): + return item.title + + def item_description(self, item): + return CommonMarkdown.get_markdown(item.body) + + def feed_copyright(self): + now = timezone.now() + return "Copyright© {year} 且听风吟".format(year=now.year) + + def item_link(self, item): + return item.get_absolute_url() + + def item_guid(self, item): + return diff --git a/djangoblog/logentryadmin.py b/djangoblog/logentryadmin.py new file mode 100644 index 0000000..2f6a535 --- /dev/null +++ b/djangoblog/logentryadmin.py @@ -0,0 +1,91 @@ +from django.contrib import admin +from django.contrib.admin.models import DELETION +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse, NoReverseMatch +from django.utils.encoding import force_str +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + + +class LogEntryAdmin(admin.ModelAdmin): + list_filter = [ + 'content_type' + ] + + search_fields = [ + 'object_repr', + 'change_message' + ] + + list_display_links = [ + 'action_time', + 'get_change_message', + ] + list_display = [ + 'action_time', + 'user_link', + 'content_type', + 'object_link', + 'get_change_message', + ] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return ( + request.user.is_superuser or + request.user.has_perm('admin.change_logentry') + ) and request.method != 'POST' + + def has_delete_permission(self, request, obj=None): + return False + + def object_link(self, obj): + object_link = escape(obj.object_repr) + content_type = obj.content_type + + if obj.action_flag != DELETION and content_type is not None: + # try returning an actual link instead of object repr string + try: + url = reverse( + 'admin:{}_{}_change'.format(content_type.app_label, + content_type.model), + args=[obj.object_id] + ) + object_link = '{}'.format(url, object_link) + except NoReverseMatch: + pass + return mark_safe(object_link) + + object_link.admin_order_field = 'object_repr' + object_link.short_description = _('object') + + def user_link(self, obj): + content_type = ContentType.objects.get_for_model(type(obj.user)) + user_link = escape(force_str(obj.user)) + try: + # try returning an actual link instead of object repr string + url = reverse( + 'admin:{}_{}_change'.format(content_type.app_label, + content_type.model), + args=[obj.user.pk] + ) + user_link = '{}'.format(url, user_link) + except NoReverseMatch: + pass + return mark_safe(user_link) + + user_link.admin_order_field = 'user' + user_link.short_description = _('user') + + def get_queryset(self, request): + queryset = super(LogEntryAdmin, self).get_queryset(request) + return queryset.prefetch_related('content_type') + + def get_actions(self, request): + actions = super(LogEntryAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions diff --git a/djangoblog/plugin_manage/base_plugin.py b/djangoblog/plugin_manage/base_plugin.py new file mode 100644 index 0000000..2b4be5c --- /dev/null +++ b/djangoblog/plugin_manage/base_plugin.py @@ -0,0 +1,41 @@ +import logging + +logger = logging.getLogger(__name__) + + +class BasePlugin: + # 插件元数据 + PLUGIN_NAME = None + PLUGIN_DESCRIPTION = None + PLUGIN_VERSION = None + + def __init__(self): + 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.") + self.init_plugin() + self.register_hooks() + + def init_plugin(self): + """ + 插件初始化逻辑 + 子类可以重写此方法来实现特定的初始化操作 + """ + logger.info(f'{self.PLUGIN_NAME} initialized.') + + def register_hooks(self): + """ + 注册插件钩子 + 子类可以重写此方法来注册特定的钩子 + """ + pass + + def get_plugin_info(self): + """ + 获取插件信息 + :return: 包含插件元数据的字典 + """ + return { + 'name': self.PLUGIN_NAME, + 'description': self.PLUGIN_DESCRIPTION, + 'version': self.PLUGIN_VERSION + } diff --git a/djangoblog/plugin_manage/hook_constants.py b/djangoblog/plugin_manage/hook_constants.py new file mode 100644 index 0000000..6685b7c --- /dev/null +++ b/djangoblog/plugin_manage/hook_constants.py @@ -0,0 +1,7 @@ +ARTICLE_DETAIL_LOAD = 'article_detail_load' +ARTICLE_CREATE = 'article_create' +ARTICLE_UPDATE = 'article_update' +ARTICLE_DELETE = 'article_delete' + +ARTICLE_CONTENT_HOOK_NAME = "the_content" + diff --git a/djangoblog/plugin_manage/hooks.py b/djangoblog/plugin_manage/hooks.py new file mode 100644 index 0000000..d712540 --- /dev/null +++ b/djangoblog/plugin_manage/hooks.py @@ -0,0 +1,44 @@ +import logging + +logger = logging.getLogger(__name__) + +_hooks = {} + + +def register(hook_name: str, callback: callable): + """ + 注册一个钩子回调。 + """ + if hook_name not in _hooks: + _hooks[hook_name] = [] + _hooks[hook_name].append(callback) + logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") + + +def run_action(hook_name: str, *args, **kwargs): + """ + 执行一个 Action Hook。 + 它会按顺序执行所有注册到该钩子上的回调函数。 + """ + if hook_name in _hooks: + logger.debug(f"Running action hook '{hook_name}'") + for callback in _hooks[hook_name]: + try: + callback(*args, **kwargs) + except Exception as e: + logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + + +def apply_filters(hook_name: str, value, *args, **kwargs): + """ + 执行一个 Filter Hook。 + 它会把 value 依次传递给所有注册的回调函数进行处理。 + """ + if hook_name in _hooks: + logger.debug(f"Applying filter hook '{hook_name}'") + for callback in _hooks[hook_name]: + try: + value = callback(value, *args, **kwargs) + except Exception as e: + logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + return value diff --git a/djangoblog/plugin_manage/loader.py b/djangoblog/plugin_manage/loader.py new file mode 100644 index 0000000..12e824b --- /dev/null +++ b/djangoblog/plugin_manage/loader.py @@ -0,0 +1,19 @@ +import os +import logging +from django.conf import settings + +logger = logging.getLogger(__name__) + +def load_plugins(): + """ + 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: + plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) + if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): + try: + __import__(f'plugins.{plugin_name}.plugin') + logger.info(f"Successfully loaded plugin: {plugin_name}") + except ImportError as e: + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file diff --git a/djangoblog/settings.py b/djangoblog/settings.py new file mode 100644 index 0000000..d076bb6 --- /dev/null +++ b/djangoblog/settings.py @@ -0,0 +1,343 @@ +""" +Django settings for djangoblog project. + +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/ +""" +import os +import sys +from pathlib import Path + +from django.utils.translation import gettext_lazy as _ + + +def env_to_bool(env, default): + str_val = os.environ.get(env) + 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 + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get( + '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 = [] +ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] +# django 4.0新增配置 +CSRF_TRUSTED_ORIGINS = ['http://example.com'] +# Application definition + + +INSTALLED_APPS = [ + # 'django.contrib.admin', + 'django.contrib.admin.apps.SimpleAdminConfig', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + 'django.contrib.sitemaps', + 'mdeditor', + 'haystack', + 'blog', + 'accounts', + 'comments', + 'oauth', + 'servermanager', + 'owntracks', + 'compressor', + 'djangoblog' +] + +MIDDLEWARE = [ + + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.gzip.GZipMiddleware', + # 'django.middleware.cache.UpdateCacheMiddleware', + 'django.middleware.common.CommonMiddleware', + # 'django.middleware.cache.FetchFromCacheMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.http.ConditionalGetMiddleware', + 'blog.middleware.OnlineMiddleware' +] + +ROOT_URLCONF = 'djangoblog.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'blog.context_processors.seo_processor' + ], + }, + }, +] + +WSGI_APPLICATION = 'djangoblog.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/1.10/ref/settings/#databases + + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog', + 'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root', + 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or 'root', + 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', + 'PORT': int( + os.environ.get('DJANGO_MYSQL_PORT') or 3306), + 'OPTIONS': { + 'charset': 'utf8mb4'}, + }} + +# Password validation +# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + '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.NumericPasswordValidator', + }, +] + +LANGUAGES = ( + ('en', _('English')), + ('zh-hans', _('Simplified Chinese')), + ('zh-hant', _('Traditional Chinese')), +) +LOCALE_PATHS = ( + os.path.join(BASE_DIR, 'locale'), +) + +LANGUAGE_CODE = 'zh-hans' + +TIME_ZONE = 'Asia/Shanghai' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = False + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.10/howto/static-files/ + + +HAYSTACK_CONNECTIONS = { + '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') + +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 = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'TIMEOUT': 10800, + 'LOCATION': 'unique-snowflake', + } +} +# 使用redis作为缓存 +if os.environ.get("DJANGO_REDIS_URL"): + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': f'redis://{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' +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' +EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) +EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') +EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') +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) +WXADMIN = os.environ.get( + 'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7' + +LOG_PATH = os.path.join(BASE_DIR, 'logs') +if not os.path.exists(LOG_PATH): + os.makedirs(LOG_PATH, exist_ok=True) + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'root': { + 'level': 'INFO', + 'handlers': ['console', 'log_file'], + }, + 'formatters': { + 'verbose': { + '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': { + 'log_file': { + 'level': 'INFO', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOG_PATH, 'djangoblog.log'), + 'when': 'D', + 'formatter': 'verbose', + 'interval': 1, + 'delay': True, + 'backupCount': 5, + 'encoding': 'utf-8' + }, + 'console': { + 'level': 'DEBUG', + 'filters': ['require_debug_true'], + 'class': 'logging.StreamHandler', + 'formatter': 'verbose' + }, + 'null': { + 'class': 'logging.NullHandler', + }, + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'djangoblog': { + 'handlers': ['log_file', 'console'], + 'level': 'INFO', + 'propagate': True, + }, + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': False, + } + } +} + +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' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): + ELASTICSEARCH_DSL = { + 'default': { + 'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') + }, + } + HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', + }, + } + +# Plugin System +PLUGINS_DIR = BASE_DIR / 'plugins' +ACTIVE_PLUGINS = [ + 'article_copyright', + 'reading_time', + 'external_links', + 'view_count', + 'seo_optimizer' +] \ No newline at end of file diff --git a/djangoblog/sitemap.py b/djangoblog/sitemap.py new file mode 100644 index 0000000..8b7d446 --- /dev/null +++ b/djangoblog/sitemap.py @@ -0,0 +1,59 @@ +from django.contrib.sitemaps import Sitemap +from django.urls import reverse + +from blog.models import Article, Category, Tag + + +class StaticViewSitemap(Sitemap): + priority = 0.5 + changefreq = 'daily' + + def items(self): + return ['blog:index', ] + + def location(self, item): + return reverse(item) + + +class ArticleSiteMap(Sitemap): + changefreq = "monthly" + priority = "0.6" + + def items(self): + return Article.objects.filter(status='p') + + def lastmod(self, obj): + return obj.last_modify_time + + +class CategorySiteMap(Sitemap): + changefreq = "Weekly" + priority = "0.6" + + def items(self): + return Category.objects.all() + + def lastmod(self, obj): + return obj.last_modify_time + + +class TagSiteMap(Sitemap): + changefreq = "Weekly" + priority = "0.3" + + def items(self): + return Tag.objects.all() + + def lastmod(self, obj): + return obj.last_modify_time + + +class UserSiteMap(Sitemap): + changefreq = "Weekly" + priority = "0.3" + + def items(self): + return list(set(map(lambda x: x.author, Article.objects.all()))) + + def lastmod(self, obj): + return obj.date_joined diff --git a/djangoblog/spider_notify.py b/djangoblog/spider_notify.py new file mode 100644 index 0000000..7b909e9 --- /dev/null +++ b/djangoblog/spider_notify.py @@ -0,0 +1,21 @@ +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class SpiderNotify(): + @staticmethod + def baidu_notify(urls): + try: + data = '\n'.join(urls) + result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) + logger.info(result.text) + except Exception as e: + logger.error(e) + + @staticmethod + def notify(url): + SpiderNotify.baidu_notify(url) diff --git a/djangoblog/tests.py b/djangoblog/tests.py new file mode 100644 index 0000000..01237d9 --- /dev/null +++ b/djangoblog/tests.py @@ -0,0 +1,32 @@ +from django.test import TestCase + +from djangoblog.utils import * + + +class DjangoBlogTest(TestCase): + def setUp(self): + pass + + def test_utils(self): + md5 = get_sha256('test') + self.assertIsNotNone(md5) + c = CommonMarkdown.get_markdown(''' + # Title1 + + ```python + import os + ``` + + [url](https://www.lylinux.net/) + + [ddd](http://www.baidu.com) + + + ''') + self.assertIsNotNone(c) + d = { + 'd': 'key1', + 'd2': 'key2' + } + data = parse_dict_to_url(d) + self.assertIsNotNone(data) diff --git a/djangoblog/urls.py b/djangoblog/urls.py new file mode 100644 index 0000000..4aae58a --- /dev/null +++ b/djangoblog/urls.py @@ -0,0 +1,64 @@ +"""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.urls.i18n import i18n_patterns +from django.conf.urls.static import static +from django.contrib.sitemaps.views import sitemap +from django.urls import path, include +from django.urls import re_path +from haystack.views import search_view_factory + +from blog.views import EsSearchView +from djangoblog.admin_site import admin_site +from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm +from djangoblog.feeds import DjangoBlogFeed +from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap + +sitemaps = { + + 'blog': ArticleSiteMap, + 'Category': CategorySiteMap, + 'Tag': TagSiteMap, + 'User': UserSiteMap, + 'static': StaticViewSitemap +} + +handler404 = 'blog.views.page_not_found_view' +handler500 = 'blog.views.server_error_view' +handle403 = 'blog.views.permission_denied_view' + +urlpatterns = [ + path('i18n/', include('django.conf.urls.i18n')), +] +urlpatterns += i18n_patterns( + re_path(r'^admin/', admin_site.urls), + re_path(r'', include('blog.urls', namespace='blog')), + re_path(r'mdeditor/', include('mdeditor.urls')), + re_path(r'', include('comments.urls', namespace='comment')), + re_path(r'', include('accounts.urls', namespace='account')), + re_path(r'', include('oauth.urls', namespace='oauth')), + re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, + name='django.contrib.sitemaps.views.sitemap'), + re_path(r'^feed/$', DjangoBlogFeed()), + re_path(r'^rss/$', DjangoBlogFeed()), + re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), + name='search'), + re_path(r'', include('servermanager.urls', namespace='servermanager')), + re_path(r'', include('owntracks.urls', namespace='owntracks')) + , prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT) diff --git a/djangoblog/utils.py b/djangoblog/utils.py new file mode 100644 index 0000000..57f63dc --- /dev/null +++ b/djangoblog/utils.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python +# encoding: utf-8 + + +import logging +import os +import random +import string +import uuid +from hashlib import sha256 + +import bleach +import markdown +import requests +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.cache import cache +from django.templatetags.static import static + +logger = logging.getLogger(__name__) + + +def get_max_articleid_commentid(): + from blog.models import Article + from comments.models import Comment + return (Article.objects.latest().pk, Comment.objects.latest().pk) + + +def get_sha256(str): + m = sha256(str.encode('utf-8')) + return m.hexdigest() + + +def cache_decorator(expiration=3 * 60): + def wrapper(func): + def news(*args, **kwargs): + try: + view = args[0] + key = view.get_cache_key() + except: + key = None + if not key: + unique_str = repr((func, args, kwargs)) + + m = sha256(unique_str.encode('utf-8')) + key = m.hexdigest() + value = cache.get(key) + if value is not None: + # logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key)) + if str(value) == '__default_cache_value__': + return None + else: + return value + else: + logger.debug( + 'cache_decorator set cache:%s key:%s' % + (func.__name__, key)) + value = func(*args, **kwargs) + if value is None: + cache.set(key, '__default_cache_value__', expiration) + else: + cache.set(key, value, expiration) + return value + + return news + + return wrapper + + +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.utils.cache import get_cache_key + + request = HttpRequest() + request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport} + request.path = path + + key = get_cache_key(request, key_prefix=key_prefix, cache=cache) + if key: + logger.info('expire_view_cache:get key:{path}'.format(path=path)) + if cache.get(key): + cache.delete(key) + return True + return False + + +@cache_decorator() +def get_current_site(): + site = Site.objects.get_current() + return site + + +class CommonMarkdown: + @staticmethod + def _convert_markdown(value): + md = markdown.Markdown( + extensions=[ + 'extra', + 'codehilite', + 'toc', + 'tables', + ] + ) + body = md.convert(value) + toc = md.toc + return body, toc + + @staticmethod + def get_markdown_with_toc(value): + body, toc = CommonMarkdown._convert_markdown(value) + return body, toc + + @staticmethod + def get_markdown(value): + body, toc = CommonMarkdown._convert_markdown(value) + 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: + """生成随机数验证码""" + return ''.join(random.sample(string.digits, 6)) + + +def parse_dict_to_url(dict): + from urllib.parse import quote + url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) + for k, v in dict.items()]) + return url + + +def get_blog_setting(): + value = cache.get('get_blog_setting') + if value: + return value + else: + from blog.models import BlogSettings + if not BlogSettings.objects.count(): + setting = BlogSettings() + setting.site_name = 'djangoblog' + setting.site_description = '基于Django的博客系统' + setting.site_seo_description = '基于Django的博客系统' + setting.site_keywords = 'Django,Python' + setting.article_sub_length = 300 + setting.sidebar_article_count = 10 + setting.sidebar_comment_count = 5 + setting.show_google_adsense = False + setting.open_site_comment = True + setting.analytics_code = '' + setting.beian_code = '' + setting.show_gongan_code = False + setting.comment_need_review = False + setting.save() + value = BlogSettings.objects.first() + logger.info('set cache get_blog_setting') + cache.set('get_blog_setting', value) + return value + + +def save_user_avatar(url): + ''' + 保存用户头像 + :param url:头像url + :return: 本地路径 + ''' + logger.info(url) + + try: + basedir = os.path.join(settings.STATICFILES, 'avatar') + rsp = requests.get(url, timeout=2) + if rsp.status_code == 200: + if not os.path.exists(basedir): + os.makedirs(basedir) + + image_extensions = ['.jpg', '.png', 'jpeg', '.gif'] + isimage = len([i for i in image_extensions if url.endswith(i)]) > 0 + ext = os.path.splitext(url)[1] if isimage else '.jpg' + save_filename = str(uuid.uuid4().hex) + ext + logger.info('保存用户头像:' + basedir + save_filename) + with open(os.path.join(basedir, save_filename), 'wb+') as file: + file.write(rsp.content) + return static('avatar/' + save_filename) + except Exception as e: + logger.error(e) + return static('blog/img/avatar.png') + + +def delete_sidebar_cache(): + from blog.models import LinkShowType + keys = ["sidebar" + x for x in LinkShowType.values] + for k in keys: + logger.info('delete sidebar key:' + k) + cache.delete(k) + + +def delete_view_cache(prefix, keys): + from django.core.cache.utils import make_template_fragment_key + key = make_template_fragment_key(prefix, keys) + cache.delete(key) + + +def get_resource_url(): + if settings.STATIC_URL: + return settings.STATIC_URL + else: + site = get_current_site() + return 'http://' + site.domain + '/static/' + + +ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', + 'h2', 'p'] +ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']} + + +def sanitize_html(html): + return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) diff --git a/djangoblog/whoosh_cn_backend.py b/djangoblog/whoosh_cn_backend.py new file mode 100644 index 0000000..04e3f7f --- /dev/null +++ b/djangoblog/whoosh_cn_backend.py @@ -0,0 +1,1044 @@ +# encoding: utf-8 + +from __future__ import absolute_import, division, print_function, unicode_literals + +import json +import os +import re +import shutil +import threading +import warnings + +import six +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from datetime import datetime +from django.utils.encoding import force_str +from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query +from haystack.constants import DJANGO_CT, DJANGO_ID, ID +from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument +from haystack.inputs import Clean, Exact, PythonData, Raw +from haystack.models import SearchResult +from haystack.utils import get_identifier, get_model_ct +from haystack.utils import log as logging +from haystack.utils.app_loading import haystack_get_model +from jieba.analyse import ChineseAnalyzer +from whoosh import index +from whoosh.analysis import StemmingAnalyzer +from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT +from whoosh.fields import ID as WHOOSH_ID +from whoosh.filedb.filestore import FileStorage, RamStorage +from whoosh.highlight import ContextFragmenter, HtmlFormatter +from whoosh.highlight import highlight as whoosh_highlight +from whoosh.qparser import QueryParser +from whoosh.searching import ResultsPage +from whoosh.writing import AsyncWriter + +try: + import whoosh +except ImportError: + raise MissingDependency( + "The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.") + +# Handle minimum requirement. +if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0): + raise MissingDependency( + "The 'whoosh' backend requires version 2.5.0 or greater.") + +# Bubble up the correct error. + +DATETIME_REGEX = re.compile( + '^(?P\d{4})-(?P\d{2})-(?P\d{2})T(?P\d{2}):(?P\d{2}):(?P\d{2})(\.\d{3,6}Z?)?$') +LOCALS = threading.local() +LOCALS.RAM_STORE = None + + +class WhooshHtmlFormatter(HtmlFormatter): + """ + This is a HtmlFormatter simpler than the whoosh.HtmlFormatter. + We use it to have consistent results across backends. Specifically, + Solr, Xapian and Elasticsearch are using this formatting. + """ + template = '<%(tag)s>%(t)s' + + +class WhooshSearchBackend(BaseSearchBackend): + # Word reserved by Whoosh for special use. + RESERVED_WORDS = ( + 'AND', + 'NOT', + 'OR', + 'TO', + ) + + # Characters reserved by Whoosh for special use. + # The '\\' must come first, so as not to overwrite the other slash + # replacements. + RESERVED_CHARACTERS = ( + '\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}', + '[', ']', '^', '"', '~', '*', '?', ':', '.', + ) + + def __init__(self, connection_alias, **connection_options): + super( + WhooshSearchBackend, + self).__init__( + connection_alias, + **connection_options) + self.setup_complete = False + self.use_file_storage = True + self.post_limit = getattr( + connection_options, + 'POST_LIMIT', + 128 * 1024 * 1024) + self.path = connection_options.get('PATH') + + if connection_options.get('STORAGE', 'file') != 'file': + self.use_file_storage = False + + if self.use_file_storage and not self.path: + raise ImproperlyConfigured( + "You must specify a 'PATH' in your settings for connection '%s'." % + connection_alias) + + self.log = logging.getLogger('haystack') + + def setup(self): + """ + Defers loading until needed. + """ + from haystack import connections + new_index = False + + # Make sure the index is there. + if self.use_file_storage and not os.path.exists(self.path): + os.makedirs(self.path) + new_index = True + + if self.use_file_storage and not os.access(self.path, os.W_OK): + raise IOError( + "The path to your Whoosh index '%s' is not writable for the current user/group." % + self.path) + + if self.use_file_storage: + self.storage = FileStorage(self.path) + else: + global LOCALS + + if getattr(LOCALS, 'RAM_STORE', None) is None: + LOCALS.RAM_STORE = RamStorage() + + self.storage = LOCALS.RAM_STORE + + self.content_field_name, self.schema = self.build_schema( + connections[self.connection_alias].get_unified_index().all_searchfields()) + self.parser = QueryParser(self.content_field_name, schema=self.schema) + + if new_index is True: + self.index = self.storage.create_index(self.schema) + else: + try: + self.index = self.storage.open_index(schema=self.schema) + except index.EmptyIndexError: + self.index = self.storage.create_index(self.schema) + + self.setup_complete = True + + def build_schema(self, fields): + schema_fields = { + ID: WHOOSH_ID(stored=True, unique=True), + DJANGO_CT: WHOOSH_ID(stored=True), + DJANGO_ID: WHOOSH_ID(stored=True), + } + # Grab the number of keys that are hard-coded into Haystack. + # We'll use this to (possibly) fail slightly more gracefully later. + initial_key_count = len(schema_fields) + content_field_name = '' + + for field_name, field_class in fields.items(): + if field_class.is_multivalued: + if field_class.indexed is False: + schema_fields[field_class.index_fieldname] = IDLIST( + stored=True, field_boost=field_class.boost) + else: + schema_fields[field_class.index_fieldname] = KEYWORD( + stored=True, commas=True, scorable=True, field_boost=field_class.boost) + elif field_class.field_type in ['date', 'datetime']: + schema_fields[field_class.index_fieldname] = DATETIME( + stored=field_class.stored, sortable=True) + elif field_class.field_type == 'integer': + schema_fields[field_class.index_fieldname] = NUMERIC( + stored=field_class.stored, numtype=int, field_boost=field_class.boost) + elif field_class.field_type == 'float': + schema_fields[field_class.index_fieldname] = NUMERIC( + stored=field_class.stored, numtype=float, field_boost=field_class.boost) + elif field_class.field_type == 'boolean': + # Field boost isn't supported on BOOLEAN as of 1.8.2. + schema_fields[field_class.index_fieldname] = BOOLEAN( + stored=field_class.stored) + elif field_class.field_type == 'ngram': + schema_fields[field_class.index_fieldname] = NGRAM( + minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost) + elif field_class.field_type == 'edge_ngram': + schema_fields[field_class.index_fieldname] = NGRAMWORDS(minsize=2, maxsize=15, at='start', + stored=field_class.stored, + field_boost=field_class.boost) + else: + # schema_fields[field_class.index_fieldname] = TEXT(stored=True, analyzer=StemmingAnalyzer(), field_boost=field_class.boost, sortable=True) + schema_fields[field_class.index_fieldname] = TEXT( + stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=True) + if field_class.document is True: + content_field_name = field_class.index_fieldname + schema_fields[field_class.index_fieldname].spelling = True + + # Fail more gracefully than relying on the backend to die if no fields + # are found. + if len(schema_fields) <= initial_key_count: + raise SearchBackendError( + "No fields were found in any search_indexes. Please correct this before attempting to search.") + + return (content_field_name, Schema(**schema_fields)) + + def update(self, index, iterable, commit=True): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + writer = AsyncWriter(self.index) + + for obj in iterable: + try: + doc = index.full_prepare(obj) + except SkipDocument: + self.log.debug(u"Indexing for object `%s` skipped", obj) + else: + # Really make sure it's unicode, because Whoosh won't have it any + # other way. + for key in doc: + doc[key] = self._from_python(doc[key]) + + # Document boosts aren't supported in Whoosh 2.5.0+. + if 'boost' in doc: + del doc['boost'] + + try: + writer.update_document(**doc) + except Exception as e: + if not self.silently_fail: + raise + + # We'll log the object identifier but won't include the actual object + # to avoid the possibility of that generating encoding errors while + # processing the log message: + self.log.error( + u"%s while preparing object for update" % + e.__class__.__name__, + exc_info=True, + extra={ + "data": { + "index": index, + "object": get_identifier(obj)}}) + + if len(iterable) > 0: + # For now, commit no matter what, as we run into locking issues + # otherwise. + writer.commit() + + def remove(self, obj_or_string, commit=True): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + whoosh_id = get_identifier(obj_or_string) + + try: + self.index.delete_by_query( + q=self.parser.parse( + u'%s:"%s"' % + (ID, whoosh_id))) + except Exception as e: + if not self.silently_fail: + raise + + self.log.error( + "Failed to remove document '%s' from Whoosh: %s", + whoosh_id, + e, + exc_info=True) + + def clear(self, models=None, commit=True): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + + if models is not None: + assert isinstance(models, (list, tuple)) + + try: + if models is None: + self.delete_index() + else: + models_to_delete = [] + + for model in models: + models_to_delete.append( + u"%s:%s" % + (DJANGO_CT, get_model_ct(model))) + + self.index.delete_by_query( + q=self.parser.parse( + u" OR ".join(models_to_delete))) + except Exception as e: + if not self.silently_fail: + raise + + if models is not None: + self.log.error( + "Failed to clear Whoosh index of models '%s': %s", + ','.join(models_to_delete), + e, + exc_info=True) + else: + self.log.error( + "Failed to clear Whoosh index: %s", e, exc_info=True) + + def delete_index(self): + # Per the Whoosh mailing list, if wiping out everything from the index, + # it's much more efficient to simply delete the index files. + if self.use_file_storage and os.path.exists(self.path): + shutil.rmtree(self.path) + elif not self.use_file_storage: + self.storage.clean() + + # Recreate everything. + self.setup() + + def optimize(self): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + self.index.optimize() + + def calculate_page(self, start_offset=0, end_offset=None): + # Prevent against Whoosh throwing an error. Requires an end_offset + # greater than 0. + if end_offset is not None and end_offset <= 0: + end_offset = 1 + + # Determine the page. + page_num = 0 + + if end_offset is None: + end_offset = 1000000 + + if start_offset is None: + start_offset = 0 + + page_length = end_offset - start_offset + + if page_length and page_length > 0: + page_num = int(start_offset / page_length) + + # Increment because Whoosh uses 1-based page numbers. + page_num += 1 + return page_num, page_length + + @log_query + def search( + self, + query_string, + sort_by=None, + start_offset=0, + end_offset=None, + fields='', + highlight=False, + facets=None, + date_facets=None, + query_facets=None, + narrow_queries=None, + spelling_query=None, + within=None, + dwithin=None, + distance_point=None, + models=None, + limit_to_registered_models=None, + result_class=None, + **kwargs): + if not self.setup_complete: + self.setup() + + # A zero length query should return no results. + if len(query_string) == 0: + return { + 'results': [], + 'hits': 0, + } + + query_string = force_str(query_string) + + # A one-character query (non-wildcard) gets nabbed by a stopwords + # filter and should yield zero results. + if len(query_string) <= 1 and query_string != u'*': + return { + 'results': [], + 'hits': 0, + } + + reverse = False + + if sort_by is not None: + # Determine if we need to reverse the results and if Whoosh can + # handle what it's being asked to sort by. Reversing is an + # all-or-nothing action, unfortunately. + sort_by_list = [] + reverse_counter = 0 + + for order_by in sort_by: + if order_by.startswith('-'): + reverse_counter += 1 + + if reverse_counter and reverse_counter != len(sort_by): + raise SearchBackendError("Whoosh requires all order_by fields" + " to use the same sort direction") + + for order_by in sort_by: + if order_by.startswith('-'): + sort_by_list.append(order_by[1:]) + + if len(sort_by_list) == 1: + reverse = True + else: + sort_by_list.append(order_by) + + if len(sort_by_list) == 1: + reverse = False + + sort_by = sort_by_list[0] + + if facets is not None: + warnings.warn( + "Whoosh does not handle faceting.", + Warning, + stacklevel=2) + + if date_facets is not None: + warnings.warn( + "Whoosh does not handle date faceting.", + Warning, + stacklevel=2) + + if query_facets is not None: + warnings.warn( + "Whoosh does not handle query faceting.", + Warning, + stacklevel=2) + + narrowed_results = None + self.index = self.index.refresh() + + if limit_to_registered_models is None: + limit_to_registered_models = getattr( + settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + if models and len(models): + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + # Using narrow queries, limit the results to only models handled + # with the current routers. + model_choices = self.build_models_list() + else: + model_choices = [] + + if len(model_choices) > 0: + if narrow_queries is None: + narrow_queries = set() + + narrow_queries.add(' OR '.join( + ['%s:%s' % (DJANGO_CT, rm) for rm in model_choices])) + + narrow_searcher = None + + if narrow_queries is not None: + # Potentially expensive? I don't see another way to do it in + # Whoosh... + narrow_searcher = self.index.searcher() + + for nq in narrow_queries: + recent_narrowed_results = narrow_searcher.search( + self.parser.parse(force_str(nq)), limit=None) + + if len(recent_narrowed_results) <= 0: + return { + 'results': [], + 'hits': 0, + } + + if narrowed_results: + narrowed_results.filter(recent_narrowed_results) + else: + narrowed_results = recent_narrowed_results + + self.index = self.index.refresh() + + if self.index.doc_count(): + searcher = self.index.searcher() + parsed_query = self.parser.parse(query_string) + + # In the event of an invalid/stopworded query, recover gracefully. + if parsed_query is None: + return { + 'results': [], + 'hits': 0, + } + + page_num, page_length = self.calculate_page( + start_offset, end_offset) + + search_kwargs = { + 'pagelen': page_length, + 'sortedby': sort_by, + 'reverse': reverse, + } + + # Handle the case where the results have been narrowed. + if narrowed_results is not None: + search_kwargs['filter'] = narrowed_results + + try: + raw_page = searcher.search_page( + parsed_query, + page_num, + **search_kwargs + ) + except ValueError: + if not self.silently_fail: + raise + + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + # Because as of Whoosh 2.5.1, it will return the wrong page of + # results if you request something too high. :( + if raw_page.pagenum < page_num: + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + results = self._process_results( + raw_page, + highlight=highlight, + query_string=query_string, + spelling_query=spelling_query, + result_class=result_class) + searcher.close() + + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + else: + if self.include_spelling: + if spelling_query: + spelling_suggestion = self.create_spelling_suggestion( + spelling_query) + else: + spelling_suggestion = self.create_spelling_suggestion( + query_string) + else: + spelling_suggestion = None + + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': spelling_suggestion, + } + + def more_like_this( + self, + model_instance, + additional_query_string=None, + start_offset=0, + end_offset=None, + models=None, + limit_to_registered_models=None, + result_class=None, + **kwargs): + if not self.setup_complete: + self.setup() + + # Deferred models will have a different class ("RealClass_Deferred_fieldname") + # which won't be in our registry: + model_klass = model_instance._meta.concrete_model + + field_name = self.content_field_name + narrow_queries = set() + narrowed_results = None + self.index = self.index.refresh() + + if limit_to_registered_models is None: + limit_to_registered_models = getattr( + settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + if models and len(models): + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + # Using narrow queries, limit the results to only models handled + # with the current routers. + model_choices = self.build_models_list() + else: + model_choices = [] + + if len(model_choices) > 0: + if narrow_queries is None: + narrow_queries = set() + + narrow_queries.add(' OR '.join( + ['%s:%s' % (DJANGO_CT, rm) for rm in model_choices])) + + if additional_query_string and additional_query_string != '*': + narrow_queries.add(additional_query_string) + + narrow_searcher = None + + if narrow_queries is not None: + # Potentially expensive? I don't see another way to do it in + # Whoosh... + narrow_searcher = self.index.searcher() + + for nq in narrow_queries: + recent_narrowed_results = narrow_searcher.search( + self.parser.parse(force_str(nq)), limit=None) + + if len(recent_narrowed_results) <= 0: + return { + 'results': [], + 'hits': 0, + } + + if narrowed_results: + narrowed_results.filter(recent_narrowed_results) + else: + narrowed_results = recent_narrowed_results + + page_num, page_length = self.calculate_page(start_offset, end_offset) + + self.index = self.index.refresh() + raw_results = EmptyResults() + + if self.index.doc_count(): + query = "%s:%s" % (ID, get_identifier(model_instance)) + searcher = self.index.searcher() + parsed_query = self.parser.parse(query) + results = searcher.search(parsed_query) + + if len(results): + raw_results = results[0].more_like_this( + field_name, top=end_offset) + + # Handle the case where the results have been narrowed. + if narrowed_results is not None and hasattr(raw_results, 'filter'): + raw_results.filter(narrowed_results) + + try: + raw_page = ResultsPage(raw_results, page_num, page_length) + except ValueError: + if not self.silently_fail: + raise + + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + # Because as of Whoosh 2.5.1, it will return the wrong page of + # results if you request something too high. :( + if raw_page.pagenum < page_num: + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + results = self._process_results(raw_page, result_class=result_class) + searcher.close() + + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + + def _process_results( + self, + raw_page, + highlight=False, + query_string='', + spelling_query=None, + result_class=None): + from haystack import connections + results = [] + + # It's important to grab the hits first before slicing. Otherwise, this + # can cause pagination failures. + hits = len(raw_page) + + if result_class is None: + result_class = SearchResult + + facets = {} + spelling_suggestion = None + unified_index = connections[self.connection_alias].get_unified_index() + indexed_models = unified_index.get_indexed_models() + + for doc_offset, raw_result in enumerate(raw_page): + score = raw_page.score(doc_offset) or 0 + app_label, model_name = raw_result[DJANGO_CT].split('.') + additional_fields = {} + model = haystack_get_model(app_label, model_name) + + if model and model in indexed_models: + for key, value in raw_result.items(): + index = unified_index.get_index(model) + string_key = str(key) + + if string_key in index.fields and hasattr( + index.fields[string_key], 'convert'): + # Special-cased due to the nature of KEYWORD fields. + if index.fields[string_key].is_multivalued: + if value is None or len(value) == 0: + additional_fields[string_key] = [] + else: + additional_fields[string_key] = value.split( + ',') + else: + additional_fields[string_key] = index.fields[string_key].convert( + value) + else: + additional_fields[string_key] = self._to_python(value) + + del (additional_fields[DJANGO_CT]) + del (additional_fields[DJANGO_ID]) + + if highlight: + sa = StemmingAnalyzer() + formatter = WhooshHtmlFormatter('em') + terms = [token.text for token in sa(query_string)] + + whoosh_result = whoosh_highlight( + additional_fields.get(self.content_field_name), + terms, + sa, + ContextFragmenter(), + formatter + ) + additional_fields['highlighted'] = { + self.content_field_name: [whoosh_result], + } + + result = result_class( + app_label, + model_name, + raw_result[DJANGO_ID], + score, + **additional_fields) + results.append(result) + else: + hits -= 1 + + if self.include_spelling: + if spelling_query: + spelling_suggestion = self.create_spelling_suggestion( + spelling_query) + else: + spelling_suggestion = self.create_spelling_suggestion( + query_string) + + return { + 'results': results, + 'hits': hits, + 'facets': facets, + 'spelling_suggestion': spelling_suggestion, + } + + def create_spelling_suggestion(self, query_string): + spelling_suggestion = None + reader = self.index.reader() + corrector = reader.corrector(self.content_field_name) + cleaned_query = force_str(query_string) + + if not query_string: + return spelling_suggestion + + # Clean the string. + for rev_word in self.RESERVED_WORDS: + cleaned_query = cleaned_query.replace(rev_word, '') + + for rev_char in self.RESERVED_CHARACTERS: + cleaned_query = cleaned_query.replace(rev_char, '') + + # Break it down. + query_words = cleaned_query.split() + suggested_words = [] + + for word in query_words: + suggestions = corrector.suggest(word, limit=1) + + if len(suggestions) > 0: + suggested_words.append(suggestions[0]) + + spelling_suggestion = ' '.join(suggested_words) + return spelling_suggestion + + def _from_python(self, value): + """ + Converts Python values to a string for Whoosh. + + Code courtesy of pysolr. + """ + if hasattr(value, 'strftime'): + if not hasattr(value, 'hour'): + value = datetime(value.year, value.month, value.day, 0, 0, 0) + elif isinstance(value, bool): + if value: + value = 'true' + else: + value = 'false' + elif isinstance(value, (list, tuple)): + value = u','.join([force_str(v) for v in value]) + elif isinstance(value, (six.integer_types, float)): + # Leave it alone. + pass + else: + value = force_str(value) + return value + + def _to_python(self, value): + """ + Converts values from Whoosh to native Python values. + + A port of the same method in pysolr, as they deal with data the same way. + """ + if value == 'true': + return True + elif value == 'false': + return False + + if value and isinstance(value, six.string_types): + possible_datetime = DATETIME_REGEX.search(value) + + if possible_datetime: + date_values = possible_datetime.groupdict() + + for dk, dv in date_values.items(): + date_values[dk] = int(dv) + + return datetime( + date_values['year'], + date_values['month'], + date_values['day'], + date_values['hour'], + date_values['minute'], + date_values['second']) + + try: + # Attempt to use json to load the values. + converted_value = json.loads(value) + + # Try to handle most built-in types. + if isinstance( + converted_value, + (list, + tuple, + set, + dict, + six.integer_types, + float, + complex)): + return converted_value + except BaseException: + # If it fails (SyntaxError or its ilk) or we don't trust it, + # continue on. + pass + + return value + + +class WhooshSearchQuery(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): + from haystack import connections + query_frag = '' + is_datetime = False + + if not hasattr(value, 'input_type_name'): + # Handle when we've got a ``ValuesListQuerySet``... + if hasattr(value, 'values_list'): + value = list(value) + + if hasattr(value, 'strftime'): + is_datetime = True + + if isinstance(value, six.string_types) and value != ' ': + # It's not an ``InputType``. Assume ``Clean``. + value = Clean(value) + else: + value = PythonData(value) + + # Prepare the query using the InputType. + prepared_value = value.prepare(self) + + if not isinstance(prepared_value, (set, list, tuple)): + # Then convert whatever we get back to what pysolr wants if needed. + prepared_value = self.backend._from_python(prepared_value) + + # 'content' is a special reserved word, much like 'pk' in + # Django's ORM layer. It indicates 'no special field'. + if field == 'content': + index_fieldname = '' + else: + index_fieldname = u'%s:' % connections[self._using].get_unified_index( + ).get_index_fieldname(field) + + filter_types = { + 'content': '%s', + 'contains': '*%s*', + 'endswith': "*%s", + 'startswith': "%s*", + 'exact': '%s', + 'gt': "{%s to}", + 'gte': "[%s to]", + 'lt': "{to %s}", + 'lte': "[to %s]", + 'fuzzy': u'%s~', + } + + if value.post_process is False: + query_frag = prepared_value + else: + if filter_type in [ + 'content', + 'contains', + 'startswith', + 'endswith', + 'fuzzy']: + if value.input_type_name == 'exact': + query_frag = prepared_value + else: + # Iterate over terms & incorportate the converted form of + # each into the query. + terms = [] + + if isinstance(prepared_value, six.string_types): + possible_values = prepared_value.split(' ') + else: + if is_datetime is True: + prepared_value = self._convert_datetime( + prepared_value) + + possible_values = [prepared_value] + + for possible_value in possible_values: + terms.append( + filter_types[filter_type] % + self.backend._from_python(possible_value)) + + if len(terms) == 1: + query_frag = terms[0] + else: + query_frag = u"(%s)" % " AND ".join(terms) + elif filter_type == 'in': + in_options = [] + + for possible_value in prepared_value: + is_datetime = False + + if hasattr(possible_value, 'strftime'): + is_datetime = True + + pv = self.backend._from_python(possible_value) + + if is_datetime is True: + pv = self._convert_datetime(pv) + + if isinstance(pv, six.string_types) and not is_datetime: + in_options.append('"%s"' % pv) + else: + in_options.append('%s' % pv) + + query_frag = "(%s)" % " OR ".join(in_options) + elif filter_type == 'range': + start = self.backend._from_python(prepared_value[0]) + end = self.backend._from_python(prepared_value[1]) + + if hasattr(prepared_value[0], 'strftime'): + start = self._convert_datetime(start) + + if hasattr(prepared_value[1], 'strftime'): + end = self._convert_datetime(end) + + query_frag = u"[%s to %s]" % (start, end) + elif filter_type == 'exact': + if value.input_type_name == 'exact': + query_frag = prepared_value + else: + prepared_value = Exact(prepared_value).prepare(self) + query_frag = filter_types[filter_type] % prepared_value + else: + if is_datetime is True: + prepared_value = self._convert_datetime(prepared_value) + + query_frag = filter_types[filter_type] % prepared_value + + if len(query_frag) and not isinstance(value, Raw): + if not query_frag.startswith('(') and not query_frag.endswith(')'): + query_frag = "(%s)" % query_frag + + return u"%s%s" % (index_fieldname, query_frag) + + # if not filter_type in ('in', 'range'): + # # 'in' is a bit of a special case, as we don't want to + # # convert a valid list/tuple to string. Defer handling it + # # until later... + # value = self.backend._from_python(value) + + +class WhooshEngine(BaseEngine): + backend = WhooshSearchBackend + query = WhooshSearchQuery diff --git a/djangoblog/wsgi.py b/djangoblog/wsgi.py new file mode 100644 index 0000000..2295efd --- /dev/null +++ b/djangoblog/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for djangoblog project. + +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 + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") + +application = get_wsgi_application() diff --git a/docs/README-en.md b/docs/README-en.md new file mode 100644 index 0000000..37ea069 --- /dev/null +++ b/docs/README-en.md @@ -0,0 +1,158 @@ +# DjangoBlog + +

+ Django CI + CodeQL + codecov + license +

+ +

+ A powerful, elegant, and modern blog system. +
+ English简体中文 +

+ +--- + +DjangoBlog is a high-performance blog platform built with Python 3.10 and Django 4.0. It not only provides all the core functionalities of a traditional blog but also features a flexible plugin system, allowing you to easily extend and customize your website. Whether you are a personal blogger, a tech enthusiast, or a content creator, DjangoBlog aims to provide a stable, efficient, and easy-to-maintain environment for writing and publishing. + +## ✨ Features + +- **Powerful Content Management**: Full support for managing articles, standalone pages, categories, and tags. Comes with a powerful built-in Markdown editor with syntax highlighting. +- **Full-Text Search**: Integrated search engine for fast and accurate content searching. +- **Interactive Comment System**: Supports replies, email notifications, and Markdown formatting in comments. +- **Flexible Sidebar**: Customizable modules for displaying recent articles, most viewed posts, tag cloud, and more. +- **Social Login**: Built-in OAuth support, with integrations for Google, GitHub, Facebook, Weibo, QQ, and other major platforms. +- **High-Performance Caching**: Native support for Redis caching with an automatic refresh mechanism to ensure high-speed website responses. +- **SEO Friendly**: Basic SEO features are included, with automatic notifications to Google and Baidu upon new content publication. +- **Extensible Plugin System**: Extend blog functionalities by creating standalone plugins, ensuring decoupled and maintainable code. We have already implemented features like view counting and SEO optimization through plugins! +- **Integrated Image Hosting**: A simple, built-in image hosting feature for easy uploads and management. +- **Automated Frontend**: Integrated with `django-compressor` to automatically compress and optimize CSS and JavaScript files. +- **Robust Operations**: Built-in email notifications for website exceptions and management capabilities through a WeChat Official Account. + +## 🛠️ Tech Stack + +- **Backend**: Python 3.10, Django 4.0 +- **Database**: MySQL, SQLite (configurable) +- **Cache**: Redis +- **Frontend**: HTML5, CSS3, JavaScript +- **Search**: Whoosh, Elasticsearch (configurable) +- **Editor**: Markdown (mdeditor) + +## 🚀 Getting Started + +### 1. Prerequisites + +Ensure you have Python 3.10+ and MySQL/MariaDB installed on your system. + +### 2. Clone & Installation + +```bash +# Clone the project to your local machine +git clone https://github.com/liangliangyy/DjangoBlog.git +cd DjangoBlog + +# Install dependencies +pip install -r requirements.txt +``` + +### 3. Project Configuration + +- **Database**: + Open `djangoblog/settings.py`, locate the `DATABASES` section, and update it with your MySQL connection details. + + ```python + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'djangoblog', + 'USER': 'root', + 'PASSWORD': 'your_password', + 'HOST': '127.0.0.1', + 'PORT': 3306, + } + } + ``` + Create the database in MySQL: + ```sql + CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + ``` + +- **More Configurations**: + For advanced settings such as email, OAuth, caching, and more, please refer to our [Detailed Configuration Guide](/docs/config-en.md). + +### 4. Database Initialization + +```bash +python manage.py makemigrations +python manage.py migrate + +# Create a superuser account +python manage.py createsuperuser +``` + +### 5. Running the Project + +```bash +# (Optional) Generate some test data +python manage.py create_testdata + +# (Optional) Collect and compress static files +python manage.py collectstatic --noinput +python manage.py compress --force + +# Start the development server +python manage.py runserver +``` + +Now, open your browser and navigate to `http://127.0.0.1:8000/`. You should see the DjangoBlog homepage! + +## Deployment + +- **Traditional Deployment**: A detailed guide for server deployment is available here: [Deployment Tutorial](https://www.lylinux.net/article/2019/8/5/58.html) (in Chinese). +- **Docker Deployment**: This project fully supports Docker. If you are familiar with containerization, please refer to the [Docker Deployment Guide](/docs/docker-en.md) for a quick start. +- **Kubernetes Deployment**: We also provide a complete [Kubernetes Deployment Guide](/docs/k8s-en.md) to help you go cloud-native easily. + +## 🧩 Plugin System + +The plugin system is a core feature of DjangoBlog. It allows you to add new functionalities to your blog without modifying the core codebase by writing standalone plugins. + +- **How it Works**: Plugins operate by registering callback functions to predefined "hooks". For instance, when an article is rendered, the `after_article_body_get` hook is triggered, and all functions registered to this hook are executed. +- **Existing Plugins**: Features like `view_count` and `seo_optimizer` are implemented through this plugin system. +- **Develop Your Own Plugin**: Simply create a new folder under the `plugins` directory and write your `plugin.py`. We welcome you to explore and contribute your creative ideas to the DjangoBlog community! + +## 🤝 Contributing + +We warmly welcome contributions of any kind! If you have great ideas or have found a bug, please feel free to open an issue or submit a pull request. + +## 📄 License + +This project is open-sourced under the [MIT License](LICENSE). + +--- + +## ❤️ Support & Sponsorship + +If you find this project helpful and wish to support its continued maintenance and development, please consider buying me a coffee! Your support is my greatest motivation. + +

+ Alipay Sponsorship + WeChat Sponsorship +

+

+ (Left) Alipay / (Right) WeChat +

+ +## 🙏 Acknowledgements + +A special thanks to **JetBrains** for providing a free open-source license for this project. + +

+ + JetBrains Logo + +

+ +--- +> If this project has helped you, please leave your website URL [here](https://github.com/liangliangyy/DjangoBlog/issues/214) to let more people see it. Your feedback is the driving force for my continued updates and maintenance. diff --git a/docs/config-en.md b/docs/config-en.md new file mode 100644 index 0000000..b877efb --- /dev/null +++ b/docs/config-en.md @@ -0,0 +1,64 @@ +# Introduction to main features settings + +## Cache: +Cache using `memcache` for default. If you don't have `memcache` environment, you can remove the `default` setting in `CACHES` and change `locmemcache` to `default`. +```python +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'LOCATION': '127.0.0.1:11211', + 'KEY_PREFIX': 'django_test' if TESTING else 'djangoblog', + 'TIMEOUT': 60 * 60 * 10 + }, + 'locmemcache': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'TIMEOUT': 10800, + 'LOCATION': 'unique-snowflake', + } +} +``` + +## OAuth Login: +QQ, Weibo, Google, GitHub and Facebook are now supported for OAuth login. Fetch OAuth login permissions from the corresponding open platform, and save them with `appkey`, `appsecret` and callback address in **Backend->OAuth** configuration. + +### Callback address examples: +QQ: http://your-domain-name/oauth/authorize?type=qq +Weibo: http://your-domain-name/oauth/authorize?type=weibo +type is in the type field of `oauthmanager`. + +## owntracks: +owntracks is a location tracking application. It will send your locaiton to the server by timing.Simple support owntracks features. Just install owntracks app and set api address as `your-domain-name/owntracks/logtracks`. Visit `your-domain-name/owntracks/show_dates` and you will see the date with latitude and langitude, click it and see the motion track. The map is drawn by AMap. + +## Email feature: +Same as before, Configure your own error msg recvie email information with`ADMINS = [('liangliang', 'liangliangyy@gmail.com')]` in `settings.py`. And modify: +```python +EMAIL_HOST = 'smtp.zoho.com' +EMAIL_PORT = 587 +EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') +EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER') +``` +with your email account information. + +## WeChat Official Account +Simple wechat official account features integrated. Set token as `your-domain-name/robot` in wechat backend. Default token is `lylinux`, you can change it to your own in `servermanager/robot.py`. Add a new command in `Backend->Servermanager->command`, in this way, you can manage the system through wechat official account. + +## Introduction to website configuration +You can add website configuration in **Backend->BLOG->WebSiteConfiguration**. Such as: keywords, description, Google Ad, website stats code, case number, etc. +OAuth user avatar path is saved in *StaticFileSavedAddress*. Please input absolute path, code directory for default. + +## Source code highlighting +If the code block in your article didn't show hightlight, please write the code blocks as following: + +![](https://resource.lylinux.net/image/codelang.png) + +That is, you should add the corresponding language name before the code block. + +## Update +If you get errors as following while executing database migrations: +```python +django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1")) +``` +This problem may cause by the mysql version under 5.6, a new version( >= 5.6 ) mysql is needed. + diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..24673a3 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,58 @@ +# 主要功能配置介绍: + +## 缓存: +缓存默认使用`localmem`缓存,如果你有`redis`环境,可以设置`DJANGO_REDIS_URL`环境变量,则会自动使用该redis来作为缓存,或者你也可以直接修改如下代码来使用。 +https://github.com/liangliangyy/DjangoBlog/blob/ffcb2c3711de805f2067dd3c1c57449cd24d84ee/djangoblog/settings.py#L185-L199 + + +## oauth登录: + +现在已经支持QQ,微博,Google,GitHub,Facebook登录,需要在其对应的开放平台申请oauth登录权限,然后在 +**后台->Oauth** 配置中新增配置,填写对应的`appkey`和`appsecret`以及回调地址。 +### 回调地址示例: +qq:http://你的域名/oauth/authorize?type=qq +微博:http://你的域名/oauth/authorize?type=weibo +type对应在`oauthmanager`中的type字段。 + +## owntracks: +owntracks是一个位置追踪软件,可以定时的将你的坐标提交到你的服务器上,现在简单的支持owntracks功能,需要安装owntracks的app,然后将api地址设置为: +`你的域名/owntracks/logtracks`就可以了。然后访问`你的域名/owntracks/show_dates`就可以看到有经纬度记录的日期,点击之后就可以看到运动轨迹了。地图是使用高德地图绘制。 + +## 邮件功能: +同样,将`settings.py`中的`ADMINS = [('liangliang', 'liangliangyy@gmail.com')]`配置为你自己的错误接收邮箱,另外修改: +```python +EMAIL_HOST = 'smtp.zoho.com' +EMAIL_PORT = 587 +EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') +EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER') +``` +为你自己的邮箱配置。 + +## 微信公众号 +集成了简单的微信公众号功能,在微信后台将token地址设置为:`你的域名/robot` 即可,默认token为`lylinux`,当然你可以修改为你自己的,在`servermanager/robot.py`中。 +然后在**后台->Servermanager->命令**中新增命令,这样就可以使用微信公众号来管理了。 +## 网站配置介绍 +在**后台->BLOG->网站配置**中,可以新增网站配置,比如关键字,描述等,以及谷歌广告,网站统计代码及备案号等等。 +其中的*静态文件保存地址*是保存oauth用户登录的头像路径,填写绝对路径,默认是代码目录。 +## 代码高亮 +如果你发现你文章的代码没有高亮,请这样书写代码块: + +![](https://resource.lylinux.net/image/codelang.png) + + +也就是说,需要在代码块开始位置加入这段代码对应的语言。 + +## update +如果你发现执行数据库迁移的时候出现如下报错: +```python +django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1")) +``` +可能是因为你的mysql版本低于5.6,需要升级mysql版本>=5.6即可。 + + +django 4.0登录可能会报错CSRF,需要配置下`settings.py`中的`CSRF_TRUSTED_ORIGINS` + +https://github.com/liangliangyy/DjangoBlog/blob/master/djangoblog/settings.py#L39 + diff --git a/docs/docker-en.md b/docs/docker-en.md new file mode 100644 index 0000000..8d5d59e --- /dev/null +++ b/docs/docker-en.md @@ -0,0 +1,114 @@ +# Deploying DjangoBlog with Docker + +![Docker Pulls](https://img.shields.io/docker/pulls/liangliangyy/djangoblog) +![Docker Image Version (latest by date)](https://img.shields.io/docker/v/liangliangyy/djangoblog?sort=date) +![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/liangliangyy/djangoblog) + +This project fully supports containerized deployment using Docker, providing you with a fast, consistent, and isolated runtime environment. We recommend using `docker-compose` to launch the entire blog service stack with a single command. + +## 1. Prerequisites + +Before you begin, please ensure you have the following software installed on your system: +- [Docker Engine](https://docs.docker.com/engine/install/) +- [Docker Compose](https://docs.docker.com/compose/install/) (Included with Docker Desktop for Mac and Windows) + +## 2. Recommended Method: Using `docker-compose` (One-Click Deployment) + +This is the simplest and most recommended way to deploy. It automatically creates and manages the Django application, a MySQL database, and an optional Elasticsearch service for you. + +### Step 1: Start the Basic Services + +From the project's root directory, run the following command: + +```bash +# Build and start the containers in detached mode (includes Django app and MySQL) +docker-compose up -d --build +``` + +`docker-compose` will read the `docker-compose.yml` file, pull the necessary images, build the project image, and start all services. + +- **Access Your Blog**: Once the services are up, you can access the blog by navigating to `http://127.0.0.1` in your browser. +- **Data Persistence**: MySQL data files will be stored in the `data/mysql` directory within the project root, ensuring that your data persists across container restarts. + +### Step 2: (Optional) Enable Elasticsearch for Full-Text Search + +If you want to use Elasticsearch for more powerful full-text search capabilities, you can include the `docker-compose.es.yml` configuration file: + +```bash +# Build and start all services in detached mode (Django, MySQL, Elasticsearch) +docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build +``` +- **Data Persistence**: Elasticsearch data will be stored in the `data/elasticsearch` directory. + +### Step 3: First-Time Initialization + +After the containers start for the first time, you'll need to execute some initialization commands inside the application container. + +```bash +# Get a shell inside the djangoblog application container (named 'web') +docker-compose exec web bash + +# Inside the container, run the following commands: +# Create a superuser account (follow the prompts to set username, email, and password) +python manage.py createsuperuser + +# (Optional) Create some test data +python manage.py create_testdata + +# (Optional, if ES is enabled) Create the search index +python manage.py rebuild_index + +# Exit the container +exit +``` + +## 3. Alternative Method: Using the Standalone Docker Image + +If you already have an external MySQL database running, you can run the DjangoBlog application image by itself. + +```bash +# Pull the latest image from Docker Hub +docker pull liangliangyy/djangoblog:latest + +# Run the container and connect it to your external database +docker run -d \ + -p 8000:8000 \ + -e DJANGO_SECRET_KEY='your-strong-secret-key' \ + -e DJANGO_MYSQL_HOST='your-mysql-host' \ + -e DJANGO_MYSQL_USER='your-mysql-user' \ + -e DJANGO_MYSQL_PASSWORD='your-mysql-password' \ + -e DJANGO_MYSQL_DATABASE='djangoblog' \ + --name djangoblog \ + liangliangyy/djangoblog:latest +``` + +- **Access Your Blog**: After startup, visit `http://127.0.0.1:8000`. +- **Create Superuser**: `docker exec -it djangoblog python manage.py createsuperuser` + +## 4. Configuration (Environment Variables) + +Most of the project's configuration is managed through environment variables. You can modify them in the `docker-compose.yml` file or pass them using the `-e` flag with the `docker run` command. + +| Environment Variable | Default/Example Value | Notes | +|---------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------| +| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **Must be changed to a random, complex string!** | +| `DJANGO_DEBUG` | `False` | Toggles Django's debug mode. | +| `DJANGO_MYSQL_HOST` | `mysql` | Database hostname. | +| `DJANGO_MYSQL_PORT` | `3306` | Database port. | +| `DJANGO_MYSQL_DATABASE` | `djangoblog` | Database name. | +| `DJANGO_MYSQL_USER` | `root` | Database username. | +| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | Database password. | +| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis connection URL (for caching). | +| `DJANGO_ELASTICSEARCH_HOST`| `elasticsearch:9200` | Elasticsearch host address. | +| `DJANGO_EMAIL_HOST` | `smtp.example.org` | Email server address. | +| `DJANGO_EMAIL_PORT` | `465` | Email server port. | +| `DJANGO_EMAIL_USER` | `user@example.org` | Email account username. | +| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | Email account password. | +| `DJANGO_EMAIL_USE_SSL` | `True` | Whether to use SSL. | +| `DJANGO_EMAIL_USE_TLS` | `False` | Whether to use TLS. | +| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | Admin email for receiving error reports. | +| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | Push API from [Baidu Webmaster Tools](https://ziyuan.baidu.com/linksubmit/index). | + +--- + +After deployment, please review and adjust these environment variables according to your needs, especially `DJANGO_SECRET_KEY` and the database and email settings. \ No newline at end of file diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..e7c255a --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,114 @@ +# 使用 Docker 部署 DjangoBlog + +![Docker Pulls](https://img.shields.io/docker/pulls/liangliangyy/djangoblog) +![Docker Image Version (latest by date)](https://img.shields.io/docker/v/liangliangyy/djangoblog?sort=date) +![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/liangliangyy/djangoblog) + +本项目全面支持使用 Docker 进行容器化部署,为您提供了快速、一致且隔离的运行环境。我们推荐使用 `docker-compose` 来一键启动整个博客服务栈。 + +## 1. 环境准备 + +在开始之前,请确保您的系统中已经安装了以下软件: +- [Docker Engine](https://docs.docker.com/engine/install/) +- [Docker Compose](https://docs.docker.com/compose/install/) (对于 Docker Desktop 用户,它已内置) + +## 2. 推荐方式:使用 `docker-compose` (一键部署) + +这是最简单、最推荐的部署方式。它会自动为您创建并管理 Django 应用、MySQL 数据库,以及可选的 Elasticsearch 服务。 + +### 步骤 1: 启动基础服务 + +在项目根目录下,执行以下命令: + +```bash +# 构建并以后台模式启动容器 (包含 Django 应用和 MySQL) +docker-compose up -d --build +``` + +`docker-compose` 会读取 `docker-compose.yml` 文件,自动拉取所需镜像、构建项目镜像,并启动所有服务。 + +- **访问您的博客**: 服务启动后,在浏览器中访问 `http://127.0.0.1` 即可看到博客首页。 +- **数据持久化**: MySQL 的数据文件将存储在项目根目录下的 `data/mysql` 文件夹中,确保数据在容器重启后不丢失。 + +### 步骤 2: (可选) 启用 Elasticsearch 全文搜索 + +如果您希望使用 Elasticsearch 提供更强大的全文搜索功能,可以额外加载 `docker-compose.es.yml` 配置文件: + +```bash +# 构建并以后台模式启动所有服务 (Django, MySQL, Elasticsearch) +docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build +``` +- **数据持久化**: Elasticsearch 的数据将存储在 `data/elasticsearch` 文件夹中。 + +### 步骤 3: 首次运行的初始化操作 + +当容器首次启动后,您需要进入容器来执行一些初始化命令。 + +```bash +# 进入 djangoblog 应用容器 +docker-compose exec web bash + +# 在容器内执行以下命令: +# 创建超级管理员账户 (请按照提示设置用户名、邮箱和密码) +python manage.py createsuperuser + +# (可选) 创建一些测试数据 +python manage.py create_testdata + +# (可选,如果启用了 ES) 创建索引 +python manage.py rebuild_index + +# 退出容器 +exit +``` + +## 3. 备选方式:使用独立的 Docker 镜像 + +如果您已经拥有一个正在运行的外部 MySQL 数据库,您也可以只运行 DjangoBlog 的应用镜像。 + +```bash +# 从 Docker Hub 拉取最新镜像 +docker pull liangliangyy/djangoblog:latest + +# 运行容器,并链接到您的外部数据库 +docker run -d \ + -p 8000:8000 \ + -e DJANGO_SECRET_KEY='your-strong-secret-key' \ + -e DJANGO_MYSQL_HOST='your-mysql-host' \ + -e DJANGO_MYSQL_USER='your-mysql-user' \ + -e DJANGO_MYSQL_PASSWORD='your-mysql-password' \ + -e DJANGO_MYSQL_DATABASE='djangoblog' \ + --name djangoblog \ + liangliangyy/djangoblog:latest +``` + +- **访问您的博客**: 启动完成后,访问 `http://127.0.0.1:8000`。 +- **创建管理员**: `docker exec -it djangoblog python manage.py createsuperuser` + +## 4. 配置说明 (环境变量) + +本项目的大部分配置都通过环境变量来管理。您可以在 `docker-compose.yml` 文件中修改它们,或者在使用 `docker run` 命令时通过 `-e` 参数传入。 + +| 环境变量名称 | 默认值/示例 | 备注 | +|-------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------| +| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **请务必修改为一个随机且复杂的字符串!** | +| `DJANGO_DEBUG` | `False` | 是否开启 Django 的调试模式 | +| `DJANGO_MYSQL_HOST` | `mysql` | 数据库主机名 | +| `DJANGO_MYSQL_PORT` | `3306` | 数据库端口 | +| `DJANGO_MYSQL_DATABASE` | `djangoblog` | 数据库名称 | +| `DJANGO_MYSQL_USER` | `root` | 数据库用户名 | +| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | 数据库密码 | +| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis 连接地址 (用于缓存) | +| `DJANGO_ELASTICSEARCH_HOST` | `elasticsearch:9200` | Elasticsearch 主机地址 | +| `DJANGO_EMAIL_HOST` | `smtp.example.org` | 邮件服务器地址 | +| `DJANGO_EMAIL_PORT` | `465` | 邮件服务器端口 | +| `DJANGO_EMAIL_USER` | `user@example.org` | 邮件账户 | +| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | 邮件密码 | +| `DJANGO_EMAIL_USE_SSL` | `True` | 是否使用 SSL | +| `DJANGO_EMAIL_USE_TLS` | `False` | 是否使用 TLS | +| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | 接收异常报告的管理员邮箱 | +| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | [百度站长平台](https://ziyuan.baidu.com/linksubmit/index) 的推送接口 | + +--- + +部署完成后,请务必检查并根据您的实际需求调整这些环境变量,特别是 `DJANGO_SECRET_KEY` 和数据库、邮件相关的配置。 diff --git a/docs/es.md b/docs/es.md new file mode 100644 index 0000000..97226c5 --- /dev/null +++ b/docs/es.md @@ -0,0 +1,28 @@ +# 集成Elasticsearch +如果你已经有了`Elasticsearch`环境,那么可以将搜索从`Whoosh`换成`Elasticsearch`,集成方式也很简单, +首先需要注意如下几点: +1. 你的`Elasticsearch`支持`ik`中文分词 +2. 你的`Elasticsearch`版本>=7.3.0 + +接下来在`settings.py`做如下改动即可: +- 增加es链接,如下所示: +```python +ELASTICSEARCH_DSL = { + 'default': { + 'hosts': '127.0.0.1:9200' + }, +} +``` +- 修改`HAYSTACK`配置: +```python +HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', + }, +} +``` +然后终端执行: +```shell script +./manage.py build_index +``` +这将会在你的es中创建两个索引,分别是`blog`和`performance`,其中`blog`索引就是搜索所使用的,而`performance`会记录每个请求的响应时间,以供将来优化使用。 \ No newline at end of file diff --git a/docs/imgs/alipay.jpg b/docs/imgs/alipay.jpg new file mode 100644 index 0000000..424d70a Binary files /dev/null and b/docs/imgs/alipay.jpg differ diff --git a/docs/imgs/pycharm_logo.png b/docs/imgs/pycharm_logo.png new file mode 100644 index 0000000..7f2a4b0 Binary files /dev/null and b/docs/imgs/pycharm_logo.png differ diff --git a/docs/imgs/wechat.jpg b/docs/imgs/wechat.jpg new file mode 100644 index 0000000..7edf525 Binary files /dev/null and b/docs/imgs/wechat.jpg differ diff --git a/docs/k8s-en.md b/docs/k8s-en.md new file mode 100644 index 0000000..20e9527 --- /dev/null +++ b/docs/k8s-en.md @@ -0,0 +1,141 @@ +# Deploying DjangoBlog with Kubernetes + +This document guides you through deploying the DjangoBlog application on a Kubernetes (K8s) cluster. We provide a complete set of `.yaml` configuration files in the `deploy/k8s` directory to deploy a full service stack, including the DjangoBlog application, Nginx, MySQL, Redis, and Elasticsearch. + +## Architecture Overview + +This deployment utilizes a microservices-based, cloud-native architecture: + +- **Core Components**: Each core service (DjangoBlog, Nginx, MySQL, Redis, Elasticsearch) runs as a separate `Deployment`. +- **Configuration Management**: Nginx configurations and Django application environment variables are managed via `ConfigMap`. **Note: For sensitive information like passwords, using `Secret` is highly recommended.** +- **Service Discovery**: All services are exposed internally within the cluster as `ClusterIP` type `Service`, enabling communication via service names. +- **External Access**: An `Ingress` resource is used to route external HTTP traffic to the Nginx service, which acts as the single entry point for the entire blog application. +- **Data Persistence**: A `local-storage` solution based on node-local paths is used. This requires you to manually create storage directories on a specific K8s node and statically bind them using `PersistentVolume` (PV) and `PersistentVolumeClaim` (PVC). + +## 1. Prerequisites + +Before you begin, please ensure you have the following: + +- A running Kubernetes cluster. +- The `kubectl` command-line tool configured to connect to your cluster. +- An [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/deploy/) installed and configured in your cluster. +- Filesystem access to one of the nodes in your cluster (defaulted to `master` in the configs) to create local storage directories. + +## 2. Deployment Steps + +### Step 1: Create a Namespace + +We recommend deploying all DjangoBlog-related resources in a dedicated namespace for better management. + +```bash +# Create a namespace named 'djangoblog' +kubectl create namespace djangoblog +``` + +### Step 2: Configure Persistent Storage + +This setup uses Local Persistent Volumes. You need to create the data storage directories on a node within your cluster (the default is the `master` node in `pv.yaml`). + +```bash +# Log in to your master node +ssh user@master-node + +# Create the required storage directories +sudo mkdir -p /mnt/local-storage-db +sudo mkdir -p /mnt/local-storage-djangoblog +sudo mkdir -p /mnt/resource/ +sudo mkdir -p /mnt/local-storage-elasticsearch + +# Log out from the node +exit +``` +**Note**: If you wish to store data on a different node or use different paths, you must modify the `nodeAffinity` and `local.path` settings in the `deploy/k8s/pv.yaml` file. + +After creating the directories, apply the storage-related configurations: + +```bash +# Apply the StorageClass +kubectl apply -f deploy/k8s/storageclass.yaml + +# Apply the PersistentVolumes (PVs) +kubectl apply -f deploy/k8s/pv.yaml + +# Apply the PersistentVolumeClaims (PVCs) +kubectl apply -f deploy/k8s/pvc.yaml +``` + +### Step 3: Configure the Application + +Before deploying the application, you need to edit the `deploy/k8s/configmap.yaml` file to modify sensitive information and custom settings. + +**It is strongly recommended to change the following fields:** +- `DJANGO_SECRET_KEY`: Change to a random, complex string. +- `DJANGO_MYSQL_PASSWORD` and `MYSQL_ROOT_PASSWORD`: Change to your own secure database password. + +```bash +# Edit the ConfigMap file +vim deploy/k8s/configmap.yaml + +# Apply the configuration +kubectl apply -f deploy/k8s/configmap.yaml +``` + +### Step 4: Deploy the Application Stack + +Now, we can deploy all the core services. + +```bash +# Deploy the Deployments (DjangoBlog, MySQL, Redis, Nginx, ES) +kubectl apply -f deploy/k8s/deployment.yaml + +# Deploy the Services (to create internal endpoints for the Deployments) +kubectl apply -f deploy/k8s/service.yaml +``` + +The deployment may take some time. You can run the following command to check if all Pods are running successfully (STATUS should be `Running`): + +```bash +kubectl get pods -n djangoblog -w +``` + +### Step 5: Expose the Application Externally + +Finally, expose the Nginx service to external traffic by applying the `Ingress` rule. + +```bash +# Apply the Ingress rule +kubectl apply -f deploy/k8s/gateway.yaml +``` + +Once deployed, you can access your blog via the external IP address of your Ingress Controller. Use the following command to find the address: + +```bash +kubectl get ingress -n djangoblog +``` + +### Step 6: First-Time Initialization + +Similar to the Docker deployment, you need to get a shell into the DjangoBlog application Pod to perform database initialization and create a superuser on the first run. + +```bash +# First, get the name of a djangoblog pod +kubectl get pods -n djangoblog | grep djangoblog + +# Exec into one of the Pods (replace [pod-name] with the name from the previous step) +kubectl exec -it [pod-name] -n djangoblog -- bash + +# Inside the Pod, run the following commands: +# Create a superuser account (follow the prompts) +python manage.py createsuperuser + +# (Optional) Create some test data +python manage.py create_testdata + +# (Optional, if ES is enabled) Create the search index +python manage.py rebuild_index + +# Exit the Pod +exit +``` + +Congratulations! You have successfully deployed DjangoBlog on your Kubernetes cluster. \ No newline at end of file diff --git a/docs/k8s.md b/docs/k8s.md new file mode 100644 index 0000000..9da3c28 --- /dev/null +++ b/docs/k8s.md @@ -0,0 +1,141 @@ +# 使用 Kubernetes 部署 DjangoBlog + +本文档将指导您如何在 Kubernetes (K8s) 集群上部署 DjangoBlog 应用。我们提供了一套完整的 `.yaml` 配置文件,位于 `deploy/k8s` 目录下,用于部署一个包含 DjangoBlog 应用、Nginx、MySQL、Redis 和 Elasticsearch 的完整服务栈。 + +## 架构概览 + +本次部署采用的是微服务化的云原生架构: + +- **核心组件**: 每个核心服务 (DjangoBlog, Nginx, MySQL, Redis, Elasticsearch) 都将作为独立的 `Deployment` 运行。 +- **配置管理**: Nginx 的配置文件和 Django 应用的环境变量通过 `ConfigMap` 进行管理。**注意:敏感信息(如密码)建议使用 `Secret` 进行管理。** +- **服务发现**: 所有服务都通过 `ClusterIP` 类型的 `Service` 在集群内部暴露,并通过服务名相互通信。 +- **外部访问**: 使用 `Ingress` 资源将外部的 HTTP 流量路由到 Nginx 服务,作为整个博客应用的统一入口。 +- **数据持久化**: 采用基于节点本地路径的 `local-storage` 方案。这需要您在指定的 K8s 节点上手动创建存储目录,并通过 `PersistentVolume` (PV) 和 `PersistentVolumeClaim` (PVC) 进行静态绑定。 + +## 1. 环境准备 + +在开始之前,请确保您已具备以下环境: + +- 一个正在运行的 Kubernetes 集群。 +- `kubectl` 命令行工具已配置并能够连接到您的集群。 +- 集群中已安装并配置好 [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/deploy/)。 +- 对集群中的一个节点(默认为 `master`)拥有文件系统访问权限,用于创建本地存储目录。 + +## 2. 部署步骤 + +### 步骤 1: 创建命名空间 + +我们建议将 DjangoBlog 相关的所有资源都部署在一个独立的命名空间中,便于管理。 + +```bash +# 创建一个名为 djangoblog 的命名空间 +kubectl create namespace djangoblog +``` + +### 步骤 2: 配置持久化存储 + +此方案使用本地持久卷 (Local Persistent Volume)。您需要在集群的一个节点上(在 `pv.yaml` 文件中默认为 `master` 节点)创建用于数据存储的目录。 + +```bash +# 登录到您的 master 节点 +ssh user@master-node + +# 创建所需的存储目录 +sudo mkdir -p /mnt/local-storage-db +sudo mkdir -p /mnt/local-storage-djangoblog +sudo mkdir -p /mnt/resource/ +sudo mkdir -p /mnt/local-storage-elasticsearch + +# 退出节点 +exit +``` +**注意**: 如果您希望将数据存储在其他节点或使用不同的路径,请务必修改 `deploy/k8s/pv.yaml` 文件中 `nodeAffinity` 和 `local.path` 的配置。 + +创建目录后,应用存储相关的配置文件: + +```bash +# 应用 StorageClass +kubectl apply -f deploy/k8s/storageclass.yaml + +# 应用 PersistentVolume (PV) +kubectl apply -f deploy/k8s/pv.yaml + +# 应用 PersistentVolumeClaim (PVC) +kubectl apply -f deploy/k8s/pvc.yaml +``` + +### 步骤 3: 配置应用 + +在部署应用之前,您需要编辑 `deploy/k8s/configmap.yaml` 文件,修改其中的敏感信息和个性化配置。 + +**强烈建议修改以下字段:** +- `DJANGO_SECRET_KEY`: 修改为一个随机且复杂的字符串。 +- `DJANGO_MYSQL_PASSWORD` 和 `MYSQL_ROOT_PASSWORD`: 修改为您自己的数据库密码。 + +```bash +# 编辑 ConfigMap 文件 +vim deploy/k8s/configmap.yaml + +# 应用配置 +kubectl apply -f deploy/k8s/configmap.yaml +``` + +### 步骤 4: 部署应用服务栈 + +现在,我们可以部署所有的核心服务了。 + +```bash +# 部署 Deployments (DjangoBlog, MySQL, Redis, Nginx, ES) +kubectl apply -f deploy/k8s/deployment.yaml + +# 部署 Services (为 Deployments 创建内部访问端点) +kubectl apply -f deploy/k8s/service.yaml +``` + +部署需要一些时间,您可以运行以下命令检查所有 Pod 是否都已成功运行 (STATUS 为 `Running`): + +```bash +kubectl get pods -n djangoblog -w +``` + +### 步骤 5: 暴露应用到外部 + +最后,通过应用 `Ingress` 规则来将外部流量引导至我们的 Nginx 服务。 + +```bash +# 应用 Ingress 规则 +kubectl apply -f deploy/k8s/gateway.yaml +``` + +部署完成后,您可以通过 Ingress Controller 的外部 IP 地址来访问您的博客。执行以下命令获取地址: + +```bash +kubectl get ingress -n djangoblog +``` + +### 步骤 6: 首次运行的初始化操作 + +与 Docker 部署类似,首次运行时,您需要进入 DjangoBlog 应用的 Pod 来执行数据库初始化和创建管理员账户。 + +```bash +# 首先,获取 djangoblog pod 的名称 +kubectl get pods -n djangoblog | grep djangoblog + +# 进入其中一个 Pod (将 [pod-name] 替换为上一步获取到的名称) +kubectl exec -it [pod-name] -n djangoblog -- bash + +# 在 Pod 内部执行以下命令: +# 创建超级管理员账户 (请按照提示操作) +python manage.py createsuperuser + +# (可选) 创建测试数据 +python manage.py create_testdata + +# (可选,如果启用了 ES) 创建索引 +python manage.py rebuild_index + +# 退出 Pod +exit +``` + +至此,您已成功在 Kubernetes 集群上完成了 DjangoBlog 的部署! \ No newline at end of file