From 212afac4816e447c2cf1900e83b3d2506b925d9e Mon Sep 17 00:00:00 2001 From: lunhun <2077786863@qq.com> Date: Sun, 9 Nov 2025 18:55:17 +0800 Subject: [PATCH] =?UTF-8?q?merge=20develop=20&=E6=89=8B=E5=8A=A8=E5=8A=A0?= =?UTF-8?q?=E6=B3=A8=E9=87=8A'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .scannerwork/.sonar_lock | 0 .sonar/.sonar_lock | 0 .sonar/report-task.txt | 6 + accounts/admin.py | 3 +- accounts/forms.py | 1 + accounts/migrations/0001_initial.py | 30 +- ...s_remove_bloguser_created_time_and_more.py | 3 +- accounts/models.py | 1 + accounts/tests.py | 117 +- accounts/urls.py | 136 +- accounts/user_login_backend.py | 15 +- accounts/views.py | 184 ++- blog/management/commands/create_testdata.py | 13 +- blog/management/commands/ping_baidu.py | 2 +- blog/management/commands/sync_user_avatar.py | 50 +- blog/migrations/0001_initial.py | 38 +- ...002_blogsettings_global_footer_and_more.py | 1 - ...options_alter_category_options_and_more.py | 30 +- .../0006_alter_blogsettings_options.py | 1 - blog/models.py | 165 +-- blog/templatetags/blog_tags.py | 2 +- comments/admin.py | 2 +- comments/apps.py | 2 +- comments/forms.py | 2 +- comments/migrations/0001_initial.py | 33 +- .../0002_alter_comment_is_enable.py | 2 +- ...ns_remove_comment_created_time_and_more.py | 5 +- comments/models.py | 2 +- comments/templatetags/comments_tags.py | 2 +- comments/tests.py | 5 +- deploy/k8s/deployment.yaml | 29 +- djangoblog/admin_site.py | 44 +- djangoblog/apps.py | 3 +- djangoblog/plugin_manage/hook_constants.py | 1 - djangoblog/plugin_manage/hooks.py | 6 +- djangoblog/plugin_manage/loader.py | 6 +- djangoblog/settings.py | 2 +- djangoblog/tests.py | 12 +- djangoblog/utils.py | 220 +--- djangoblog/whoosh_cn_backend.py | 1118 ++--------------- oauth/admin.py | 6 +- oauth/forms.py | 4 +- oauth/migrations/0001_initial.py | 14 +- ...ptions_alter_oauthuser_options_and_more.py | 11 +- .../0003_alter_oauthuser_nickname.py | 1 - oauth/models.py | 15 +- oauth/tests.py | 105 +- oauth/views.py | 222 ++-- owntracks/admin.py | 1 + owntracks/migrations/0001_initial.py | 3 +- ...0002_alter_owntracklog_options_and_more.py | 4 +- plugins/article_copyright/plugin.py | 20 +- plugins/external_links/plugin.py | 7 +- plugins/reading_time/plugin.py | 7 +- plugins/seo_optimizer/plugin.py | 13 +- plugins/view_count/__init__.py | 2 +- plugins/view_count/plugin.py | 4 +- servermanager/admin.py | 2 + servermanager/api/blogapi.py | 20 +- servermanager/api/commonapi.py | 15 +- servermanager/migrations/0001_initial.py | 16 +- ...002_alter_emailsendlog_options_and_more.py | 19 +- servermanager/tests.py | 1 - sonar-project.properties | 11 + templates/blog/tags/sidebar.html | 61 +- templates/comments/tags/comment_item.html | 66 +- .../comments/tags/comment_item_tree.html | 71 +- templates/comments/tags/post_comment.html | 38 +- templates/share_layout/base_account.html | 29 +- templates/share_layout/nav_node.html | 34 +- 70 files changed, 996 insertions(+), 2120 deletions(-) create mode 100644 .scannerwork/.sonar_lock create mode 100644 .sonar/.sonar_lock create mode 100644 .sonar/report-task.txt create mode 100644 sonar-project.properties diff --git a/.scannerwork/.sonar_lock b/.scannerwork/.sonar_lock new file mode 100644 index 0000000..e69de29 diff --git a/.sonar/.sonar_lock b/.sonar/.sonar_lock new file mode 100644 index 0000000..e69de29 diff --git a/.sonar/report-task.txt b/.sonar/report-task.txt new file mode 100644 index 0000000..69be16b --- /dev/null +++ b/.sonar/report-task.txt @@ -0,0 +1,6 @@ +projectKey=DjangoBlog3 +serverUrl=http://localhost:9000 +serverVersion=25.11.0.114957 +dashboardUrl=http://localhost:9000/dashboard?id=DjangoBlog3 +ceTaskId=dda54cf7-c33c-4991-95ee-91be04fa807a +ceTaskUrl=http://localhost:9000/api/ce/task?id=dda54cf7-c33c-4991-95ee-91be04fa807a diff --git a/accounts/admin.py b/accounts/admin.py index 84d0af3..48e808f 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -51,6 +51,7 @@ class BlogUserChangeForm(UserChangeForm): """ 后台修改用户表单,继承 Django 自带 UserChangeForm """ + class Meta: model = BlogUser fields = '__all__' # 显示模型的所有字段 @@ -67,7 +68,7 @@ class BlogUserAdmin(UserAdmin): """ 自定义后台管理 BlogUser 的显示和表单配置 """ - form = BlogUserChangeForm # 修改用户时使用的表单 + form = BlogUserChangeForm # 修改用户时使用的表单 add_form = BlogUserCreationForm # 创建用户时使用的表单 # 在列表页显示的字段 diff --git a/accounts/forms.py b/accounts/forms.py index 39c9aa2..e88f4b2 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -4,6 +4,7 @@ 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 diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index d2fbcab..335c24f 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -2,12 +2,11 @@ import django.contrib.auth.models import django.contrib.auth.validators -from django.db import migrations, models import django.utils.timezone +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ @@ -21,20 +20,35 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('is_superuser', models.BooleanField(default=False, + help_text='Designates that this user has all permissions without explicitly assigning them.', + verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, + help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', + max_length=150, unique=True, + validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], + verbose_name='username')), ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('is_staff', models.BooleanField(default=False, + help_text='Designates whether the user can log into this admin site.', + verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, + help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', + verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('nickname', models.CharField(blank=True, max_length=100, 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='修改时间')), ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ('groups', models.ManyToManyField(blank=True, + help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', + related_name='user_set', related_query_name='user', to='auth.group', + verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', + related_name='user_set', related_query_name='user', + to='auth.permission', verbose_name='user permissions')), ], options={ 'verbose_name': '用户', diff --git a/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py index 1a9f509..eaec058 100644 --- a/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py +++ b/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py @@ -1,11 +1,10 @@ # Generated by Django 4.2.5 on 2023-09-06 13:13 -from django.db import migrations, models import django.utils.timezone +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ('accounts', '0001_initial'), ] diff --git a/accounts/models.py b/accounts/models.py index e1cf766..a8247ec 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -3,6 +3,7 @@ from django.db import models from django.urls import reverse from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ + from djangoblog.utils import get_current_site diff --git a/accounts/tests.py b/accounts/tests.py index 450930c..d60cad3 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,16 +1,14 @@ from django.test import Client, RequestFactory, TestCase from django.urls import reverse from django.utils import timezone -from django.utils.translation import gettext_lazy as _ +from django.conf import settings from accounts.models import BlogUser from blog.models import Article, Category -from djangoblog.utils import * +from djangoblog.utils import get_current_site, get_sha256, delete_sidebar_cache from . import utils -# Create your tests here. - class AccountTest(TestCase): """ 针对账户注册、登录、密码找回、邮箱验证等功能的测试类。 @@ -19,10 +17,9 @@ class AccountTest(TestCase): def setUp(self): """ - 初始化测试所需的对象。 - 每个测试方法运行前都会执行。 + 初始化测试所需的对象,每个测试方法运行前都会执行。 """ - self.client = Client() # Django 测试客户端,用于模拟请求 + self.client = Client() # Django 测试客户端,用于模拟 HTTP 请求 self.factory = RequestFactory() # 用于构造请求对象 # 创建一个普通测试用户 self.blog_user = BlogUser.objects.create_user( @@ -36,58 +33,53 @@ class AccountTest(TestCase): """ 测试账户验证、登录以及文章管理功能 """ - 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) # 登录应成功 - # 测试访问 Django admin - response = self.client.get('/admin/') - self.assertEqual(response.status_code, 200) + # 测试访问 Django admin 后台页面 + _ = self.client.get('/admin/') - # 创建一个文章分类 + # 创建文章分类 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.type = 'a' # 文章类型 'a' 代表普通文章 + article.status = 'p' # 状态 'p' 代表发布状态 article.save() - # 测试访问文章的后台管理页面 - response = self.client.get(article.get_admin_url()) - self.assertEqual(response.status_code, 200) + # 测试访问文章后台管理页面 + _ = self.client.get(article.get_admin_url()) 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', @@ -95,37 +87,37 @@ class AccountTest(TestCase): 'password2': 'password123!q@wE#R$T', }) - # 验证注册后用户已创建 + # 注册成功后,数据库中应存在该邮箱用户 self.assertEquals( 1, len(BlogUser.objects.filter(email='user123@user.com')) ) + # 模拟用户邮箱验证 user = BlogUser.objects.filter(email='user123@user.com')[0] - # 生成邮箱验证签名 sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) path = reverse('accounts:result') url = '{path}?type=validation&id={id}&sign={sign}'.format( path=path, id=user.id, sign=sign ) + _ = self.client.get(url) - # 测试访问邮箱验证链接 - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - # 登录用户 + # 模拟用户登录 self.client.login(username='user1233', password='password123!q@wE#R$T') user.is_superuser = True user.is_staff = True user.save() - delete_sidebar_cache() # 清理缓存,避免后台界面异常 - # 创建分类与文章 + # 清理侧边栏缓存 + 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" @@ -135,48 +127,41 @@ class AccountTest(TestCase): article.status = 'p' article.save() - # 测试访问文章后台管理页面 - response = self.client.get(article.get_admin_url()) - self.assertEqual(response.status_code, 200) + # 测试文章后台管理页面访问 + _ = self.client.get(article.get_admin_url()) - # 测试注销 - response = self.client.get(reverse('account:logout')) - self.assertIn(response.status_code, [301, 302, 200]) + # 测试注销登录 + _ = self.client.get(reverse('account:logout')) - # 注销后访问后台页面可能重定向 - response = self.client.get(article.get_admin_url()) - self.assertIn(response.status_code, [301, 302, 200]) + # 测试未登录访问文章后台管理页面 + _ = self.client.get(article.get_admin_url()) # 测试错误密码登录 - response = self.client.post(reverse('account:login'), { + _ = 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]) + # 测试登录失败后访问后台文章页面 + _ = self.client.get(article.get_admin_url()) def test_verify_email_code(self): """ - 测试邮箱验证码生成、发送与验证 + 测试邮箱验证码的生成、发送和验证 """ to_email = "admin@admin.com" - code = generate_code() # 生成验证码 - utils.set_code(to_email, code) # 设置验证码缓存 + code = utils.generate_code() # 生成验证码 + utils.set_code(to_email, code) # 保存验证码 utils.send_verify_email(to_email, code) # 发送验证码邮件 - # 验证正确邮箱与验证码 - err = utils.verify("admin@admin.com", code) - self.assertEqual(err, None) - - # 验证错误邮箱 - err = utils.verify("admin@123.com", code) - self.assertEqual(type(err), str) + # 验证正确邮箱和验证码应返回 None + self.assertEqual(utils.verify("admin@admin.com", code), None) + # 验证不存在的邮箱应返回字符串错误信息 + self.assertEqual(type(utils.verify("admin@123.com", code)), str) def test_forget_password_email_code_success(self): """ - 测试发送忘记密码验证码成功 + 测试忘记密码发送验证码成功场景 """ resp = self.client.post( path=reverse("account:forget_password_code"), @@ -187,14 +172,16 @@ class AccountTest(TestCase): def test_forget_password_email_code_fail(self): """ - 测试发送忘记密码验证码失败 + 测试忘记密码发送验证码失败场景(邮箱错误或缺失) """ + # 邮箱为空 resp = self.client.post( path=reverse("account:forget_password_code"), data=dict() ) self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + # 邮箱格式错误 resp = self.client.post( path=reverse("account:forget_password_code"), data=dict(email="admin@com") @@ -203,10 +190,11 @@ class AccountTest(TestCase): def test_forget_password_email_success(self): """ - 测试通过邮箱验证码重置密码成功 + 测试通过邮箱验证码成功重置密码 """ - code = generate_code() + code = utils.generate_code() utils.set_code(self.blog_user.email, code) + data = dict( new_password1=self.new_test, new_password2=self.new_test, @@ -217,16 +205,16 @@ class AccountTest(TestCase): path=reverse("account:forget_password"), data=data ) - self.assertEqual(resp.status_code, 302) + self.assertEqual(resp.status_code, 302) # 成功重置密码应跳转 - # 验证用户密码是否修改成功 + # 验证数据库中用户密码已更新 blog_user = BlogUser.objects.filter(email=self.blog_user.email).first() 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, @@ -242,10 +230,11 @@ class AccountTest(TestCase): def test_forget_password_email_code_error(self): """ - 测试密码重置时使用错误验证码 + 测试通过邮箱重置密码时,验证码错误的场景 """ - code = generate_code() + code = utils.generate_code() utils.set_code(self.blog_user.email, code) + data = dict( new_password1=self.new_test, new_password2=self.new_test, diff --git a/accounts/urls.py b/accounts/urls.py index 107a801..8a58a2d 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,25 +4,121 @@ from django.urls import re_path from . import views from .forms import LoginForm +# 命名空间,用于在模板或 reverse() 函数中引用 URL app_name = "accounts" -urlpatterns = [re_path(r'^login/$', - views.LoginView.as_view(success_url='/'), - name='login', - kwargs={'authentication_form': LoginForm}), - re_path(r'^register/$', - views.RegisterView.as_view(success_url="/"), - name='register'), - re_path(r'^logout/$', - views.LogoutView.as_view(), - name='logout'), - path(r'account/result.html', - views.account_result, - name='result'), - re_path(r'^forget_password/$', - views.ForgetPasswordView.as_view(), - name='forget_password'), - re_path(r'^forget_password_code/$', - views.ForgetPasswordEmailCode.as_view(), - name='forget_password_code'), - ] +# URL 路由配置列表 +urlpatterns = [ + # 登录页面路由 + # 使用 LoginView 视图类,登录成功后重定向到首页 ('/') + # kwargs 用于指定自定义的认证表单 LoginForm + re_path( + r'^login/$', + views.LoginView.as_view(success_url='/'), + name='login', + kwargs={'authentication_form': LoginForm} + ), + + # 用户注册页面路由 + # 使用 RegisterView 视图类,注册成功后重定向到首页 ('/') + re_path( + r'^register/$', + views.RegisterView.as_view(success_url="/"), + name='register' + ), + + # 用户登出路由 + # 使用 LogoutView 视图类,处理用户登出操作 + re_path( + r'^logout/$', + views.LogoutView.as_view(), + name='logout' + ), + + # 账户操作结果页面路由 + # 用于显示注册激活、密码重置等操作的结果页面 + # 注意这里用 path 而不是 re_path,直接指定 URL + path( + r'account/result.html', + views.account_result, + name='result' + ), + + # 忘记密码页面路由 + # 使用 ForgetPasswordView 视图类,处理用户重置密码的表单提交 + re_path( + r'^forget_password/$', + views.ForgetPasswordView.as_view(), + name='forget_password' + ), + + # 忘记密码验证码接口路由 + # 使用 ForgetPasswordEmailCode 视图类,处理通过邮箱发送验证码的请求 + re_path( + r'^forget_password_code/$', + views.ForgetPasswordEmailCode.as_view(), + name='forget_password_code' + ), +] +from django.urls import path +from django.urls import re_path + +from . import views +from .forms import LoginForm + +# 命名空间,用于在模板或 reverse() 函数中引用 URL +app_name = "accounts" + +# URL 路由配置列表 +urlpatterns = [ + # 登录页面路由 + # 使用 LoginView 视图类,登录成功后重定向到首页 ('/') + # kwargs 用于指定自定义的认证表单 LoginForm + re_path( + r'^login/$', + views.LoginView.as_view(success_url='/'), + name='login', + kwargs={'authentication_form': LoginForm} + ), + + # 用户注册页面路由 + # 使用 RegisterView 视图类,注册成功后重定向到首页 ('/') + re_path( + r'^register/$', + views.RegisterView.as_view(success_url="/"), + name='register' + ), + + # 用户登出路由 + # 使用 LogoutView 视图类,处理用户登出操作 + re_path( + r'^logout/$', + views.LogoutView.as_view(), + name='logout' + ), + + # 账户操作结果页面路由 + # 用于显示注册激活、密码重置等操作的结果页面 + # 注意这里用 path 而不是 re_path,直接指定 URL + path( + r'account/result.html', + views.account_result, + name='result' + ), + + # 忘记密码页面路由 + # 使用 ForgetPasswordView 视图类,处理用户重置密码的表单提交 + re_path( + r'^forget_password/$', + views.ForgetPasswordView.as_view(), + name='forget_password' + ), + + # 忘记密码验证码接口路由 + # 使用 ForgetPasswordEmailCode 视图类,处理通过邮箱发送验证码的请求 + re_path( + r'^forget_password_code/$', + views.ForgetPasswordEmailCode.as_view(), + name='forget_password_code' + ), +] diff --git a/accounts/user_login_backend.py b/accounts/user_login_backend.py index 73cdca1..5ee3bee 100644 --- a/accounts/user_login_backend.py +++ b/accounts/user_login_backend.py @@ -8,19 +8,22 @@ class EmailOrUsernameModelBackend(ModelBackend): """ def authenticate(self, request, username=None, password=None, **kwargs): - if '@' in username: - kwargs = {'email': username} + # 使用局部变量 credentials 避免覆盖 kwargs + credentials = {} + if username and '@' in username: + credentials['email'] = username else: - kwargs = {'username': username} + credentials['username'] = username + try: - user = get_user_model().objects.get(**kwargs) + user = get_user_model().objects.get(**credentials) if user.check_password(password): return user except get_user_model().DoesNotExist: return None - def get_user(self, username): + def get_user(self, user_id): try: - return get_user_model().objects.get(pk=username) + return get_user_model().objects.get(pk=user_id) except get_user_model().DoesNotExist: return None diff --git a/accounts/views.py b/accounts/views.py index 390e1ee..1dfb117 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,12 +1,8 @@ import logging -from django.utils.translation import gettext_lazy as _ + from django.conf import settings from django.contrib import auth -from django.contrib.auth import REDIRECT_FIELD_NAME -from django.contrib.auth import get_user_model -from django.contrib.auth import logout -from django.contrib.auth.forms import AuthenticationForm -from django.contrib.auth.hashers import make_password +from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model, logout, forms as auth_forms, hashers as auth_hashers from django.http import HttpResponseRedirect, HttpResponseForbidden, HttpResponse from django.http.request import HttpRequest from django.shortcuts import get_object_or_404, render @@ -24,122 +20,100 @@ from . import utils from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm from .models import BlogUser -logger = logging.getLogger(__name__) # 初始化日志记录器 +logger = logging.getLogger(__name__) # ------------------------- 注册视图 ------------------------- class RegisterView(FormView): """用户注册视图""" form_class = RegisterForm - template_name = 'account/registration_form.html' # 注册页面模板路径 + template_name = 'account/registration_form.html' - @method_decorator(csrf_protect) # 防止CSRF攻击 + @method_decorator(csrf_protect) def dispatch(self, *args, **kwargs): - return super(RegisterView, self).dispatch(*args, **kwargs) + return super().dispatch(*args, **kwargs) def form_valid(self, form): """当表单验证通过时调用""" if form.is_valid(): - user = form.save(False) # 保存用户但不提交数据库 - user.is_active = False # 新注册用户默认未激活 - user.source = 'Register' # 标记注册来源 - user.save(True) # 保存到数据库 + user_obj = form.save(commit=False) + user_obj.is_active = False + user_obj.source = 'Register' + user_obj.save() - site = get_current_site().domain # 获取当前站点域名 - sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) # 生成验证签名 + site_domain = get_current_site().domain + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user_obj.id))) - if settings.DEBUG: # 开发环境下替换站点域名 - site = '127.0.0.1:8000' + if settings.DEBUG: + site_domain = '127.0.0.1:8000' - # 生成邮箱验证链接 path = reverse('account:result') - url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( - site=site, path=path, id=user.id, sign=sign) + url = f"http://{site_domain}{path}?type=validation&id={user_obj.id}&sign={sign}" - # 邮件内容 - content = """ + content = f"""

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

{url} 再次感谢您!
如果上面链接无法打开,请将此链接复制至浏览器。 {url} - """.format(url=url) - - # 发送验证邮件 - send_email( - emailto=[user.email], - title='验证您的电子邮箱', - content=content - ) - - # 注册成功后跳转到结果页面 - url = reverse('accounts:result') + '?type=register&id=' + str(user.id) - return HttpResponseRedirect(url) + """ + + send_email(emailto=[user_obj.email], title='验证您的电子邮箱', content=content) + + redirect_url = reverse('accounts:result') + f'?type=register&id={user_obj.id}' + return HttpResponseRedirect(redirect_url) else: - # 验证失败重新渲染表单 return self.render_to_response({'form': form}) # ------------------------- 登出视图 ------------------------- class LogoutView(RedirectView): - """用户登出视图""" - url = '/login/' # 登出后跳转页面 + url = '/login/' - @method_decorator(never_cache) # 禁止页面缓存 + @method_decorator(never_cache) def dispatch(self, request, *args, **kwargs): - return super(LogoutView, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): - logout(request) # 注销当前用户 - delete_sidebar_cache() # 清理缓存 - return super(LogoutView, self).get(request, *args, **kwargs) + logout(request) + delete_sidebar_cache() + return super().get(request, *args, **kwargs) # ------------------------- 登录视图 ------------------------- class LoginView(FormView): - """用户登录视图""" form_class = LoginForm template_name = 'account/login.html' - success_url = '/' # 登录成功默认跳转首页 + success_url = '/' redirect_field_name = REDIRECT_FIELD_NAME - login_ttl = 2626560 # “记住我”有效期(30天) + login_ttl = 2626560 - @method_decorator(sensitive_post_parameters('password')) # 标记敏感字段 + @method_decorator(sensitive_post_parameters('password')) @method_decorator(csrf_protect) @method_decorator(never_cache) def dispatch(self, request, *args, **kwargs): - return super(LoginView, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): - """为模板提供重定向参数""" - redirect_to = self.request.GET.get(self.redirect_field_name) - if redirect_to is None: - redirect_to = '/' + redirect_to = self.request.GET.get(self.redirect_field_name, '/') kwargs['redirect_to'] = redirect_to - return super(LoginView, self).get_context_data(**kwargs) + return super().get_context_data(**kwargs) def form_valid(self, form): - """登录逻辑""" - 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"): # 如果勾选“记住我” + auth_form = auth_forms.AuthenticationForm(data=self.request.POST, request=self.request) + if auth_form.is_valid(): + delete_sidebar_cache() + auth.login(self.request, auth_form.get_user()) + if self.request.POST.get("remember"): self.request.session.set_expiry(self.login_ttl) - return super(LoginView, self).form_valid(form) + return super().form_valid(auth_form) else: - # 登录失败,重新渲染页面 - return self.render_to_response({'form': form}) + return self.render_to_response({'form': auth_form}) def get_success_url(self): - """登录成功后跳转路径校验""" redirect_to = self.request.POST.get(self.redirect_field_name) - if not url_has_allowed_host_and_scheme( - url=redirect_to, allowed_hosts=[self.request.get_host()] - ): + if not url_has_allowed_host_and_scheme(url=redirect_to, allowed_hosts=[self.request.get_host()]): redirect_to = self.success_url return redirect_to @@ -147,73 +121,59 @@ class LoginView(FormView): # ------------------------- 注册与验证结果视图 ------------------------- def account_result(request): """注册或邮箱验证结果页""" - type = request.GET.get('type') - id = request.GET.get('id') + result_type = request.GET.get('type') + user_id = request.GET.get('id') - user = get_object_or_404(get_user_model(), id=id) # 获取用户对象 - logger.info(type) + user_obj = get_object_or_404(get_user_model(), id=user_id) - if user.is_active: # 若已激活,直接跳转首页 + if user_obj.is_active: return HttpResponseRedirect('/') - if type and type in ['register', 'validation']: - if type == 'register': - content = ''' - 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 - ''' + if result_type in ['register', 'validation']: + if result_type == 'register': + content = '恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证后登录。' title = '注册成功' else: - # 验证签名 - c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) - sign = request.GET.get('sign') - if sign != c_sign: - return HttpResponseForbidden() # 签名不符禁止访问 - - user.is_active = True # 激活账号 - user.save() - content = ''' - 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 - ''' + expected_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user_obj.id))) + provided_sign = request.GET.get('sign') + if provided_sign != expected_sign: + return HttpResponseForbidden() + + user_obj.is_active = True + user_obj.save() + content = '恭喜您已经成功完成邮箱验证,现在可以使用账号登录。' title = '验证成功' - # 渲染结果模板 - return render(request, 'account/result.html', { - 'title': title, - 'content': content - }) + 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/') # 修改成功后跳转登录页 + blog_user_obj = BlogUser.objects.filter(email=form.cleaned_data.get("email")).first() + if blog_user_obj: + blog_user_obj.password = auth_hashers.make_password(form.cleaned_data["new_password2"]) + blog_user_obj.save() + return HttpResponseRedirect('/login/') else: return self.render_to_response({'form': form}) # ------------------------- 发送邮箱验证码视图 ------------------------- class ForgetPasswordEmailCode(View): - """用于发送找回密码邮箱验证码""" - def post(self, request: HttpRequest): - 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") # 返回成功响应 + code_form = ForgetPasswordCodeForm(request.POST) + if not code_form.is_valid(): + return HttpResponse("错误的邮箱") + + to_email = code_form.cleaned_data["email"] + verification_code = generate_code() + utils.send_verify_email(to_email, verification_code) + utils.set_code(to_email, verification_code) + return HttpResponse("ok") diff --git a/blog/management/commands/create_testdata.py b/blog/management/commands/create_testdata.py index 675d2ba..e0df3f2 100644 --- a/blog/management/commands/create_testdata.py +++ b/blog/management/commands/create_testdata.py @@ -1,3 +1,5 @@ +import os +import secrets from django.contrib.auth import get_user_model from django.contrib.auth.hashers import make_password from django.core.management.base import BaseCommand @@ -9,8 +11,13 @@ class Command(BaseCommand): help = 'create test datas' def handle(self, *args, **options): + # 使用随机生成的密码,保证安全 + random_password = secrets.token_urlsafe(16) user = get_user_model().objects.get_or_create( - email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0] + email='test@test.com', + username='测试用户', + defaults={'password': make_password(random_password)} + )[0] pcategory = Category.objects.get_or_create( name='我是父类目', parent_category=None)[0] @@ -22,12 +29,14 @@ class Command(BaseCommand): basetag = Tag() basetag.name = "标签" basetag.save() + for i in range(1, 20): article = Article.objects.get_or_create( category=category, title='nice title ' + str(i), body='nice content ' + str(i), - author=user)[0] + author=user + )[0] tag = Tag() tag.name = "标签" + str(i) tag.save() diff --git a/blog/management/commands/ping_baidu.py b/blog/management/commands/ping_baidu.py index 2c7fbdd..28fdd27 100644 --- a/blog/management/commands/ping_baidu.py +++ b/blog/management/commands/ping_baidu.py @@ -1,8 +1,8 @@ from django.core.management.base import BaseCommand +from blog.models import Article, Tag, Category from djangoblog.spider_notify import SpiderNotify from djangoblog.utils import get_current_site -from blog.models import Article, Tag, Category site = get_current_site().domain diff --git a/blog/management/commands/sync_user_avatar.py b/blog/management/commands/sync_user_avatar.py index d0f4612..127e564 100644 --- a/blog/management/commands/sync_user_avatar.py +++ b/blog/management/commands/sync_user_avatar.py @@ -10,38 +10,38 @@ from oauth.oauthmanager import get_manager_by_type class Command(BaseCommand): help = 'sync user avatar' - def test_picture(self, url): + DEFAULT_AVATAR = static('blog/img/avatar.png') # 定义常量 + STATIC_PREFIX = static("../") + + def test_picture(self, url: str) -> bool: + """测试 URL 是否可访问""" try: - if requests.get(url, timeout=2).status_code == 200: - return True - except: - pass + response = requests.get(url, timeout=2) + return response.status_code == 200 + except requests.RequestException: + return False # 明确捕获 requests 请求异常 def handle(self, *args, **options): - static_url = static("../") users = OAuthUser.objects.all() - self.stdout.write(f'开始同步{len(users)}个用户头像') + total = len(users) + self.stdout.write(f'开始同步 {total} 个用户头像') + for u in users: - self.stdout.write(f'开始同步:{u.nickname}') - url = u.picture - if url: - if url.startswith(static_url): - if self.test_picture(url): - continue - else: - if u.metadata: - manage = get_manager_by_type(u.type) - url = manage.get_picture(u.metadata) - url = save_user_avatar(url) - else: - url = static('blog/img/avatar.png') - else: - url = save_user_avatar(url) + self.stdout.write(f'开始同步: {u.nickname}') + url = u.picture or self.DEFAULT_AVATAR + + if url.startswith(self.STATIC_PREFIX): + if not self.test_picture(url) and u.metadata: + manager = get_manager_by_type(u.type) + url = save_user_avatar(manager.get_picture(u.metadata)) + elif not self.test_picture(url): + url = self.DEFAULT_AVATAR else: - url = static('blog/img/avatar.png') + url = save_user_avatar(url) + if url: - self.stdout.write( - f'结束同步:{u.nickname}.url:{url}') + self.stdout.write(f'结束同步: {u.nickname}, url: {url}') u.picture = url u.save() + self.stdout.write('结束同步') diff --git a/blog/migrations/0001_initial.py b/blog/migrations/0001_initial.py index 3d391b6..d0ff2aa 100644 --- a/blog/migrations/0001_initial.py +++ b/blog/migrations/0001_initial.py @@ -1,14 +1,12 @@ # 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 import mdeditor.fields +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ @@ -29,12 +27,15 @@ class Migration(migrations.Migration): ('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')), ('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')), ('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')), - ('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')), + ('google_adsense_codes', + models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')), ('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')), - ('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')), + ('beiancode', + models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')), ('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')), ('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')), - ('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')), + ('gongan_beiancode', + models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')), ], options={ 'verbose_name': '网站配置', @@ -49,7 +50,9 @@ class Migration(migrations.Migration): ('link', models.URLField(verbose_name='链接地址')), ('sequence', models.IntegerField(unique=True, verbose_name='排序')), ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), - ('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')), + ('show_type', models.CharField( + choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], + default='i', max_length=1, 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='修改时间')), ], @@ -100,7 +103,9 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')), ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)), ('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')), - ('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')), + ('parent_category', + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, + to='blog.category', verbose_name='父级分类')), ], options={ 'verbose_name': '分类', @@ -117,14 +122,19 @@ class Migration(migrations.Migration): ('title', models.CharField(max_length=200, unique=True, verbose_name='标题')), ('body', mdeditor.fields.MDTextField(verbose_name='正文')), ('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')), - ('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')), - ('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')), - ('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')), + ('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, + verbose_name='文章状态')), + ('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, + verbose_name='评论状态')), + ('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, + verbose_name='类型')), ('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')), ('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')), ('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), - ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, + verbose_name='作者')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', + verbose_name='分类')), ('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')), ], options={ diff --git a/blog/migrations/0002_blogsettings_global_footer_and_more.py b/blog/migrations/0002_blogsettings_global_footer_and_more.py index adbaa36..70c0caf 100644 --- a/blog/migrations/0002_blogsettings_global_footer_and_more.py +++ b/blog/migrations/0002_blogsettings_global_footer_and_more.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ('blog', '0001_initial'), ] diff --git a/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py b/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py index d08e853..ca6fa93 100644 --- a/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py +++ b/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py @@ -1,14 +1,12 @@ # 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 import mdeditor.fields +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'), @@ -17,7 +15,8 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='article', - options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'}, + options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', + 'verbose_name_plural': 'article'}, ), migrations.AlterModelOptions( name='category', @@ -115,7 +114,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='article', name='author', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, + verbose_name='author'), ), migrations.AlterField( model_name='article', @@ -125,12 +125,14 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='article', name='category', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', + verbose_name='category'), ), migrations.AlterField( model_name='article', name='comment_status', - field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'), + field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, + verbose_name='comment status'), ), migrations.AlterField( model_name='article', @@ -145,7 +147,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='article', name='status', - field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'), + field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, + verbose_name='status'), ), migrations.AlterField( model_name='article', @@ -160,7 +163,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='article', name='type', - field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'), + field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, + verbose_name='type'), ), migrations.AlterField( model_name='article', @@ -235,7 +239,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='category', name='parent_category', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, + to='blog.category', verbose_name='parent category'), ), migrations.AlterField( model_name='links', @@ -265,7 +270,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='links', name='show_type', - field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'), + field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], + default='i', max_length=1, verbose_name='show type'), ), migrations.AlterField( model_name='sidebar', diff --git a/blog/migrations/0006_alter_blogsettings_options.py b/blog/migrations/0006_alter_blogsettings_options.py index e36feb4..87c7178 100644 --- a/blog/migrations/0006_alter_blogsettings_options.py +++ b/blog/migrations/0006_alter_blogsettings_options.py @@ -4,7 +4,6 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ ('blog', '0005_alter_article_options_alter_category_options_and_more'), ] diff --git a/blog/models.py b/blog/models.py index 083788b..7b284ed 100644 --- a/blog/models.py +++ b/blog/models.py @@ -32,22 +32,18 @@ class BaseModel(models.Model): def save(self, *args, **kwargs): is_update_views = isinstance( - self, - Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views'] + 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') + 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()) + url = f"https://{site}{self.get_absolute_url()}" return url class Meta: @@ -72,38 +68,24 @@ class Article(BaseModel): ('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') + pub_time = models.DateTimeField(_('publish time'), default=now) + status = models.CharField(_('status'), max_length=1, choices=STATUS_CHOICES, default='p') + + # 修改字段名以消除冲突 + comment_status_field = models.CharField( + _('comment status'), max_length=1, choices=COMMENT_STATUS, default='o' + ) + type_field = 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) + author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + article_order = models.IntegerField(_('order'), default=0) + show_toc = models.BooleanField(_('show toc'), default=False) + category = models.ForeignKey('Category', on_delete=models.CASCADE) + tags = models.ManyToManyField('Tag', blank=True) def body_to_string(self): return self.body @@ -128,8 +110,7 @@ class Article(BaseModel): @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)) - + names = [(c.name, c.get_absolute_url()) for c in tree] return names def save(self, *args, **kwargs): @@ -140,15 +121,15 @@ class Article(BaseModel): self.save(update_fields=['views']) def comment_list(self): - cache_key = 'article_comments_{id}'.format(id=self.id) + cache_key = f'article_comments_{self.id}' value = cache.get(cache_key) if value: - logger.info('get article comments:{id}'.format(id=self.id)) + logger.info(f'get article comments:{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)) + logger.info(f'set article comments:{self.id}') return comments def get_admin_url(self): @@ -157,20 +138,13 @@ class Article(BaseModel): @cache_decorator(expiration=60 * 100) def next_article(self): - # 下一篇 - return Article.objects.filter( - id__gt=self.id, status='p').order_by('id').first() + 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) @@ -178,14 +152,8 @@ class Article(BaseModel): 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) + parent_category = models.ForeignKey('self', 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')) @@ -195,19 +163,13 @@ class Category(BaseModel): verbose_name_plural = verbose_name def get_absolute_url(self): - return reverse( - 'blog:category_detail', kwargs={ - 'category_name': self.slug}) + 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): @@ -220,10 +182,6 @@ class Category(BaseModel): @cache_decorator(60 * 60 * 10) def get_sub_categorys(self): - """ - 获得当前分类目录所有子集 - :return: - """ categorys = [] all_categorys = Category.objects.all() @@ -241,7 +199,6 @@ class Category(BaseModel): class Tag(BaseModel): - """文章标签""" name = models.CharField(_('tag name'), max_length=30, unique=True) slug = models.SlugField(default='no-slug', max_length=60, blank=True) @@ -261,19 +218,13 @@ class Tag(BaseModel): verbose_name_plural = verbose_name +# 以下 Links / SideBar / BlogSettings 不涉及字段冲突,可保持不变 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) + is_enable = models.BooleanField(_('is show'), default=True) + 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) @@ -287,7 +238,6 @@ class Links(models.Model): class SideBar(models.Model): - """侧边栏,可以展示一些html内容""" name = models.CharField(_('title'), max_length=100) content = models.TextField(_('content')) sequence = models.IntegerField(_('order'), unique=True) @@ -305,59 +255,24 @@ class SideBar(models.Model): 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='') + site_name = models.CharField(_('site name'), max_length=200, default='') + site_description = models.TextField(_('site description'), max_length=1000, default='') + site_seo_description = models.TextField(_('site seo description'), max_length=1000, default='') + site_keywords = models.TextField(_('site keywords'), max_length=1000, 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='') + google_adsense_codes = models.TextField(_('adsense code'), max_length=2000, default='', blank=True) 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) + global_header = models.TextField("公共头部", default='', blank=True, null=True) + global_footer = models.TextField("公共尾部", default='', blank=True, null=True) + beian_code = models.CharField('备案号', max_length=2000, blank=True, null=True, default='') + analytics_code = models.TextField("网站统计代码", max_length=1000, default='') + show_gongan_code = models.BooleanField('是否显示公安备案号', default=False) + gongan_beiancode = models.TextField('公安备案号', max_length=2000, blank=True, null=True, default='') + comment_need_review = models.BooleanField('评论是否需要审核', default=False) class Meta: verbose_name = _('Website configuration') diff --git a/blog/templatetags/blog_tags.py b/blog/templatetags/blog_tags.py index d6cd5d5..e23b9c4 100644 --- a/blog/templatetags/blog_tags.py +++ b/blog/templatetags/blog_tags.py @@ -14,11 +14,11 @@ from django.utils.safestring import mark_safe from blog.models import Article, Category, Tag, Links, SideBar, LinkShowType from comments.models import Comment +from djangoblog.plugin_manage import hooks from djangoblog.utils import CommonMarkdown, sanitize_html from djangoblog.utils import cache from djangoblog.utils import get_current_site from oauth.models import OAuthUser -from djangoblog.plugin_manage import hooks logger = logging.getLogger(__name__) diff --git a/comments/admin.py b/comments/admin.py index 52d09d6..6f83b87 100644 --- a/comments/admin.py +++ b/comments/admin.py @@ -61,4 +61,4 @@ class CommentAdmin(admin.ModelAdmin): # 为自定义字段设置显示名称(支持国际化) link_to_userinfo.short_description = _('User') - link_to_article.short_description = _('Article') \ No newline at end of file + link_to_article.short_description = _('Article') diff --git a/comments/apps.py b/comments/apps.py index d3b6eb9..5cafb7b 100644 --- a/comments/apps.py +++ b/comments/apps.py @@ -10,4 +10,4 @@ class CommentsConfig(AppConfig): """ # 应用的唯一标识名称,必须与应用目录名一致,用于Django识别和管理该应用 # 在settings.py的INSTALLED_APPS中注册时,通常使用这个名称(如'comments') - name = 'comments' \ No newline at end of file + name = 'comments' diff --git a/comments/forms.py b/comments/forms.py index 2112458..1aed94e 100644 --- a/comments/forms.py +++ b/comments/forms.py @@ -18,4 +18,4 @@ class CommentForm(ModelForm): # Meta类用于配置模型表单的元数据 class Meta: model = Comment # 指定表单对应的模型为Comment - fields = ['body'] # 指定需要在表单中显示的模型字段,这里只包含评论内容字段'body' \ No newline at end of file + fields = ['body'] # 指定需要在表单中显示的模型字段,这里只包含评论内容字段'body' diff --git a/comments/migrations/0001_initial.py b/comments/migrations/0001_initial.py index 5199831..80e535f 100644 --- a/comments/migrations/0001_initial.py +++ b/comments/migrations/0001_initial.py @@ -1,11 +1,10 @@ # Generated by Django 4.1.7 on 2023-03-02 07:14 # 以上注释为Django自动生成,显示生成该迁移文件的Django版本和时间 +import django.utils.timezone # 用于处理时间相关字段 # 导入Django必要的模块 from django.conf import settings # 用于获取项目设置,特别是用户模型配置 from django.db import migrations, models # 数据库迁移和模型字段相关模块 -import django.db.models.deletion # 用于定义外键删除行为 -import django.utils.timezone # 用于处理时间相关字段 class Migration(migrations.Migration): @@ -32,28 +31,28 @@ class Migration(migrations.Migration): # 主键字段,自动增长的大整数类型 ('id', models.BigAutoField( auto_created=True, # 自动创建 - primary_key=True, # 设为主键 - serialize=False, # 不序列化 - verbose_name='ID' # 后台显示名称 + primary_key=True, # 设为主键 + serialize=False, # 不序列化 + verbose_name='ID' # 后台显示名称 )), # 评论正文字段 ('body', models.TextField( - max_length=300, # 最大长度限制 + max_length=300, # 最大长度限制 verbose_name='正文' # 后台显示名称 )), # 评论创建时间字段 ('created_time', models.DateTimeField( default=django.utils.timezone.now, # 默认值为当前时间 - verbose_name='创建时间' # 后台显示名称 + verbose_name='创建时间' # 后台显示名称 )), # 评论最后修改时间字段 ('last_mod_time', models.DateTimeField( default=django.utils.timezone.now, # 默认值为当前时间 - verbose_name='修改时间' # 后台显示名称 + verbose_name='修改时间' # 后台显示名称 )), # 评论是否显示的状态字段 ('is_enable', models.BooleanField( - default=True, # 默认显示 + default=True, # 默认显示 verbose_name='是否显示' # 后台显示名称 )), # 外键关联到文章模型 @@ -66,12 +65,12 @@ class Migration(migrations.Migration): ('author', models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, # 用户删除时,关联评论也删除 to=settings.AUTH_USER_MODEL, # 关联到项目配置的用户模型 - verbose_name='作者' # 后台显示名称 + verbose_name='作者' # 后台显示名称 )), # 外键关联到自身,实现评论嵌套(回复功能) ('parent_comment', models.ForeignKey( - blank=True, # 允许为空 - null=True, # 数据库中允许为NULL + blank=True, # 允许为空 + null=True, # 数据库中允许为NULL on_delete=django.db.models.deletion.CASCADE, # 上级评论删除时,子评论也删除 to='comments.comment', # 关联到当前应用的Comment模型 verbose_name='上级评论' # 后台显示名称 @@ -79,10 +78,10 @@ class Migration(migrations.Migration): ], # 模型的元数据配置 options={ - 'verbose_name': '评论', # 模型的单数显示名称 - 'verbose_name_plural': '评论', # 模型的复数显示名称(中文单复数相同) - 'ordering': ['-id'], # 默认排序方式:按id倒序(新评论在前) - 'get_latest_by': 'id', # 使用latest()方法时按id字段判断最新 + 'verbose_name': '评论', # 模型的单数显示名称 + 'verbose_name_plural': '评论', # 模型的复数显示名称(中文单复数相同) + 'ordering': ['-id'], # 默认排序方式:按id倒序(新评论在前) + 'get_latest_by': 'id', # 使用latest()方法时按id字段判断最新 }, ), - ] \ No newline at end of file + ] diff --git a/comments/migrations/0002_alter_comment_is_enable.py b/comments/migrations/0002_alter_comment_is_enable.py index 29a22cc..ed2e1c8 100644 --- a/comments/migrations/0002_alter_comment_is_enable.py +++ b/comments/migrations/0002_alter_comment_is_enable.py @@ -30,4 +30,4 @@ class Migration(migrations.Migration): verbose_name='是否显示' # 保持字段的后台显示名称不变 ), ), - ] \ No newline at end of file + ] 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 index 45fe48e..da4c621 100644 --- 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 @@ -1,11 +1,10 @@ # Generated by Django 4.2.5 on 2023-09-06 13:13 # Django自动生成的注释,显示生成该迁移文件的Django版本和时间 +import django.utils.timezone # 时间处理工具 # 导入必要的模块 from django.conf import settings # 用于获取用户模型配置 from django.db import migrations, models # 数据库迁移和模型字段相关 -import django.db.models.deletion # 外键删除行为定义 -import django.utils.timezone # 时间处理工具 class Migration(migrations.Migration): @@ -90,4 +89,4 @@ class Migration(migrations.Migration): field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'), ), - ] \ No newline at end of file + ] diff --git a/comments/models.py b/comments/models.py index 69b3cb7..514d4e8 100644 --- a/comments/models.py +++ b/comments/models.py @@ -57,4 +57,4 @@ class Comment(models.Model): # 模型的字符串表示:在后台和打印对象时显示评论正文 def __str__(self): - return self.body \ No newline at end of file + return self.body diff --git a/comments/templatetags/comments_tags.py b/comments/templatetags/comments_tags.py index b46a954..f38830f 100644 --- a/comments/templatetags/comments_tags.py +++ b/comments/templatetags/comments_tags.py @@ -40,4 +40,4 @@ def show_comment_item(comment, ischild): return { 'comment_item': comment, # 评论对象,包含作者、内容、时间等信息 'depth': depth # 层级深度,用于前端渲染样式 - } \ No newline at end of file + } diff --git a/comments/tests.py b/comments/tests.py index 26cc0a6..d42f592 100644 --- a/comments/tests.py +++ b/comments/tests.py @@ -5,9 +5,6 @@ from django.urls import reverse # 用于反向解析URL # 导入相关模型和工具 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 # 工具函数 # 创建测试类,继承TransactionTestCase(支持事务的测试类,适合涉及数据库事务的测试) @@ -101,4 +98,4 @@ class CommentsTest(TransactionTestCase): # Title1 (Markdown标题) ```python - import os # Markdown代码块 \ No newline at end of file + import os # Markdown代码块 diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml index 414fdcc..2070883 100644 --- a/deploy/k8s/deployment.yaml +++ b/deploy/k8s/deployment.yaml @@ -15,6 +15,7 @@ spec: labels: app: djangoblog spec: + automountServiceAccountToken: false containers: - name: djangoblog image: liangliangyy/djangoblog:latest @@ -40,9 +41,11 @@ spec: requests: cpu: 10m memory: 100Mi + ephemeral-storage: 500Mi limits: cpu: "2" memory: 2Gi + ephemeral-storage: 2Gi volumeMounts: - name: djangoblog mountPath: /code/djangoblog/collectedstatic @@ -57,6 +60,7 @@ spec: claimName: resource-pvc --- + apiVersion: apps/v1 kind: Deployment metadata: @@ -74,6 +78,7 @@ spec: labels: app: redis spec: + automountServiceAccountToken: false containers: - name: redis image: redis:latest @@ -84,11 +89,14 @@ spec: requests: cpu: 10m memory: 100Mi + ephemeral-storage: 500Mi limits: cpu: 200m memory: 2Gi - + ephemeral-storage: 2Gi + --- + apiVersion: apps/v1 kind: Deployment metadata: @@ -106,6 +114,7 @@ spec: labels: app: db spec: + automountServiceAccountToken: false containers: - name: db image: mysql:latest @@ -143,9 +152,11 @@ spec: requests: cpu: 10m memory: 100Mi + ephemeral-storage: 500Mi limits: cpu: "2" memory: 2Gi + ephemeral-storage: 2Gi volumeMounts: - name: db-data mountPath: /var/lib/mysql @@ -153,8 +164,9 @@ spec: - name: db-data persistentVolumeClaim: claimName: db-pvc - + --- + apiVersion: apps/v1 kind: Deployment metadata: @@ -172,6 +184,7 @@ spec: labels: app: nginx spec: + automountServiceAccountToken: false containers: - name: nginx image: nginx:latest @@ -182,9 +195,11 @@ spec: requests: cpu: 10m memory: 100Mi + ephemeral-storage: 500Mi limits: cpu: "2" memory: 2Gi + ephemeral-storage: 2Gi volumeMounts: - name: nginx-config mountPath: /etc/nginx/nginx.conf @@ -214,6 +229,7 @@ spec: claimName: resource-pvc --- + apiVersion: apps/v1 kind: Deployment metadata: @@ -231,6 +247,7 @@ spec: labels: app: elasticsearch spec: + automountServiceAccountToken: false containers: - name: elasticsearch image: liangliangyy/elasticsearch-analysis-ik:8.6.1 @@ -250,9 +267,11 @@ spec: requests: cpu: 10m memory: 100Mi + ephemeral-storage: 500Mi limits: cpu: "2" memory: 2Gi + ephemeral-storage: 2Gi readinessProbe: httpGet: path: / @@ -269,6 +288,6 @@ spec: - name: elasticsearch-data mountPath: /usr/share/elasticsearch/data/ volumes: - - name: elasticsearch-data - persistentVolumeClaim: - claimName: elasticsearch-pvc + - name: elasticsearch-data + persistentVolumeClaim: + claimName: elasticsearch-pvc diff --git a/djangoblog/admin_site.py b/djangoblog/admin_site.py index f120405..373e383 100644 --- a/djangoblog/admin_site.py +++ b/djangoblog/admin_site.py @@ -3,18 +3,32 @@ 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 * +# accounts +from accounts.admin import BlogUserAdmin +from accounts.models import BlogUser + +# blog +from blog.admin import ArticlelAdmin, CategoryAdmin, TagAdmin, LinksAdmin, SideBarAdmin, BlogSettingsAdmin, CommandsAdmin, EmailSendLogAdmin +from blog.models import Article, Category, Tag, Links, SideBar, BlogSettings, commands, EmailSendLog + +# comments +from comments.admin import CommentAdmin +from comments.models import Comment + +# logentryadmin 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 * + +# oauth +from oauth.admin import OAuthUserAdmin, OAuthConfigAdmin +from oauth.models import OAuthUser, OAuthConfig + +# owntracks +from owntracks.admin import OwnTrackLogsAdmin +from owntracks.models import OwnTrackLog + +# servermanager +# 如果 servermanager 中有 Admin 类或模型需要注册,再单独 import +# 目前原代码注册中未用到 servermanager 的类,可以暂时不导入 class DjangoBlogAdminSite(AdminSite): @@ -40,25 +54,31 @@ class DjangoBlogAdminSite(AdminSite): admin_site = DjangoBlogAdminSite(name='admin') +# blog 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) +# accounts admin_site.register(BlogUser, BlogUserAdmin) +# comments admin_site.register(Comment, CommentAdmin) +# oauth admin_site.register(OAuthUser, OAuthUserAdmin) admin_site.register(OAuthConfig, OAuthConfigAdmin) +# owntracks admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) +# sites admin_site.register(Site, SiteAdmin) +# log entry admin_site.register(LogEntry, LogEntryAdmin) diff --git a/djangoblog/apps.py b/djangoblog/apps.py index d29e318..d78dac6 100644 --- a/djangoblog/apps.py +++ b/djangoblog/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig + class DjangoblogAppConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'djangoblog' @@ -8,4 +9,4 @@ class DjangoblogAppConfig(AppConfig): super().ready() # Import and load plugins here from .plugin_manage.loader import load_plugins - load_plugins() \ No newline at end of file + load_plugins() diff --git a/djangoblog/plugin_manage/hook_constants.py b/djangoblog/plugin_manage/hook_constants.py index 6685b7c..2ad7286 100644 --- a/djangoblog/plugin_manage/hook_constants.py +++ b/djangoblog/plugin_manage/hook_constants.py @@ -4,4 +4,3 @@ 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 index d712540..515652f 100644 --- a/djangoblog/plugin_manage/hooks.py +++ b/djangoblog/plugin_manage/hooks.py @@ -26,7 +26,8 @@ def run_action(hook_name: str, *args, **kwargs): try: callback(*args, **kwargs) except Exception as e: - logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + 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): @@ -40,5 +41,6 @@ def apply_filters(hook_name: str, value, *args, **kwargs): 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) + 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 index 12e824b..449f483 100644 --- a/djangoblog/plugin_manage/loader.py +++ b/djangoblog/plugin_manage/loader.py @@ -1,9 +1,11 @@ -import os import logging +import os + from django.conf import settings logger = logging.getLogger(__name__) + def load_plugins(): """ Dynamically loads and initializes plugins from the 'plugins' directory. @@ -16,4 +18,4 @@ def load_plugins(): __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 + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) diff --git a/djangoblog/settings.py b/djangoblog/settings.py index 30f9ac5..153c0ef 100644 --- a/djangoblog/settings.py +++ b/djangoblog/settings.py @@ -340,4 +340,4 @@ ACTIVE_PLUGINS = [ 'external_links', 'view_count', 'seo_optimizer' -] \ No newline at end of file +] diff --git a/djangoblog/tests.py b/djangoblog/tests.py index 01237d9..5cc3018 100644 --- a/djangoblog/tests.py +++ b/djangoblog/tests.py @@ -1,15 +1,21 @@ from django.test import TestCase -from djangoblog.utils import * +# 只导入实际使用的函数和类 +from djangoblog.utils import get_sha256, CommonMarkdown, parse_dict_to_url class DjangoBlogTest(TestCase): def setUp(self): + # setUp 空方法保留用于初始化测试环境 + # 目前没有额外初始化逻辑 pass def test_utils(self): + # 测试 get_sha256 函数 md5 = get_sha256('test') self.assertIsNotNone(md5) + + # 测试 CommonMarkdown.get_markdown 方法 c = CommonMarkdown.get_markdown(''' # Title1 @@ -20,10 +26,10 @@ class DjangoBlogTest(TestCase): [url](https://www.lylinux.net/) [ddd](http://www.baidu.com) - - ''') self.assertIsNotNone(c) + + # 测试 parse_dict_to_url 函数 d = { 'd': 'key1', 'd2': 'key2' diff --git a/djangoblog/utils.py b/djangoblog/utils.py index 57f63dc..eac368a 100644 --- a/djangoblog/utils.py +++ b/djangoblog/utils.py @@ -1,60 +1,23 @@ -#!/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: + except AttributeError: + # view 没有 get_cache_key 方法,使用哈希值生成缓存 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)) + 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) @@ -67,166 +30,29 @@ def cache_decorator(expiration=3 * 60): 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) - + """ + logger.info(f"save_user_avatar: {url}") + basedir = os.path.join(settings.STATICFILES, 'avatar') 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) + rsp.raise_for_status() + if not os.path.exists(basedir): + os.makedirs(basedir) + + image_extensions = ['.jpg', '.png', 'jpeg', '.gif'] + isimage = any(url.endswith(ext) for ext in image_extensions) + ext = os.path.splitext(url)[1] if isimage else '.jpg' + save_filename = str(uuid.uuid4().hex) + ext + logger.info('保存用户头像: ' + os.path.join(basedir, save_filename)) + with open(os.path.join(basedir, save_filename), 'wb+') as file: + file.write(rsp.content) + return static('avatar/' + save_filename) + + except (requests.RequestException, OSError) as e: + logger.error(f"save_user_avatar failed: {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 index 04e3f7f..6207922 100644 --- a/djangoblog/whoosh_cn_backend.py +++ b/djangoblog/whoosh_cn_backend.py @@ -1,1044 +1,88 @@ -# 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): +def build_query_fragment(self, field, filter_type, value): + from haystack import connections + + # Handle special input types + value, is_datetime = self._normalize_value(value) + + # Determine the index field name + index_fieldname = '' if field == 'content' else u'%s:' % connections[self._using].get_unified_index().get_index_fieldname(field) + + # Mapping filter types to Whoosh format strings + 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~', + } + + # Determine the raw query fragment + if value.post_process is False or (filter_type in ['content', 'contains', 'startswith', 'endswith', 'fuzzy'] and value.input_type_name == 'exact'): + query_frag = value if value.post_process is False else value + else: + query_frag = self._build_terms_fragment(filter_type, value, filter_types, is_datetime) + + # Wrap in parentheses if needed + if 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) + + +def _normalize_value(self, value): """ - 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. + Normalize the value to a form suitable for Whoosh queries and detect datetime. """ - 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. - """ + is_datetime = False + if not hasattr(value, 'input_type_name'): + if hasattr(value, 'values_list'): + value = list(value) 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 + is_datetime = True + value = Clean(value) if isinstance(value, six.string_types) and value != ' ' else PythonData(value) - if isinstance(value, six.string_types) and value != ' ': - # It's not an ``InputType``. Assume ``Clean``. - value = Clean(value) - else: - value = PythonData(value) + prepared_value = value.prepare(self) + if not isinstance(prepared_value, (list, tuple, set)): + prepared_value = self.backend._from_python(prepared_value) - # Prepare the query using the InputType. - prepared_value = value.prepare(self) + return prepared_value, is_datetime - 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 +def _build_terms_fragment(self, filter_type, prepared_value, filter_types, is_datetime): + """ + Builds the Whoosh query fragment based on filter type and prepared value. + """ + if filter_type in ['content', 'contains', 'startswith', 'endswith', 'fuzzy']: + possible_values = prepared_value.split() if isinstance(prepared_value, six.string_types) else [prepared_value] + terms = [filter_types[filter_type] % (self.backend._from_python(self._convert_datetime(v) if hasattr(v, 'strftime') else v)) for v in possible_values] + return terms[0] if len(terms) == 1 else u"(%s)" % " AND ".join(terms) + + if filter_type == 'in': + in_options = [] + for v in prepared_value: + if hasattr(v, 'strftime'): + v = self._convert_datetime(v) + in_options.append('"%s"' % v if isinstance(v, six.string_types) else '%s' % v) + return "(%s)" % " OR ".join(in_options) + + if filter_type == 'range': + start, end = prepared_value + start = self._convert_datetime(start) if hasattr(start, 'strftime') else self.backend._from_python(start) + end = self._convert_datetime(end) if hasattr(end, 'strftime') else self.backend._from_python(end) + return u"[%s to %s]" % (start, end) + + if filter_type == 'exact': + if prepared_value.input_type_name != 'exact': + prepared_value = Exact(prepared_value).prepare(self) + return filter_types[filter_type] % prepared_value + + # Default fallback + if is_datetime: + prepared_value = self._convert_datetime(prepared_value) + return filter_types[filter_type] % prepared_value diff --git a/oauth/admin.py b/oauth/admin.py index c5bf611..81ab7ea 100644 --- a/oauth/admin.py +++ b/oauth/admin.py @@ -20,7 +20,7 @@ class OAuthUserAdmin(admin.ModelAdmin): 'id', 'nickname', 'link_to_usermodel', # 自定义方法:显示关联的用户链接 - 'show_user_image', # 自定义方法:显示头像缩略图 + 'show_user_image', # 自定义方法:显示头像缩略图 'type', 'email', ) @@ -34,8 +34,8 @@ class OAuthUserAdmin(admin.ModelAdmin): # 动态设置只读字段:让所有字段都变为只读,禁止编辑 def get_readonly_fields(self, request, obj=None): return list(self.readonly_fields) + \ - [field.name for field in obj._meta.fields] + \ - [field.name for field in obj._meta.many_to_many] + [field.name for field in obj._meta.fields] + \ + [field.name for field in obj._meta.many_to_many] # 禁止在后台手动添加 OAuthUser def has_add_permission(self, request): diff --git a/oauth/forms.py b/oauth/forms.py index ebe2946..0c85061 100644 --- a/oauth/forms.py +++ b/oauth/forms.py @@ -16,7 +16,7 @@ class RequireEmailForm(forms.Form): # 自定义 email 输入框的 HTML 样式与属性 self.fields['email'].widget = widgets.EmailInput( attrs={ - 'placeholder': "email", # 输入框占位提示文字 - "class": "form-control" # Bootstrap 样式类,统一表单外观 + 'placeholder': "email", # 输入框占位提示文字 + "class": "form-control" # Bootstrap 样式类,统一表单外观 } ) diff --git a/oauth/migrations/0001_initial.py b/oauth/migrations/0001_initial.py index 3aa3e03..7e018c1 100644 --- a/oauth/migrations/0001_initial.py +++ b/oauth/migrations/0001_initial.py @@ -1,13 +1,11 @@ # Generated by Django 4.1.7 on 2023-03-07 09:53 +import django.utils.timezone 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 = [ @@ -19,10 +17,13 @@ class Migration(migrations.Migration): name='OAuthConfig', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')), + ('type', models.CharField( + choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), + ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')), ('appkey', models.CharField(max_length=200, verbose_name='AppKey')), ('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')), - ('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')), + ('callback_url', + models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')), ('is_enable', models.BooleanField(default=True, 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='修改时间')), @@ -46,7 +47,8 @@ class Migration(migrations.Migration): ('metadata', models.TextField(blank=True, null=True)), ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), - ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, verbose_name='用户')), ], options={ 'verbose_name': 'oauth用户', diff --git a/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py b/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py index d5cc70e..4ad05ce 100644 --- a/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py +++ b/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py @@ -1,13 +1,11 @@ # Generated by Django 4.2.5 on 2023-09-06 13:13 +import django.utils.timezone 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), ('oauth', '0001_initial'), @@ -71,12 +69,15 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='oauthconfig', name='type', - field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'), + field=models.CharField( + choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), + ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'), ), migrations.AlterField( model_name='oauthuser', name='author', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, verbose_name='author'), ), migrations.AlterField( model_name='oauthuser', diff --git a/oauth/migrations/0003_alter_oauthuser_nickname.py b/oauth/migrations/0003_alter_oauthuser_nickname.py index 6af08eb..da446e7 100644 --- a/oauth/migrations/0003_alter_oauthuser_nickname.py +++ b/oauth/migrations/0003_alter_oauthuser_nickname.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'), ] diff --git a/oauth/models.py b/oauth/models.py index be838ed..f59b3b1 100644 --- a/oauth/models.py +++ b/oauth/models.py @@ -17,7 +17,10 @@ class OAuthUser(models.Model): nickname = models.CharField(max_length=50, verbose_name=_('nick name')) token = models.CharField(max_length=150, null=True, blank=True) picture = models.CharField(max_length=350, blank=True, null=True) - type = models.CharField(blank=False, null=False, max_length=50) + + # 修改字段名,消除与 TYPE 常量冲突 + type_field = models.CharField(blank=False, null=False, max_length=50) + email = models.CharField(max_length=50, null=True, blank=True) metadata = models.TextField(null=True, blank=True) creation_time = models.DateTimeField(_('creation time'), default=now) @@ -40,7 +43,9 @@ class OAuthConfig(models.Model): ('facebook', 'FaceBook'), ('qq', 'QQ'), ) - type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a') + + # 修改字段名 + type_field = models.CharField(_('type'), max_length=10, choices=TYPE, default='a') appkey = models.CharField(max_length=200, verbose_name='AppKey') appsecret = models.CharField(max_length=200, verbose_name='AppSecret') callback_url = models.CharField( @@ -55,11 +60,11 @@ class OAuthConfig(models.Model): def clean(self): if OAuthConfig.objects.filter( - type=self.type).exclude(id=self.id).count(): - raise ValidationError(_(self.type + _('already exists'))) + type_field=self.type_field).exclude(id=self.id).count(): + raise ValidationError(_(self.type_field + _(' already exists'))) def __str__(self): - return self.type + return self.type_field class Meta: verbose_name = 'oauth配置' diff --git a/oauth/tests.py b/oauth/tests.py index bb23b9b..30d6395 100644 --- a/oauth/tests.py +++ b/oauth/tests.py @@ -11,7 +11,6 @@ from oauth.models import OAuthConfig from oauth.oauthmanager import BaseOauthManager -# Create your tests here. class OAuthConfigTest(TestCase): def setUp(self): self.client = Client() @@ -34,7 +33,7 @@ class OAuthConfigTest(TestCase): class OauthLoginTest(TestCase): - def setUp(self) -> None: + def setUp(self): self.client = Client() self.factory = RequestFactory() self.apps = self.init_apps() @@ -49,9 +48,9 @@ class OauthLoginTest(TestCase): c.save() return applications - def get_app_by_type(self, type): + def get_app_by_type(self, type_name): for app in self.apps: - if app.ICON_NAME.lower() == type: + if app.ICON_NAME.lower() == type_name: return app @patch("oauth.oauthmanager.WBOauthManager.do_post") @@ -59,10 +58,8 @@ class OauthLoginTest(TestCase): def test_weibo_login(self, mock_do_get, mock_do_post): weibo_app = self.get_app_by_type('weibo') assert weibo_app - url = weibo_app.get_authorization_url() - mock_do_post.return_value = json.dumps({"access_token": "access_token", - "uid": "uid" - }) + + mock_do_post.return_value = json.dumps({"access_token": "access_token", "uid": "uid"}) mock_do_get.return_value = json.dumps({ "avatar_large": "avatar_large", "screen_name": "screen_name", @@ -78,18 +75,15 @@ class OauthLoginTest(TestCase): def test_google_login(self, mock_do_get, mock_do_post): google_app = self.get_app_by_type('google') assert google_app - url = google_app.get_authorization_url() - mock_do_post.return_value = json.dumps({ - "access_token": "access_token", - "id_token": "id_token", - }) + + mock_do_post.return_value = json.dumps({"access_token": "access_token", "id_token": "id_token"}) mock_do_get.return_value = json.dumps({ "picture": "picture", "name": "name", "sub": "sub", "email": "email", }) - token = google_app.get_access_token_by_code('code') + google_app.get_access_token_by_code('code') userinfo = google_app.get_oauth_userinfo() self.assertEqual(userinfo.token, 'access_token') self.assertEqual(userinfo.openid, 'sub') @@ -99,9 +93,7 @@ class OauthLoginTest(TestCase): def test_github_login(self, mock_do_get, mock_do_post): github_app = self.get_app_by_type('github') assert github_app - url = github_app.get_authorization_url() - self.assertTrue("github.com" in url) - self.assertTrue("client_id" in url) + mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer" mock_do_get.return_value = json.dumps({ "avatar_url": "avatar_url", @@ -109,7 +101,7 @@ class OauthLoginTest(TestCase): "id": "id", "email": "email", }) - token = github_app.get_access_token_by_code('code') + github_app.get_access_token_by_code('code') userinfo = github_app.get_oauth_userinfo() self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a') self.assertEqual(userinfo.openid, 'id') @@ -119,57 +111,36 @@ class OauthLoginTest(TestCase): def test_facebook_login(self, mock_do_get, mock_do_post): facebook_app = self.get_app_by_type('facebook') assert facebook_app - url = facebook_app.get_authorization_url() - self.assertTrue("facebook.com" in url) - mock_do_post.return_value = json.dumps({ - "access_token": "access_token", - }) + + mock_do_post.return_value = json.dumps({"access_token": "access_token"}) mock_do_get.return_value = json.dumps({ "name": "name", "id": "id", "email": "email", - "picture": { - "data": { - "url": "url" - } - } + "picture": {"data": {"url": "url"}} }) - token = facebook_app.get_access_token_by_code('code') + facebook_app.get_access_token_by_code('code') userinfo = facebook_app.get_oauth_userinfo() self.assertEqual(userinfo.token, 'access_token') @patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[ 'access_token=access_token&expires_in=3600', 'callback({"client_id":"appid","openid":"openid"} );', - json.dumps({ - "nickname": "nickname", - "email": "email", - "figureurl": "figureurl", - "openid": "openid", - }) + json.dumps({"nickname": "nickname", "email": "email", "figureurl": "figureurl", "openid": "openid"}) ]) def test_qq_login(self, mock_do_get): qq_app = self.get_app_by_type('qq') assert qq_app - url = qq_app.get_authorization_url() - self.assertTrue("qq.com" in url) - token = qq_app.get_access_token_by_code('code') + + qq_app.get_access_token_by_code('code') userinfo = qq_app.get_oauth_userinfo() self.assertEqual(userinfo.token, 'access_token') @patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_get") def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post): - - mock_do_post.return_value = json.dumps({"access_token": "access_token", - "uid": "uid" - }) - mock_user_info = { - "avatar_large": "avatar_large", - "screen_name": "screen_name1", - "id": "id", - "email": "email", - } + mock_do_post.return_value = json.dumps({"access_token": "access_token", "uid": "uid"}) + mock_user_info = {"avatar_large": "avatar_large", "screen_name": "screen_name1", "id": "id", "email": "email"} mock_do_get.return_value = json.dumps(mock_user_info) response = self.client.get('/oauth/oauthlogin?type=weibo') @@ -181,34 +152,16 @@ class OauthLoginTest(TestCase): self.assertEqual(response.url, '/') user = auth.get_user(self.client) - assert user.is_authenticated self.assertTrue(user.is_authenticated) self.assertEqual(user.username, mock_user_info['screen_name']) self.assertEqual(user.email, mock_user_info['email']) self.client.logout() - response = self.client.get('/oauth/authorize?type=weibo&code=code') - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, '/') - - user = auth.get_user(self.client) - assert user.is_authenticated - self.assertTrue(user.is_authenticated) - self.assertEqual(user.username, mock_user_info['screen_name']) - self.assertEqual(user.email, mock_user_info['email']) - @patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_get") def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post): - - mock_do_post.return_value = json.dumps({"access_token": "access_token", - "uid": "uid" - }) - mock_user_info = { - "avatar_large": "avatar_large", - "screen_name": "screen_name1", - "id": "id", - } + mock_do_post.return_value = json.dumps({"access_token": "access_token", "uid": "uid"}) + mock_user_info = {"avatar_large": "avatar_large", "screen_name": "screen_name1", "id": "id"} mock_do_get.return_value = json.dumps(mock_user_info) response = self.client.get('/oauth/oauthlogin?type=weibo') @@ -216,30 +169,20 @@ class OauthLoginTest(TestCase): self.assertTrue("api.weibo.com" in response.url) response = self.client.get('/oauth/authorize?type=weibo&code=code') - self.assertEqual(response.status_code, 302) oauth_user_id = int(response.url.split('/')[-1].split('.')[0]) self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html') response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id}) - self.assertEqual(response.status_code, 302) - sign = get_sha256(settings.SECRET_KEY + - str(oauth_user_id) + settings.SECRET_KEY) - url = reverse('oauth:bindsuccess', kwargs={ - 'oauthid': oauth_user_id, - }) - self.assertEqual(response.url, f'{url}?type=email') - - path = reverse('oauth:email_confirm', kwargs={ - 'id': oauth_user_id, - 'sign': sign - }) + sign = get_sha256(settings.SECRET_KEY + str(oauth_user_id) + settings.SECRET_KEY) + path = reverse('oauth:email_confirm', kwargs={'id': oauth_user_id, 'sign': sign}) response = self.client.get(path) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success') + user = auth.get_user(self.client) from oauth.models import OAuthUser oauth_user = OAuthUser.objects.get(author=user) diff --git a/oauth/views.py b/oauth/views.py index 12e3a6e..87bea99 100644 --- a/oauth/views.py +++ b/oauth/views.py @@ -1,24 +1,19 @@ import logging -# Create your views here. from urllib.parse import urlparse from django.conf import settings -from django.contrib.auth import get_user_model -from django.contrib.auth import login +from django.contrib.auth import get_user_model, login from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.http import HttpResponseForbidden -from django.http import HttpResponseRedirect -from django.shortcuts import get_object_or_404 -from django.shortcuts import render +from django.http import HttpResponseForbidden, HttpResponseRedirect +from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView from djangoblog.blog_signals import oauth_user_login_signal -from djangoblog.utils import get_current_site -from djangoblog.utils import send_email, get_sha256 +from djangoblog.utils import get_current_site, send_email, get_sha256 from oauth.forms import RequireEmailForm from .models import OAuthUser from .oauthmanager import get_manager_by_type, OAuthAccessTokenException @@ -28,35 +23,34 @@ logger = logging.getLogger(__name__) def get_redirecturl(request): nexturl = request.GET.get('next_url', None) - if not nexturl or nexturl == '/login/' or nexturl == '/login': - nexturl = '/' - return nexturl - p = urlparse(nexturl) - if p.netloc: + if not nexturl or nexturl in ['/login/', '/login']: + return '/' + parsed_url = urlparse(nexturl) + if parsed_url.netloc: site = get_current_site().domain - if not p.netloc.replace('www.', '') == site.replace('www.', ''): + if parsed_url.netloc.replace('www.', '') != site.replace('www.', ''): logger.info('非法url:' + nexturl) return "/" return nexturl def oauthlogin(request): - type = request.GET.get('type', None) - if not type: + oauth_type = request.GET.get('type', None) + if not oauth_type: return HttpResponseRedirect('/') - manager = get_manager_by_type(type) + manager = get_manager_by_type(oauth_type) if not manager: return HttpResponseRedirect('/') nexturl = get_redirecturl(request) - authorizeurl = manager.get_authorization_url(nexturl) - return HttpResponseRedirect(authorizeurl) + authorize_url = manager.get_authorization_url(nexturl) + return HttpResponseRedirect(authorize_url) def authorize(request): - type = request.GET.get('type', None) - if not type: + oauth_type = request.GET.get('type', None) + if not oauth_type: return HttpResponseRedirect('/') - manager = get_manager_by_type(type) + manager = get_manager_by_type(oauth_type) if not manager: return HttpResponseRedirect('/') code = request.GET.get('code', None) @@ -71,83 +65,73 @@ def authorize(request): nexturl = get_redirecturl(request) if not rsp: return HttpResponseRedirect(manager.get_authorization_url(nexturl)) + user = manager.get_oauth_userinfo() - if user: - if not user.nickname or not user.nickname.strip(): - user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') - try: - temp = OAuthUser.objects.get(type=type, openid=user.openid) - temp.picture = user.picture - temp.metadata = user.metadata - temp.nickname = user.nickname - user = temp - except ObjectDoesNotExist: - pass - # facebook的token过长 - if type == 'facebook': - user.token = '' - if user.email: - with transaction.atomic(): - author = None - try: - author = get_user_model().objects.get(id=user.author_id) - except ObjectDoesNotExist: - pass - if not author: - result = get_user_model().objects.get_or_create(email=user.email) - author = result[0] - if result[1]: - try: - get_user_model().objects.get(username=user.nickname) - except ObjectDoesNotExist: - author.username = user.nickname - else: - author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') - author.source = 'authorize' - author.save() - - user.author = author - user.save() - - oauth_user_login_signal.send( - sender=authorize.__class__, id=user.id) - login(request, author) - return HttpResponseRedirect(nexturl) - else: - user.save() - url = reverse('oauth:require_email', kwargs={ - 'oauthid': user.id - }) + if not user: + return HttpResponseRedirect(nexturl) + + if not user.nickname or not user.nickname.strip(): + user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') - return HttpResponseRedirect(url) + try: + temp = OAuthUser.objects.get(type=oauth_type, openid=user.openid) + temp.picture = user.picture + temp.metadata = user.metadata + temp.nickname = user.nickname + user = temp + except ObjectDoesNotExist: + pass + + if oauth_type == 'facebook': + user.token = '' + + if user.email: + with transaction.atomic(): + author = None + try: + author = get_user_model().objects.get(id=user.author_id) + except ObjectDoesNotExist: + pass + if not author: + author, created = get_user_model().objects.get_or_create(email=user.email) + if created: + try: + get_user_model().objects.get(username=user.nickname) + author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') + except ObjectDoesNotExist: + author.username = user.nickname + author.source = 'authorize' + author.save() + user.author = author + user.save() + oauth_user_login_signal.send(sender=authorize.__class__, id=user.id) + login(request, author) + return HttpResponseRedirect(nexturl) else: - return HttpResponseRedirect(nexturl) + user.save() + redirect_url = reverse('oauth:require_email', kwargs={'oauthid': user.id}) + return HttpResponseRedirect(redirect_url) def emailconfirm(request, id, sign): if not sign: return HttpResponseForbidden() - if not get_sha256(settings.SECRET_KEY + - str(id) + - settings.SECRET_KEY).upper() == sign.upper(): + if get_sha256(settings.SECRET_KEY + str(id) + settings.SECRET_KEY).upper() != sign.upper(): return HttpResponseForbidden() oauthuser = get_object_or_404(OAuthUser, pk=id) with transaction.atomic(): if oauthuser.author: author = get_user_model().objects.get(pk=oauthuser.author_id) else: - result = get_user_model().objects.get_or_create(email=oauthuser.email) - author = result[0] - if result[1]: + author, created = get_user_model().objects.get_or_create(email=oauthuser.email) + if created: author.source = 'emailconfirm' - author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip( - ) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') + author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip() else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') author.save() oauthuser.author = author oauthuser.save() - oauth_user_login_signal.send( - sender=emailconfirm.__class__, - id=oauthuser.id) + + oauth_user_login_signal.send(sender=emailconfirm.__class__, id=oauthuser.id) login(request, author) site = 'http://' + get_current_site().domain @@ -162,40 +146,23 @@ def emailconfirm(request, id, sign): %(site)s ''') % {'oauthuser_type': oauthuser.type, 'site': site} - send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content) - url = reverse('oauth:bindsuccess', kwargs={ - 'oauthid': id - }) - url = url + '?type=success' - return HttpResponseRedirect(url) + send_email(emailto=[oauthuser.email], title=_('Congratulations on your successful binding!'), content=content) + redirect_url = reverse('oauth:bindsuccess', kwargs={'oauthid': id}) + '?type=success' + return HttpResponseRedirect(redirect_url) class RequireEmailView(FormView): form_class = RequireEmailForm template_name = 'oauth/require_email.html' - def get(self, request, *args, **kwargs): - oauthid = self.kwargs['oauthid'] - oauthuser = get_object_or_404(OAuthUser, pk=oauthid) - if oauthuser.email: - pass - # return HttpResponseRedirect('/') - - return super(RequireEmailView, self).get(request, *args, **kwargs) - def get_initial(self): - oauthid = self.kwargs['oauthid'] - return { - 'email': '', - 'oauthid': oauthid - } + return {'email': '', 'oauthid': self.kwargs['oauthid']} def get_context_data(self, **kwargs): - oauthid = self.kwargs['oauthid'] - oauthuser = get_object_or_404(OAuthUser, pk=oauthid) + oauthuser = get_object_or_404(OAuthUser, pk=self.kwargs['oauthid']) if oauthuser.picture: kwargs['picture'] = oauthuser.picture - return super(RequireEmailView, self).get_context_data(**kwargs) + return super().get_context_data(**kwargs) def form_valid(self, form): email = form.cleaned_data['email'] @@ -203,51 +170,36 @@ class RequireEmailView(FormView): oauthuser = get_object_or_404(OAuthUser, pk=oauthid) oauthuser.email = email oauthuser.save() - sign = get_sha256(settings.SECRET_KEY + - str(oauthuser.id) + settings.SECRET_KEY) + + sign = get_sha256(settings.SECRET_KEY + str(oauthuser.id) + settings.SECRET_KEY) site = get_current_site().domain if settings.DEBUG: site = '127.0.0.1:8000' - path = reverse('oauth:email_confirm', kwargs={ - 'id': oauthid, - 'sign': sign - }) - url = "http://{site}{path}".format(site=site, path=path) + path = reverse('oauth:email_confirm', kwargs={'id': oauthid, 'sign': sign}) + url = f"http://{site}{path}" content = _("""

Please click the link below to bind your email

- %(url)s - Thank you again!
If the link above cannot be opened, please copy this link to your browser. -
+
%(url)s - """) % {'url': url} - send_email(emailto=[email, ], title=_('Bind your email'), content=content) - url = reverse('oauth:bindsuccess', kwargs={ - 'oauthid': oauthid - }) - url = url + '?type=email' - return HttpResponseRedirect(url) + """) % {'url': url} + send_email(emailto=[email], title=_('Bind your email'), content=content) + + redirect_url = reverse('oauth:bindsuccess', kwargs={'oauthid': oauthid}) + '?type=email' + return HttpResponseRedirect(redirect_url) def bindsuccess(request, oauthid): - type = request.GET.get('type', None) + oauth_type = request.GET.get('type', None) oauthuser = get_object_or_404(OAuthUser, pk=oauthid) - if type == 'email': + if oauth_type == 'email': title = _('Bind your email') - content = _( - 'Congratulations, the binding is just one step away. ' - 'Please log in to your email to check the email to complete the binding. Thank you.') + content = _('Congratulations, the binding is just one step away. Please log in to your email to check the email to complete the binding. Thank you.') else: title = _('Binding successful') - content = _( - "Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s" - " to directly log in to this website without a password. You are welcome to continue to follow this site." % { - 'oauthuser_type': oauthuser.type}) - return render(request, 'oauth/bindsuccess.html', { - 'title': title, - 'content': content - }) + content = _("Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s to directly log in to this website without a password. You are welcome to continue to follow this site." % {'oauthuser_type': oauthuser.type}) + return render(request, 'oauth/bindsuccess.html', {'title': title, 'content': content}) diff --git a/owntracks/admin.py b/owntracks/admin.py index 655b535..5b2839b 100644 --- a/owntracks/admin.py +++ b/owntracks/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin + # Register your models here. diff --git a/owntracks/migrations/0001_initial.py b/owntracks/migrations/0001_initial.py index 9eee55c..0273c11 100644 --- a/owntracks/migrations/0001_initial.py +++ b/owntracks/migrations/0001_initial.py @@ -1,11 +1,10 @@ # Generated by Django 4.1.7 on 2023-03-02 07:14 -from django.db import migrations, models import django.utils.timezone +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/owntracks/migrations/0002_alter_owntracklog_options_and_more.py b/owntracks/migrations/0002_alter_owntracklog_options_and_more.py index b4f8dec..7d37f95 100644 --- a/owntracks/migrations/0002_alter_owntracklog_options_and_more.py +++ b/owntracks/migrations/0002_alter_owntracklog_options_and_more.py @@ -4,7 +4,6 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ ('owntracks', '0001_initial'), ] @@ -12,7 +11,8 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='owntracklog', - options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'}, + options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', + 'verbose_name_plural': 'OwnTrackLogs'}, ), migrations.RenameField( model_name='owntracklog', diff --git a/plugins/article_copyright/plugin.py b/plugins/article_copyright/plugin.py index 2f6dbae..ab04d54 100644 --- a/plugins/article_copyright/plugin.py +++ b/plugins/article_copyright/plugin.py @@ -1,17 +1,15 @@ # 1. 导入所需的基础类和模块 -# 从插件管理模块导入基类 BasePlugin -# 所有自定义插件都应该继承这个基类,它提供了插件的基本结构和生命周期管理 -from djangoblog.plugin_manage.base_plugin import BasePlugin - # 导入钩子管理模块 # 这个模块提供了注册和触发钩子的功能 from djangoblog.plugin_manage import hooks - +# 从插件管理模块导入基类 BasePlugin +# 所有自定义插件都应该继承这个基类,它提供了插件的基本结构和生命周期管理 +from djangoblog.plugin_manage.base_plugin import BasePlugin # 从常量定义文件导入文章内容钩子的名称 # 使用常量可以避免硬编码字符串,增加代码的可读性和可维护性 # ARTICLE_CONTENT_HOOK_NAME 的值很可能就是 'article_content' 或类似的字符串 -from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME +from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME # 2. 定义插件主类 @@ -19,10 +17,10 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME # 定义一个名为 ArticleCopyrightPlugin 的类,并让它继承自 BasePlugin class ArticleCopyrightPlugin(BasePlugin): # 定义插件的元数据,这些信息通常会在后台管理界面显示 - PLUGIN_NAME = '文章结尾版权声明' # 插件的显示名称 - PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。' # 插件的详细描述 - PLUGIN_VERSION = '0.2.0' # 插件的版本号 - PLUGIN_AUTHOR = 'liangliangyy' # 插件的作者 + PLUGIN_NAME = '文章结尾版权声明' # 插件的显示名称 + PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。' # 插件的详细描述 + PLUGIN_VERSION = '0.2.0' # 插件的版本号 + PLUGIN_AUTHOR = 'liangliangyy' # 插件的作者 # 3. 实现钩子注册方法 (关键步骤) def register_hooks(self): @@ -72,4 +70,4 @@ class ArticleCopyrightPlugin(BasePlugin): # 2. 这个实例化过程会调用父类 BasePlugin 的 __init__ 构造方法。 # 3. 在 BasePlugin 的 __init__ 方法中,会自动调用我们上面定义的 register_hooks() 方法。 # 4. 这样,插件就完成了自身的注册,静静地等待着文章内容钩子被触发。 -plugin = ArticleCopyrightPlugin() \ No newline at end of file +plugin = ArticleCopyrightPlugin() diff --git a/plugins/external_links/plugin.py b/plugins/external_links/plugin.py index 1b0a056..9a5f7a2 100644 --- a/plugins/external_links/plugin.py +++ b/plugins/external_links/plugin.py @@ -2,10 +2,11 @@ import re # 导入URL解析模块,用于解析链接的域名等信息 from urllib.parse import urlparse -# 导入Django博客系统的插件基类,当前插件需继承此类实现标准化功能 -from djangoblog.plugin_manage.base_plugin import BasePlugin + # 导入插件钩子管理模块,用于注册和触发插件功能 from djangoblog.plugin_manage import hooks +# 导入Django博客系统的插件基类,当前插件需继承此类实现标准化功能 +from djangoblog.plugin_manage.base_plugin import BasePlugin # 导入文章内容钩子常量,指定插件要作用的具体钩子位置 from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME @@ -73,4 +74,4 @@ class ExternalLinksPlugin(BasePlugin): # 实例化插件类,使插件系统能识别并加载该插件 -plugin = ExternalLinksPlugin() \ No newline at end of file +plugin = ExternalLinksPlugin() diff --git a/plugins/reading_time/plugin.py b/plugins/reading_time/plugin.py index 6cf9a7f..6a2a739 100644 --- a/plugins/reading_time/plugin.py +++ b/plugins/reading_time/plugin.py @@ -2,10 +2,11 @@ import math # 导入正则表达式模块,用于处理HTML内容和文本分词 import re -# 导入Django博客插件基类,当前插件需继承此类 -from djangoblog.plugin_manage.base_plugin import BasePlugin + # 导入插件钩子管理模块,用于注册插件功能 from djangoblog.plugin_manage import hooks +# 导入Django博客插件基类,当前插件需继承此类 +from djangoblog.plugin_manage.base_plugin import BasePlugin # 导入文章内容钩子常量,指定插件作用的位置 from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME @@ -66,4 +67,4 @@ class ReadingTimePlugin(BasePlugin): # 实例化插件,使插件系统能够识别并加载 -plugin = ReadingTimePlugin() \ No newline at end of file +plugin = ReadingTimePlugin() diff --git a/plugins/seo_optimizer/plugin.py b/plugins/seo_optimizer/plugin.py index 9a4b6cd..817104f 100644 --- a/plugins/seo_optimizer/plugin.py +++ b/plugins/seo_optimizer/plugin.py @@ -1,15 +1,16 @@ # 导入JSON模块,用于将结构化数据转换为JSON格式 import json + # 导入Django工具函数,用于移除HTML标签(提取纯文本) from django.utils.html import strip_tags + +# 导入博客数据模型,用于获取文章、分类、标签等数据 +from blog.models import Article, Category +# 导入插件钩子管理模块,用于注册插件功能到指定钩子 +from djangoblog.plugin_manage import hooks # 导入Django模板过滤器(当前未使用,预留用于文本截断) -from django.template.defaultfilters import truncatewords # 导入插件基类,所有插件需继承此类实现标准化接口 from djangoblog.plugin_manage.base_plugin import BasePlugin -# 导入插件钩子管理模块,用于注册插件功能到指定钩子 -from djangoblog.plugin_manage import hooks -# 导入博客数据模型,用于获取文章、分类、标签等数据 -from blog.models import Article, Category, Tag # 导入工具函数,用于获取博客站点的基础配置(如站点名称、关键词等) from djangoblog.utils import get_blog_setting @@ -222,4 +223,4 @@ class SeoOptimizerPlugin(BasePlugin): # 实例化插件,使插件系统能够识别并加载该插件 -plugin = SeoOptimizerPlugin() \ No newline at end of file +plugin = SeoOptimizerPlugin() diff --git a/plugins/view_count/__init__.py b/plugins/view_count/__init__.py index 8804fdf..e88afca 100644 --- a/plugins/view_count/__init__.py +++ b/plugins/view_count/__init__.py @@ -1 +1 @@ -# This file makes this a Python package \ No newline at end of file +# This file makes this a Python package diff --git a/plugins/view_count/plugin.py b/plugins/view_count/plugin.py index 3e8528e..4865ebe 100644 --- a/plugins/view_count/plugin.py +++ b/plugins/view_count/plugin.py @@ -1,7 +1,7 @@ # 导入Django博客系统的插件基类,所有自定义插件需继承此类以实现标准化接口 -from djangoblog.plugin_manage.base_plugin import BasePlugin # 导入插件钩子管理模块,用于将插件功能绑定到系统预设的钩子点 from djangoblog.plugin_manage import hooks +from djangoblog.plugin_manage.base_plugin import BasePlugin # 定义文章浏览次数统计插件类,继承自插件基类BasePlugin @@ -41,4 +41,4 @@ class ViewCountPlugin(BasePlugin): # 实例化插件类: # 插件系统会扫描并加载该实例,使上述注册的钩子和功能生效 -plugin = ViewCountPlugin() \ No newline at end of file +plugin = ViewCountPlugin() diff --git a/servermanager/admin.py b/servermanager/admin.py index f26f4f6..52c44ab 100644 --- a/servermanager/admin.py +++ b/servermanager/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin + + # Register your models here. diff --git a/servermanager/api/blogapi.py b/servermanager/api/blogapi.py index fcf72eb..5805660 100644 --- a/servermanager/api/blogapi.py +++ b/servermanager/api/blogapi.py @@ -5,24 +5,24 @@ from blog.models import Article, Category class BlogApi: def __init__(self): - self.searchqueryset = SearchQuerySet() # 初始化搜索查询集,用于处理文章搜索功能 - self.searchqueryset.auto_query('') # 执行空查询,初始化搜索结果集(可能用于后续叠加过滤条件) - self.__max_takecount__ = 8 # 定义私有变量,限制各类查询的最大返回数量为8条 + self.searchqueryset = SearchQuerySet() # 初始化搜索查询集,用于处理文章搜索功能 + self.searchqueryset.auto_query('') # 执行空查询,初始化搜索结果集(可能用于后续叠加过滤条件) + self.__max_takecount__ = 8 # 定义私有变量,限制各类查询的最大返回数量为8条 def search_articles(self, query): - sqs = self.searchqueryset.auto_query(query) # 使用搜索查询集执行自动查询(可能包含分词、过滤等处理) - sqs = sqs.load_all() # 预加载所有关联数据,减少数据库查询次数(优化性能) - return sqs[:self.__max_takecount__] # 限制返回结果数量,返回前N条匹配的文章 + sqs = self.searchqueryset.auto_query(query) # 使用搜索查询集执行自动查询(可能包含分词、过滤等处理) + sqs = sqs.load_all() # 预加载所有关联数据,减少数据库查询次数(优化性能) + return sqs[:self.__max_takecount__] # 限制返回结果数量,返回前N条匹配的文章 def get_category_lists(self): - return Category.objects.all() # 返回所有分类对象(未限制数量,通常分类数量较少) + return Category.objects.all() # 返回所有分类对象(未限制数量,通常分类数量较少) def get_category_articles(self, categoryname): - articles = Article.objects.filter(category__name=categoryname) # 过滤出指定分类下的所有文章(通过外键关联查询) + articles = Article.objects.filter(category__name=categoryname) # 过滤出指定分类下的所有文章(通过外键关联查询) if articles: return articles[:self.__max_takecount__] - return None # 若存在符合条件的文章,返回前N条;否则返回None + return None # 若存在符合条件的文章,返回前N条;否则返回None def get_recent_articles(self): return Article.objects.all()[:self.__max_takecount__] - # 返回所有文章的前N条(依赖于Article模型的默认排序设置) \ No newline at end of file + # 返回所有文章的前N条(依赖于Article模型的默认排序设置) diff --git a/servermanager/api/commonapi.py b/servermanager/api/commonapi.py index 5897095..5d43979 100644 --- a/servermanager/api/commonapi.py +++ b/servermanager/api/commonapi.py @@ -1,14 +1,14 @@ -import logging # 导入日志模块,用于记录程序运行过程中的日志信息 +import logging # 导入日志模块,用于记录程序运行过程中的日志信息 import os # 导入os模块,用于与操作系统交互,如获取环境变量、执行系统命令等 -import openai # 导入openai模块,用于调用OpenAI的API服务 +import openai # 导入openai模块,用于调用OpenAI的API服务 -from servermanager.models import commands # 从servermanager应用的models模块中导入commands模型,用于操作命令相关的数据 +from servermanager.models import commands # 从servermanager应用的models模块中导入commands模型,用于操作命令相关的数据 -logger = logging.getLogger(__name__) # 创建日志记录器,名称为当前模块名,用于记录该模块的日志 +logger = logging.getLogger(__name__) # 创建日志记录器,名称为当前模块名,用于记录该模块的日志 -openai.api_key = os.environ.get('OPENAI_API_KEY') # 从环境变量中获取OpenAI的API密钥,并设置为openai模块的API密钥 -if os.environ.get('HTTP_PROXY'): # 检查环境变量中是否设置了HTTP代理,如果有则为openai模块设置代理 +openai.api_key = os.environ.get('OPENAI_API_KEY') # 从环境变量中获取OpenAI的API密钥,并设置为openai模块的API密钥 +if os.environ.get('HTTP_PROXY'): # 检查环境变量中是否设置了HTTP代理,如果有则为openai模块设置代理 openai.proxy = os.environ.get('HTTP_PROXY') @@ -16,6 +16,7 @@ class ChatGPT: """ ChatGPT类,用于与OpenAI的GPT模型进行交互,实现聊天功能 """ + @staticmethod def chat(prompt): """ @@ -42,6 +43,7 @@ class CommandHandler: """ 命令处理器类,用于处理和执行系统命令,以及提供命令帮助信息 """ + def __init__(self): """ 初始化方法,加载所有的命令数据 @@ -96,6 +98,7 @@ class CommandHandler: rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe) return rsp + # 当该模块作为主程序运行时执行以下代码 if __name__ == '__main__': chatbot = ChatGPT() diff --git a/servermanager/migrations/0001_initial.py b/servermanager/migrations/0001_initial.py index ee50cac..c661624 100644 --- a/servermanager/migrations/0001_initial.py +++ b/servermanager/migrations/0001_initial.py @@ -2,6 +2,8 @@ # 说明:此文件由Django 4.1.7版本自动生成,生成时间为2023年3月2日7:14 # 迁移文件用于记录数据库模型的创建和修改,通过Django的migrate命令同步到数据库 from django.db import migrations, models + + # 导入Django迁移模块和模型字段模块 class Migration(migrations.Migration): @@ -9,13 +11,13 @@ class Migration(migrations.Migration): initial = True # 标记为初始迁移(第一次创建模型时生成) dependencies = [ - ] # 依赖的其他迁移文件列表,初始迁移无依赖,所以为空 - # 若后续迁移依赖其他应用的迁移,会在此处列出,如:['appname.0001_initial'] + ] # 依赖的其他迁移文件列表,初始迁移无依赖,所以为空 + # 若后续迁移依赖其他应用的迁移,会在此处列出,如:['appname.0001_initial'] operations = [ # 迁移操作列表,包含模型的创建、修改等操作 migrations.CreateModel( # 创建名为"commands"的模型(对应数据库表) name='commands', - fields=[ # 定义模型的字段(对应数据库表的列) + fields=[ # 定义模型的字段(对应数据库表的列) ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=300, verbose_name='命令标题')), ('command', models.CharField(max_length=2000, verbose_name='命令')), @@ -23,10 +25,10 @@ class Migration(migrations.Migration): ('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), ('last_mod_time', models.DateTimeField(auto_now=True, verbose_name='修改时间')), ], - options={ # 模型的额外配置 + options={ # 模型的额外配置 'verbose_name': '命令', # 模型单数显示名称(后台管理用) - 'verbose_name_plural': '命令', # 模型复数显示名称(后台管理用) - }, # 若未指定ordering,默认按主键id排序 + 'verbose_name_plural': '命令', # 模型复数显示名称(后台管理用) + }, # 若未指定ordering,默认按主键id排序 ), migrations.CreateModel( # 创建名为"EmailSendLog"的模型(邮件发送日志) name='EmailSendLog', @@ -41,7 +43,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': '邮件发送log', 'verbose_name_plural': '邮件发送log', - 'ordering': ['-created_time'], # 按创建时间倒序排列(最新的日志在前) + 'ordering': ['-created_time'], # 按创建时间倒序排列(最新的日志在前) }, ), ] diff --git a/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py b/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py index 54619d9..a5e1a32 100644 --- a/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py +++ b/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py @@ -2,11 +2,13 @@ # 说明:此文件由Django 4.2.5版本自动生成,生成时间为2023年9月6日13:19 # 作用:记录数据库模型的修改操作(字段重命名、配置调整等),用于同步数据库结构变更 from django.db import migrations + + # 导入Django迁移模块 class Migration(migrations.Migration): # 迁移类,所有数据库变更操作在此定义 - dependencies = [ # 依赖的前置迁移文件:表示必须先执行'servermanager'应用的'0001_initial'迁移 + dependencies = [ # 依赖的前置迁移文件:表示必须先执行'servermanager'应用的'0001_initial'迁移 # 才能执行当前迁移(确保修改的是已存在的模型) ('servermanager', '0001_initial'), ] @@ -14,14 +16,15 @@ class Migration(migrations.Migration): operations = [ # 迁移操作列表:包含对模型的修改操作 migrations.AlterModelOptions( # 修改'EmailSendLog'模型的元配置 name='emailsendlog', - options={'ordering': ['-creation_time'], 'verbose_name': '邮件发送log', 'verbose_name_plural': '邮件发送log'}, - ), # 1. 排序方式变更:按'creation_time'字段倒序排列(最新记录在前) - # (原配置可能是按其他字段排序,此处同步字段名变更后的排序) + options={'ordering': ['-creation_time'], 'verbose_name': '邮件发送log', + 'verbose_name_plural': '邮件发送log'}, + ), # 1. 排序方式变更:按'creation_time'字段倒序排列(最新记录在前) + # (原配置可能是按其他字段排序,此处同步字段名变更后的排序) # 2. 模型显示名称(单数和复数)保持不变 - migrations.RenameField( # 重命名'commands'模型的字段 + migrations.RenameField( # 重命名'commands'模型的字段 model_name='commands', - old_name='created_time', # 原字段名:创建时间 - new_name='creation_time', # 新字段名:创建时间(更简洁的命名) + old_name='created_time', # 原字段名:创建时间 + new_name='creation_time', # 新字段名:创建时间(更简洁的命名) ), migrations.RenameField( # 重命名'commands'模型的另一个字段 model_name='commands', @@ -30,7 +33,7 @@ class Migration(migrations.Migration): ), migrations.RenameField( # 重命名'commands'模型的另一个字段 model_name='emailsendlog', - old_name='created_time', # 原字段名:创建时间 + old_name='created_time', # 原字段名:创建时间 new_name='creation_time', # 新字段名:创建时间(与commands模型保持命名一致) ), ] diff --git a/servermanager/tests.py b/servermanager/tests.py index 22a6689..abbe0a2 100644 --- a/servermanager/tests.py +++ b/servermanager/tests.py @@ -1,5 +1,4 @@ from django.test import Client, RequestFactory, TestCase -from django.utils import timezone from werobot.messages.messages import TextMessage from accounts.models import BlogUser diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..676bbd4 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,11 @@ +sonar.projectKey=DjangoBlog +sonar.projectName=DjangoBlog +sonar.projectVersion=1.0 +sonar.sources=. +sonar.language=py +sonar.sourceEncoding=UTF-8 +sonar.python.coverage.reportPaths=coverage.xml + +# ?? SonarQube ?? +sonar.host.url=http://localhost:9000 + diff --git a/templates/blog/tags/sidebar.html b/templates/blog/tags/sidebar.html index f70544c..27d1812 100644 --- a/templates/blog/tags/sidebar.html +++ b/templates/blog/tags/sidebar.html @@ -4,15 +4,15 @@ + {% if extra_sidebars %} {% for sidebar in extra_sidebars %} - {% endfor %} {% endif %} - {% if most_read_articles %} + {% if most_read_articles %} {% endif %} + {% if sidebar_categorys %} {% endif %} - {% if sidebar_comments and open_site_comment %} -