From b4dd4c2c923dcbfa071c502aa1bc70e96064b5d3 Mon Sep 17 00:00:00 2001 From: zyl <3116318851@qq.com> Date: Thu, 27 Nov 2025 23:44:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=A5=E6=AC=A3=E6=80=A1=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/blog/blog/__init__.py | 0 .../blog/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 189 bytes .../blog/__pycache__/admin.cpython-312.pyc | Bin 0 -> 6760 bytes .../blog/__pycache__/apps.cpython-312.pyc | Bin 0 -> 701 bytes .../context_processors.cpython-312.pyc | Bin 0 -> 3189 bytes .../__pycache__/documents.cpython-312.pyc | Bin 0 -> 10964 bytes .../__pycache__/middleware.cpython-312.pyc | Bin 0 -> 2443 bytes .../blog/__pycache__/models.cpython-312.pyc | Bin 0 -> 20367 bytes .../blog/__pycache__/urls.cpython-312.pyc | Bin 0 -> 2325 bytes .../blog/__pycache__/views.cpython-312.pyc | Bin 0 -> 19247 bytes src/blog/blog/admin.py | 189 +++++++ src/blog/blog/apps.py | 15 + src/blog/blog/context_processors.py | 73 +++ src/blog/blog/documents.py | 267 ++++++++++ src/blog/blog/forms.py | 41 ++ src/blog/blog/management/__init__.py | 0 src/blog/blog/management/commands/__init__.py | 0 .../blog/management/commands/build_index.py | 45 ++ .../management/commands/build_search_words.py | 32 ++ .../blog/management/commands/clear_cache.py | 26 + .../management/commands/create_testdata.py | 76 +++ .../blog/management/commands/ping_baidu.py | 88 ++++ .../management/commands/sync_user_avatar.py | 86 +++ src/blog/blog/middleware.py | 90 ++++ src/blog/blog/migrations/0001_initial.py | 202 +++++++ ...002_blogsettings_global_footer_and_more.py | 45 ++ .../0003_blogsettings_comment_need_review.py | 31 ++ ...de_blogsettings_analytics_code_and_more.py | 40 ++ ...options_alter_category_options_and_more.py | 107 ++++ .../0006_alter_blogsettings_options.py | 30 ++ src/blog/blog/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-312.pyc | Bin 0 -> 9174 bytes ...ngs_global_footer_and_more.cpython-312.pyc | Bin 0 -> 1007 bytes ...ttings_comment_need_review.cpython-312.pyc | Bin 0 -> 869 bytes ...gs_analytics_code_and_more.cpython-312.pyc | Bin 0 -> 946 bytes ..._category_options_and_more.cpython-312.pyc | Bin 0 -> 11005 bytes ...alter_blogsettings_options.cpython-312.pyc | Bin 0 -> 786 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 200 bytes src/blog/blog/models.py | 415 +++++++++++++++ src/blog/blog/search_indexes.py | 32 ++ src/blog/blog/templatetags/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 202 bytes .../__pycache__/blog_tags.cpython-312.pyc | Bin 0 -> 14753 bytes src/blog/blog/templatetags/blog_tags.py | 408 ++++++++++++++ src/blog/blog/tests.py | 331 ++++++++++++ src/blog/blog/urls.py | 98 ++++ src/blog/blog/views.py | 498 ++++++++++++++++++ 47 files changed, 3265 insertions(+) create mode 100644 src/blog/blog/__init__.py create mode 100644 src/blog/blog/__pycache__/__init__.cpython-312.pyc create mode 100644 src/blog/blog/__pycache__/admin.cpython-312.pyc create mode 100644 src/blog/blog/__pycache__/apps.cpython-312.pyc create mode 100644 src/blog/blog/__pycache__/context_processors.cpython-312.pyc create mode 100644 src/blog/blog/__pycache__/documents.cpython-312.pyc create mode 100644 src/blog/blog/__pycache__/middleware.cpython-312.pyc create mode 100644 src/blog/blog/__pycache__/models.cpython-312.pyc create mode 100644 src/blog/blog/__pycache__/urls.cpython-312.pyc create mode 100644 src/blog/blog/__pycache__/views.cpython-312.pyc create mode 100644 src/blog/blog/admin.py create mode 100644 src/blog/blog/apps.py create mode 100644 src/blog/blog/context_processors.py create mode 100644 src/blog/blog/documents.py create mode 100644 src/blog/blog/forms.py create mode 100644 src/blog/blog/management/__init__.py create mode 100644 src/blog/blog/management/commands/__init__.py create mode 100644 src/blog/blog/management/commands/build_index.py create mode 100644 src/blog/blog/management/commands/build_search_words.py create mode 100644 src/blog/blog/management/commands/clear_cache.py create mode 100644 src/blog/blog/management/commands/create_testdata.py create mode 100644 src/blog/blog/management/commands/ping_baidu.py create mode 100644 src/blog/blog/management/commands/sync_user_avatar.py create mode 100644 src/blog/blog/middleware.py create mode 100644 src/blog/blog/migrations/0001_initial.py create mode 100644 src/blog/blog/migrations/0002_blogsettings_global_footer_and_more.py create mode 100644 src/blog/blog/migrations/0003_blogsettings_comment_need_review.py create mode 100644 src/blog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py create mode 100644 src/blog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py create mode 100644 src/blog/blog/migrations/0006_alter_blogsettings_options.py create mode 100644 src/blog/blog/migrations/__init__.py create mode 100644 src/blog/blog/migrations/__pycache__/0001_initial.cpython-312.pyc create mode 100644 src/blog/blog/migrations/__pycache__/0002_blogsettings_global_footer_and_more.cpython-312.pyc create mode 100644 src/blog/blog/migrations/__pycache__/0003_blogsettings_comment_need_review.cpython-312.pyc create mode 100644 src/blog/blog/migrations/__pycache__/0004_rename_analyticscode_blogsettings_analytics_code_and_more.cpython-312.pyc create mode 100644 src/blog/blog/migrations/__pycache__/0005_alter_article_options_alter_category_options_and_more.cpython-312.pyc create mode 100644 src/blog/blog/migrations/__pycache__/0006_alter_blogsettings_options.cpython-312.pyc create mode 100644 src/blog/blog/migrations/__pycache__/__init__.cpython-312.pyc create mode 100644 src/blog/blog/models.py create mode 100644 src/blog/blog/search_indexes.py create mode 100644 src/blog/blog/templatetags/__init__.py create mode 100644 src/blog/blog/templatetags/__pycache__/__init__.cpython-312.pyc create mode 100644 src/blog/blog/templatetags/__pycache__/blog_tags.cpython-312.pyc create mode 100644 src/blog/blog/templatetags/blog_tags.py create mode 100644 src/blog/blog/tests.py create mode 100644 src/blog/blog/urls.py create mode 100644 src/blog/blog/views.py diff --git a/src/blog/blog/__init__.py b/src/blog/blog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blog/blog/__pycache__/__init__.cpython-312.pyc b/src/blog/blog/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75eab84f32defd9ed75c6822ef782bb1981d4468 GIT binary patch literal 189 zcmX@j%ge<81S<31XMpI(AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxd72{$R2 cKczG$)vkyYXc;3A7lRldnHd=wisFa*f|{*lIHZ0;8`@k!Q9X9B^QWb&ia_x4V*t(ajt zlN;UB-oCfHZ})xgzrEga0?#w2zuu8pK*(44QFt6yX z%yr*cSa{}t*lxB%eyL(F2~|lRfYp*0V2xA>u+~OokK`K$4)KwzggUcDU~a(pi zlT)XLjDc6b{`^qxm7&S=hmALe*hVJLeNaM}du?y-^J7z&Udp|CDF5c$g&Dqqk$;%_zS4Pdn zr%oN3Ja?YWmwSE0I53d?cqqUBVD|8V$@4Gth+VQ2-x)L4HckuyANhR)xr-yfv+?d< z@^2(3FJ1=bffeJ(vFxWQb2OW7&JkKRL2lO0IYQYU;^7z_y|#e;2mh=i3%1FcsFw+X zs3?Na9&qX|L5PV_SrGJc0i~Nzu4 zanN3J!{%_fzN@x57JfqjnAAuZw%ijm3BfkLHlGTf&k#V@JQT0ZNpYSM|w%X z-mY^!XE@!dYND1XfTH?|xCq1XRfglys2tM-^FVLK(!#J3SLFgrf;Gq*vkF)W!aMia zPO@YB*-y!EoUXtlxW%zG#e3w~Em&L&<9?1~kx#}_--6$oQg2peaHg6pHCyVS_BCuo z0nCG_e+-YIK-gS3hQ7M3u=J0*!?0HI;jPxq7of4f#79dua5DCgLw1JBBtGPl93i*l z43$e|p$f?r@<{HGSFV(OvR|(1;vuA$V@QXPT`N@->_eUvm?d9|baD{>-JyEc^6qto z8YCYi0bHmtMRswa1=~o#uX`tRm}fMde|G4U@z#srQ5n{BtGSOZfM+fG zaFY-`ym9ofapWw}W{+RWojz~;?NIjRfys++WRHy+pC4h9=FzhTY&zc&mwI)F7SR+r z;MQ#siQW$w-6?+I%ag^7@E)^)(A;PbKBSoJb>Fi}|Y!6$( z60n4n!ONUgBRC^)R$Mpho(sBXkFZeP1+mW}{Xa%KXAQHOownUJ&0Xl1xPDt7P~Boq zmaP_O&HqkxsTQZZ?V7iMmF)fY*Exy5#Y`AGN*g7L z;4E~95XCIPE%x+464xOsR+M6i0!8tfnu-dh^Oz$9c%282)T!HHV>%y+b;flb4MIg# z!VR+sMjwK&zB}ibZ?M#EWS~(IB+2!9QdK+XdC4>GZ^`&u#{DZZ{*^=F50x{@c<|v& z@Zs^`mP~L<+Si)mCw=wlh806nrs3XmOD?Y*U3q!+=<4)CTdp9|@HPEw`?>oruOD4M z{>#>}U$&+fwvGAP)2?>rR@t`a#3!taD&W2O7Jyf|L827{y;iiCelh^M+7H^?X^W74 z`)Telx0~1SSlBac{dc>p#;m_?oW10kojCOK-I#rpJdzy>I3g5R=;uL$`9v>t7R8?5 zKVZCY!pcGqe}?(vvC-_um<|^mpouk=dq(HXd^dmMsFC^>0sMf@R<}p?rO}q8jk~Lx#C(e;j2kinCSxUtneUD%=g|4 z4W>T@44I=pparNu{q}x-*z&rNGI9Z1;v@_xp0O#@$;)pV9~>{nH&d)L;+wZ_$-n)k zarzjz2y<{gc>$^&v&ukiI5=dS8aDRr%l`f7a*9B}hO10$K%`+RcCy=NpucD!~K$4LgK9T#&=;XPB*}uP&d*j6x z5QgcvXGC|40P%l>29p#+!GQrtX{JUl=LbiA|GQQwOLG@r0v7V`9>bVakS)ct0n7EH zWrP!yvUED4p)1D!;4^I{exKu&}NIDcQuT#?U5GXV3bXh}(Oqn3Dl*Q)Ert6MVFEo0S7Q;$v5 z)Tf^O)=9kUuGTCWt64T)voce&GPQA{rXjV3DMf&54>XmOh!j1k?u8Oc6Too^Sd;5s zRTgP@N5K)XLXl~%htWw9wMP+q_3GJQ0*iBc1?#iiS(W)OKw=o_i^nuk(aYprVI?8S zx@YzYunog77zp#l!^TJA6` zV_hHMOcO&*f7o=2=!Kb?J`4?YCxGv<=(Raq*PW!UA=x%jy9_`@eRAVp{`>wj$(P#!-WG4)7@hKQ($wda<1!0}hYX`k_ zZ5O-Dlph^I_T>KD2bYT8a8|YtWi_uVi7t??l_0b1m?h2aXG-r1m?F?ln6dPpAc}nm z-iBAjv~(}I$}hcE`QV<-<~9&H|2FVmV(yhzpKcxst!Kg; zkO`{16;r>Nt(op_zO9|H(H@w`6b*e2n7ba}=rwaYgIGA5>0pJ0fpDO*`a-WN^kBdV;>|K;)kfo-ehqFKRfW~q z6gi_2T*t|-)Kv4<(%ps^)_7ZNUi&RYvgHVh{+420td}!FrV7kYGQ(#E!nBVGj-5k^ zSbuP>hxOprbOSvI^PnG7{{!H959GHYB}Q$l|MO)~4@$ za%|0(G+B6sth%~-O?vI)W2>J?lcp9C!#+t^!m(1NV8xlQ~O}A^{B~=v=&~>kqWT<0siP zyJj9vgUM9_njiyYr~;W&q0FnCELglGIO6@BmSwfYKZGV;Cx+`S-1&r$8(w2~+^;SX z%DBJowJO1C-5YeguRSs3C7&vVtQM47{>hr#-XVFv`x008@#`96G6_(UeKUz@rO;Xy*@0h zFoD6FVEeoGu7m9^p}Wm*M4G*6)NkR9kN#lbc3+K-_RimNJ}BI_iQY`f5$GQmrHB(n zsG#IC2st7`c}*?mD2^fYtf=IsHy036wKPc!H1P#Y=$y|UC2yx#b2`#n)(Z6& zoKnLv(GDroNdHU9E9Ucz@t;gcR&?E**DZtYM(8LoFB4Y*Ke@R`^fUotiE`42&j^bW z?_6T-=4rJ-*M-%`C<=*G;4FHFeQv?HLPEMJCf$-#9zux0jbC#jOxV|c19F5_e*qls B3NQcw literal 0 HcmV?d00001 diff --git a/src/blog/blog/__pycache__/context_processors.cpython-312.pyc b/src/blog/blog/__pycache__/context_processors.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ac05ddbaafb6a4a0fee38fc99af37732daabec1 GIT binary patch literal 3189 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 literal 0 HcmV?d00001 diff --git a/src/blog/blog/__pycache__/middleware.cpython-312.pyc b/src/blog/blog/__pycache__/middleware.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c3c974dd0ad7f0dc6bf3571027a44ca40bb930f GIT binary patch literal 2443 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 N!j6CBD9$?f{{R+JQ>Xv{ literal 0 HcmV?d00001 diff --git a/src/blog/blog/__pycache__/urls.cpython-312.pyc b/src/blog/blog/__pycache__/urls.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5da770c5d926a71db39e034fb655df3cea4b95a0 GIT binary patch literal 2325 zcmbW2zi-<{6vvN}D4!_O`eE6YEr*sZN09?qS^~5%3;}Mv1PD46MIjgv<_ErAUFK z(nwQVWXY5c#qPbk|Y3p z?fv#||JO7Cf0M=hDlp^fWfFilKmiJ8fC1OxIASM^#30#8Vmqm%jMRYd@Yv=%=>$+Y zC9U%3%X856^L);{c7Y3KD@%XC3!I5ul##gyLx?f5m{AOw(feoX5MvApGfL6u(EcoA zImXD{gOQIh3Yf8W$2y8J#>zbyr5IxsGs<_YV=cy5zXzinV{9bIoFtXa3ofh%GdJ&6 z*E~|G#MoPywRJa^@*u{LF+;x1P~@0y8|$hIblcZDP3jcJ)%~6KM7GZbsUdRv_$l;I zudjB8NK@aFmw;XTb6x$(2~uZGqkepfR(Ux~nVgX5SqcfuN`^(v7pRu1t1&$W=AV9=DDhW27C< zmt9f^)93zohJllVxnp5ar0y_ZX)f=E@nIJmXyo=SyLA*kqX{MEV(Io4DQ{&d-UteF zlwG8;8nG2kA&@oW*A}R5;d9pkAzMOrudhE>9onRx$G@Wiockt%p_@O3TLDk)AUzry z7E%HpA00Bk*)IlJ1Zly&$5wg*-n#Ake$LB8qF^v?p1CEw>`K$g*(1f`nZ(j*FHeNXM9+>y}v>BD{TKM-VbAE z@R0`}P2s*T-B27*Mb(4V8Ekm4F@=wPNhXXcV|=+tQ6q|nOtD85vIpfEtb4FNg$-ZY zA_|!)T8k7rL{Vdk8dX$0See0^2WwOKFji@Ek>Wz4^Gv-(Q##g(fhw%&<_nJ9ar zJQM4lSf7fGD?X8Ve3=Hz<&RmO-}oTXw^*X&O%HC);I0REr*JR!o(oh__F&nUR!Q0G Ytn579Ag#|ME;a6pT;i9kUB`#}AHxMInE(I) literal 0 HcmV?d00001 diff --git a/src/blog/blog/__pycache__/views.cpython-312.pyc b/src/blog/blog/__pycache__/views.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..95a72d8de621bb48b6ec4e6cb1f41e5b470f142b GIT binary patch literal 19247 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' % (link, obj.category.name)) + + # 自定义字段的显示名称 + link_to_category.short_description = _('category') + + def get_form(self, request, obj=None, **kwargs): + """ + 重写表单获取方法,自定义表单字段 + + 这里限制了作者只能选择超级用户 + """ + form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs) + # 作者字段只显示超级用户 + form.base_fields['author'].queryset = get_user_model().objects.filter(is_superuser=True) + return form + + def save_model(self, request, obj, form, change): + """ + 重写保存模型的方法 + + 可以在这里添加额外的保存逻辑,如自动填充某些字段 + 目前使用默认实现 + """ + super(ArticlelAdmin, self).save_model(request, obj, form, change) + + def get_view_on_site_url(self, obj=None): + """ + 自定义"在站点上查看"的链接 + + Args: + obj: 文章对象 + + Returns: + 文章的前台访问URL或网站首页 + """ + if obj: + # 如果有文章对象,返回文章的完整URL + url = obj.get_full_url() + return url + else: + # 如果没有对象(如在列表页),返回网站首页 + from djangoblog.utils import get_current_site + site = get_current_site().domain + return site + + +class TagAdmin(admin.ModelAdmin): + """标签模型的Admin配置""" + # 编辑页排除的字段(自动生成) + exclude = ('slug', 'last_mod_time', 'creation_time') + + +class CategoryAdmin(admin.ModelAdmin): + """分类模型的Admin配置""" + # 列表页显示的字段 + list_display = ('name', 'parent_category', 'index') + # 编辑页排除的字段 + exclude = ('slug', 'last_mod_time', 'creation_time') + + +class LinksAdmin(admin.ModelAdmin): + """链接模型的Admin配置""" + exclude = ('last_mod_time', 'creation_time') + + +class SideBarAdmin(admin.ModelAdmin): + """侧边栏模型的Admin配置""" + list_display = ('name', 'content', 'is_enable', 'sequence') + exclude = ('last_mod_time', 'creation_time') + + +class BlogSettingsAdmin(admin.ModelAdmin): + """博客设置模型的Admin配置""" + pass # 使用默认配置 diff --git a/src/blog/blog/apps.py b/src/blog/blog/apps.py new file mode 100644 index 00000000..4bd78485 --- /dev/null +++ b/src/blog/blog/apps.py @@ -0,0 +1,15 @@ +# 从Django的apps模块导入AppConfig类,用于定义应用的配置 +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + """ + 博客应用(blog)的配置类 + + Django通过此类识别和配置应用的基本信息, + 包括应用名称、默认自动生成的主键类型等。 + 当项目启动时,Django会加载每个应用的AppConfig子类。 + """ + # 定义应用的名称,必须与应用的实际目录名一致 + # 这个名称用于Django内部识别应用,例如在INSTALLED_APPS中注册时使用 + name = 'blog' diff --git a/src/blog/blog/context_processors.py b/src/blog/blog/context_processors.py new file mode 100644 index 00000000..f2acba47 --- /dev/null +++ b/src/blog/blog/context_processors.py @@ -0,0 +1,73 @@ +# 导入日志模块,用于记录系统运行时的信息和错误 +import logging + +# 从django.utils导入timezone,用于获取当前时间 +from django.utils import timezone + +# 导入自定义的缓存工具和获取博客设置的工具函数 +from djangoblog.utils import cache, get_blog_setting +# 导入当前应用下的Category(分类)和Article(文章)模型 +from .models import Category, Article + +# 创建日志记录器,用于记录当前模块的日志信息 +logger = logging.getLogger(__name__) + + +def seo_processor(requests): + """ + 自定义上下文处理器,用于在所有模板中全局共享SEO相关的配置和数据 + + 上下文处理器是Django的一个功能,允许你在所有模板中自动添加变量, + 无需在每个视图函数中单独传递,特别适合网站全局配置信息的共享。 + + Args: + requests: Django请求对象,包含当前请求的相关信息(如域名、协议等) + + Returns: + dict: 包含网站配置、分类、页面等信息的字典,将被注入到所有模板中 + """ + # 定义缓存键,用于标识当前处理器的缓存数据 + key = 'seo_processor' + # 尝试从缓存中获取数据,减少数据库查询和计算开销 + value = cache.get(key) + + # 如果缓存中存在数据,直接返回缓存内容 + if value: + return value + else: + # 缓存未命中时,记录日志并重新计算数据 + logger.info('set processor cache.') + # 获取博客的全局设置(从数据库或其他配置源) + setting = get_blog_setting() + + # 构建需要传递给模板的全局变量字典 + value = { + 'SITE_NAME': setting.site_name, # 网站名称 + 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, # 是否显示谷歌广告 + 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, # 谷歌广告代码 + 'SITE_SEO_DESCRIPTION': setting.site_seo_description, # 网站SEO描述(用于搜索引擎) + 'SITE_DESCRIPTION': setting.site_description, # 网站描述 + 'SITE_KEYWORDS': setting.site_keywords, # 网站关键词(用于SEO) + # 网站基础URL(如https://example.com/) + 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', + 'ARTICLE_SUB_LENGTH': setting.article_sub_length, # 文章摘要长度 + 'nav_category_list': Category.objects.all(), # 导航栏显示的所有分类 + # 导航栏显示的页面(类型为'p'即page,状态为'p'即published) + 'nav_pages': Article.objects.filter( + type='p', + status='p'), + 'OPEN_SITE_COMMENT': setting.open_site_comment, # 是否开启网站评论功能 + 'BEIAN_CODE': setting.beian_code, # 网站备案号 + 'ANALYTICS_CODE': setting.analytics_code, # 网站统计代码(如Google Analytics) + "BEIAN_CODE_GONGAN": setting.gongan_beiancode, # 公安备案号 + "SHOW_GONGAN_CODE": setting.show_gongan_code, # 是否显示公安备案号 + "CURRENT_YEAR": timezone.now().year, # 当前年份(用于页脚版权信息等) + "GLOBAL_HEADER": setting.global_header, # 全局页眉代码(如额外的CSS/JS) + "GLOBAL_FOOTER": setting.global_footer, # 全局页脚代码 + "COMMENT_NEED_REVIEW": setting.comment_need_review, # 评论是否需要审核 + } + + # 将数据存入缓存,有效期为10小时(60秒*60分*10小时) + cache.set(key, value, 60 * 60 * 10) + # 返回构建的全局变量字典 + return value \ No newline at end of file diff --git a/src/blog/blog/documents.py b/src/blog/blog/documents.py new file mode 100644 index 00000000..c9ba1285 --- /dev/null +++ b/src/blog/blog/documents.py @@ -0,0 +1,267 @@ +# 导入时间处理模块 +import time + +# 导入Elasticsearch客户端相关模块 +import elasticsearch.client +# 导入Django配置模块 +from django.conf import settings +# 导入Elasticsearch DSL相关组件,用于定义文档结构 +from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean +from elasticsearch_dsl.connections import connections + +# 导入博客文章模型,用于数据同步 +from blog.models import Article + +# 检查是否启用Elasticsearch(通过判断配置中是否存在ELASTICSEARCH_DSL) +ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL') + +# 如果启用了Elasticsearch,则进行初始化配置 +if ELASTICSEARCH_ENABLED: + # 创建Elasticsearch连接(从Django配置中获取主机地址) + connections.create_connection( + hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']]) + # 导入Elasticsearch客户端并初始化 + from elasticsearch import Elasticsearch + + es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + + # 初始化IngestClient(用于处理数据预处理管道) + from elasticsearch.client import IngestClient + + c = IngestClient(es) + + # 尝试获取名为'geoip'的管道,如果不存在则创建 + try: + c.get_pipeline('geoip') + except elasticsearch.exceptions.NotFoundError: + # 创建geoip管道:通过ip地址解析地理位置信息 + c.put_pipeline('geoip', body='''{ + "description" : "Add geoip info", # 管道描述:添加地理信息 + "processors" : [ + { + "geoip" : { + "field" : "ip" # 基于ip字段解析地理信息 + } + } + ] + }''') + + +# 定义地理位置信息内部文档(嵌套在主文档中) +class GeoIp(InnerDoc): + continent_name = Keyword() # 大洲名称( Keyword类型:不分词,适合精确查询) + country_iso_code = Keyword() # 国家ISO代码(如CN、US) + country_name = Keyword() # 国家名称 + location = GeoPoint() # 经纬度坐标(Elasticsearch地理点类型) + + +# 定义用户代理浏览器信息内部文档 +class UserAgentBrowser(InnerDoc): + Family = Keyword() # 浏览器家族(如Chrome、Firefox) + Version = Keyword() # 浏览器版本 + + +# 定义用户代理操作系统信息内部文档(继承浏览器结构,字段相同) +class UserAgentOS(UserAgentBrowser): + pass + + +# 定义用户代理设备信息内部文档 +class UserAgentDevice(InnerDoc): + Family = Keyword() # 设备家族(如iPhone、Windows) + Brand = Keyword() # 设备品牌(如Apple、Samsung) + Model = Keyword() # 设备型号(如iPhone 13) + + +# 定义用户代理整体信息内部文档(整合浏览器、系统、设备信息) +class UserAgent(InnerDoc): + browser = Object(UserAgentBrowser, required=False) # 浏览器信息(可选) + os = Object(UserAgentOS, required=False) # 操作系统信息(可选) + device = Object(UserAgentDevice, required=False) # 设备信息(可选) + string = Text() # 原始用户代理字符串(如"Mozilla/5.0...") + is_bot = Boolean() # 是否为爬虫机器人 + + +# 定义性能日志文档(记录访问性能数据) +class ElapsedTimeDocument(Document): + url = Keyword() # 访问的URL(精确匹配) + time_taken = Long() # 页面加载耗时(毫秒) + log_datetime = Date() # 日志记录时间 + ip = Keyword() # 访问者IP地址 + geoip = Object(GeoIp, required=False) # 地理位置信息(由geoip管道生成) + useragent = Object(UserAgent, required=False) # 用户代理信息 + + # 索引配置 + class Index: + name = 'performance' # 索引名称:performance(性能日志) + settings = { + "number_of_shards": 1, # 主分片数量 + "number_of_replicas": 0 # 副本分片数量(单节点环境设为0) + } + + # 文档类型配置(Elasticsearch 7+后逐渐废弃,但DSL仍保留兼容) + class Meta: + doc_type = 'ElapsedTime' + + +# 性能日志文档管理器(处理索引创建、删除、数据写入) +class ElaspedTimeDocumentManager: + @staticmethod + def build_index(): + """创建performance索引(如果不存在)""" + from elasticsearch import Elasticsearch + # 连接Elasticsearch + client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + # 检查索引是否存在 + res = client.indices.exists(index="performance") + if not res: + # 初始化索引(根据ElapsedTimeDocument的定义创建映射) + ElapsedTimeDocument.init() + + @staticmethod + def delete_index(): + """删除performance索引""" + from elasticsearch import Elasticsearch + es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + # 忽略400(索引不存在)和404(请求错误) + es.indices.delete(index='performance', ignore=[400, 404]) + + @staticmethod + def create(url, time_taken, log_datetime, useragent, ip): + """创建一条性能日志记录并写入Elasticsearch""" + # 确保索引存在 + ElaspedTimeDocumentManager.build_index() + + # 构建用户代理信息对象 + ua = UserAgent() + ua.browser = UserAgentBrowser() + ua.browser.Family = useragent.browser.family # 浏览器家族 + ua.browser.Version = useragent.browser.version_string # 浏览器版本 + + ua.os = UserAgentOS() + ua.os.Family = useragent.os.family # 操作系统家族 + ua.os.Version = useragent.os.version_string # 操作系统版本 + + ua.device = UserAgentDevice() + ua.device.Family = useragent.device.family # 设备家族 + ua.device.Brand = useragent.device.brand # 设备品牌 + ua.device.Model = useragent.device.model # 设备型号 + ua.string = useragent.ua_string # 原始用户代理字符串 + ua.is_bot = useragent.is_bot # 是否为爬虫 + + # 构建性能日志文档 + doc = ElapsedTimeDocument( + meta={ + # 用当前时间戳(毫秒)作为文档ID + 'id': int(round(time.time() * 1000)) + }, + url=url, # 访问URL + time_taken=time_taken, # 耗时 + log_datetime=log_datetime, # 日志时间 + useragent=ua, # 用户代理信息 + ip=ip # IP地址 + ) + # 保存文档时应用geoip管道(自动解析IP对应的地理位置) + doc.save(pipeline="geoip") + + +# 定义文章文档(用于博客文章的搜索索引) +class ArticleDocument(Document): + # 文章内容(使用ik分词器:max_word最大化分词,smart智能分词) + body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') + # 文章标题(同上分词配置) + title = Text(analyzer='ik_max_word', search_analyzer='ik_smart') + # 作者信息(嵌套对象) + author = Object(properties={ + 'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 作者昵称 + 'id': Integer() # 作者ID + }) + # 分类信息(嵌套对象) + category = Object(properties={ + 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 分类名称 + 'id': Integer() # 分类ID + }) + # 标签信息(嵌套对象列表) + tags = Object(properties={ + 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 标签名称 + 'id': Integer() # 标签ID + }) + pub_time = Date() # 发布时间 + status = Text() # 文章状态(如发布、草稿) + comment_status = Text() # 评论状态(如开启、关闭) + type = Text() # 文章类型(如原创、转载) + views = Integer() # 浏览量 + article_order = Integer() # 文章排序权重 + + # 索引配置 + class Index: + name = 'blog' # 索引名称:blog(博客文章) + settings = { + "number_of_shards": 1, + "number_of_replicas": 0 + } + + # 文档类型配置 + class Meta: + doc_type = 'Article' + + +# 文章文档管理器(处理文章索引的创建、更新、重建) +class ArticleDocumentManager(): + + def __init__(self): + """初始化时创建索引(如果不存在)""" + self.create_index() + + def create_index(self): + """创建blog索引(根据ArticleDocument定义初始化映射)""" + ArticleDocument.init() + + def delete_index(self): + """删除blog索引""" + from elasticsearch import Elasticsearch + es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + es.indices.delete(index='blog', ignore=[400, 404]) + + def convert_to_doc(self, articles): + """将Django模型对象列表转换为ArticleDocument列表""" + return [ + ArticleDocument( + meta={'id': article.id}, # 用文章ID作为文档ID + body=article.body, # 文章内容 + title=article.title, # 文章标题 + author={ + 'nickname': article.author.username, # 作者用户名 + 'id': article.author.id # 作者ID + }, + category={ + 'name': article.category.name, # 分类名称 + 'id': article.category.id # 分类ID + }, + # 转换标签列表(多对多关系) + tags=[{'name': t.name, 'id': t.id} for t in article.tags.all()], + pub_time=article.pub_time, # 发布时间 + status=article.status, # 文章状态 + comment_status=article.comment_status, # 评论状态 + type=article.type, # 文章类型 + views=article.views, # 浏览量 + article_order=article.article_order # 排序权重 + ) for article in articles + ] + + def rebuild(self, articles=None): + """重建索引(默认同步所有文章,可指定文章列表)""" + # 初始化索引结构 + ArticleDocument.init() + # 如果未指定文章,则同步所有文章 + articles = articles if articles else Article.objects.all() + # 转换模型为文档对象 + docs = self.convert_to_doc(articles) + # 批量保存文档 + for doc in docs: + doc.save() + + def update_docs(self, docs): + """更新文档列表(批量保存)""" + for doc in docs: + doc.save() \ No newline at end of file diff --git a/src/blog/blog/forms.py b/src/blog/blog/forms.py new file mode 100644 index 00000000..690d1dd5 --- /dev/null +++ b/src/blog/blog/forms.py @@ -0,0 +1,41 @@ +# 导入日志模块,用于记录搜索相关日志 +import logging + +# 导入Django的表单模块,用于构建自定义表单 +from django import forms +# 导入Haystack的搜索表单基类,用于扩展搜索功能 +from haystack.forms import SearchForm + +# 创建日志记录器,使用当前模块名作为日志器名称 +logger = logging.getLogger(__name__) + + +class BlogSearchForm(SearchForm): + """ + 博客搜索表单类,继承自Haystack的SearchForm + 用于自定义博客搜索的表单验证和搜索逻辑 + """ + # 定义搜索查询字段,required=True表示该字段为必填项 + # 用户输入的搜索关键词将通过该字段传递 + querydata = forms.CharField(required=True) + + def search(self): + """ + 重写父类的search方法,实现自定义搜索逻辑 + 该方法会处理搜索请求并返回搜索结果 + """ + # 调用父类的search方法,获取初始搜索结果集 + # 父类方法会处理Haystack的核心搜索逻辑 + datas = super(BlogSearchForm, self).search() + + # 检查表单数据是否有效,若无效则返回无查询结果的默认响应 + if not self.is_valid(): + return self.no_query_found() + + # 如果表单验证通过且存在查询数据(querydata) + if self.cleaned_data['querydata']: + # 记录搜索关键词到日志,方便后续分析用户搜索行为 + logger.info(self.cleaned_data['querydata']) + + # 返回处理后的搜索结果集 + return datas \ No newline at end of file diff --git a/src/blog/blog/management/__init__.py b/src/blog/blog/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blog/blog/management/commands/__init__.py b/src/blog/blog/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blog/blog/management/commands/build_index.py b/src/blog/blog/management/commands/build_index.py new file mode 100644 index 00000000..22369c44 --- /dev/null +++ b/src/blog/blog/management/commands/build_index.py @@ -0,0 +1,45 @@ +# 导入Django命令基类,用于创建自定义管理命令 +from django.core.management.base import BaseCommand + +# 导入博客相关的Elasticsearch文档管理器和配置 +from blog.documents import ( + ElapsedTimeDocument, # 耗时统计文档模型 + ArticleDocumentManager, # 文章文档管理器 + ElaspedTimeDocumentManager, # 耗时统计文档管理器(注:原拼写可能存在笔误,应为Elapsed) + ELASTICSEARCH_ENABLED # Elasticsearch启用状态标记 +) + + +# TODO: 后续可优化为支持参数化(如指定重建的索引类型等) +class Command(BaseCommand): + """ + Django自定义管理命令:构建Elasticsearch搜索索引 + 用于初始化或重建文章和耗时统计相关的搜索索引 + """ + # 命令的帮助信息(使用python manage.py help build_index时显示) + help = 'build search index' + + def handle(self, *args, **options): + """ + 命令核心执行方法 + 当运行python manage.py build_index时调用 + """ + # 仅在Elasticsearch启用时执行索引构建 + if ELASTICSEARCH_ENABLED: + # 构建耗时统计文档的索引 + ElaspedTimeDocumentManager.build_index() + + # 初始化耗时统计文档的索引结构 + elapsed_manager = ElapsedTimeDocument() + elapsed_manager.init() # 创建索引映射 + + # 处理文章文档索引:先删除旧索引,再重建 + article_manager = ArticleDocumentManager() + article_manager.delete_index() # 删除现有文章索引 + article_manager.rebuild() # 重新创建索引并同步数据 + + # 输出成功信息到控制台 + self.stdout.write(self.style.SUCCESS('Successfully built search indexes')) + else: + # 当Elasticsearch未启用时,提示用户 + self.stdout.write(self.style.WARNING('Elasticsearch is not enabled, skipping index build')) \ No newline at end of file diff --git a/src/blog/blog/management/commands/build_search_words.py b/src/blog/blog/management/commands/build_search_words.py new file mode 100644 index 00000000..b0d807e5 --- /dev/null +++ b/src/blog/blog/management/commands/build_search_words.py @@ -0,0 +1,32 @@ +# 导入Django命令基类,用于创建自定义管理命令 +from django.core.management.base import BaseCommand + +# 导入博客应用中的标签和分类模型 +from blog.models import Tag, Category + + +# TODO: 后续可优化为支持参数化(如指定输出格式、过滤条件等) +class Command(BaseCommand): + """ + Django自定义管理命令:生成搜索关键词列表 + 提取所有标签和分类的名称,用于构建搜索提示词或关键词库 + """ + # 命令的帮助信息(执行python manage.py help build_search_words时显示) + help = 'build search words' + + def handle(self, *args, **options): + """ + 命令核心执行逻辑 + 当运行python manage.py build_search_words时调用 + """ + # 1. 提取所有标签(Tag)的名称并转换为列表 + # 2. 提取所有分类(Category)的名称并转换为列表 + # 3. 合并两个列表并通过set去重(确保关键词唯一) + datas = set( + [tag.name for tag in Tag.objects.all()] + # 标签名称列表 + [category.name for category in Category.objects.all()] # 分类名称列表 + ) + + # 将去重后的关键词按行打印输出 + # 格式为每个关键词单独一行,便于后续处理(如写入文件或导入搜索提示库) + print('\n'.join(datas)) \ No newline at end of file diff --git a/src/blog/blog/management/commands/clear_cache.py b/src/blog/blog/management/commands/clear_cache.py new file mode 100644 index 00000000..73803c40 --- /dev/null +++ b/src/blog/blog/management/commands/clear_cache.py @@ -0,0 +1,26 @@ +# 导入Django命令基类,用于创建自定义管理命令 +from django.core.management.base import BaseCommand + +# 导入项目自定义的缓存工具(封装自djangoblog.utils) +from djangoblog.utils import cache + + +class Command(BaseCommand): + """ + Django自定义管理命令:清除系统所有缓存 + 用于手动触发缓存清理,确保缓存数据与数据库同步 + """ + # 命令的帮助信息(执行python manage.py help clear_cache时显示) + help = 'clear the whole cache' + + def handle(self, *args, **options): + """ + 命令核心执行逻辑 + 当运行python manage.py clear_cache时调用 + """ + # 调用缓存工具的clear()方法,清除所有缓存数据 + # 这里的cache是项目自定义的缓存实例(可能封装了Django原生缓存或其他缓存后端) + cache.clear() + + # 向控制台输出成功信息(使用Django命令的样式工具,显示绿色成功提示) + self.stdout.write(self.style.SUCCESS('Cleared cache\n')) \ No newline at end of file diff --git a/src/blog/blog/management/commands/create_testdata.py b/src/blog/blog/management/commands/create_testdata.py new file mode 100644 index 00000000..8d472bf6 --- /dev/null +++ b/src/blog/blog/management/commands/create_testdata.py @@ -0,0 +1,76 @@ +# 导入Django用户模型、密码加密、命令基类 +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from django.core.management.base import BaseCommand + +# 导入博客应用的核心模型 +from blog.models import Article, Tag, Category + + +class Command(BaseCommand): + """ + Django自定义管理命令:创建测试数据 + 用于快速生成用户、分类、标签和文章等测试数据,方便开发和测试 + """ + # 命令的帮助信息(执行python manage.py help create_testdata时显示) + help = 'create test datas' + + def handle(self, *args, **options): + """ + 命令核心执行逻辑 + 当运行python manage.py create_testdata时调用,生成测试数据 + """ + # 1. 创建或获取测试用户 + # get_or_create:存在则获取,不存在则创建(避免重复生成) + # make_password:加密密码(安全存储) + user = get_user_model().objects.get_or_create( + email='test@test.com', # 测试邮箱 + username='测试用户', # 用户名 + password=make_password('test!q@w#eTYU') # 加密后的密码 + )[0] # [0]取返回元组中的用户对象 + + # 2. 创建分类(含层级关系) + # 创建父分类(无上级分类) + pcategory = Category.objects.get_or_create( + name='我是父类目', + parent_category=None # 顶级分类 + )[0] + + # 创建子分类(关联父分类) + category = Category.objects.get_or_create( + name='子类目', + parent_category=pcategory # 关联到父分类 + )[0] + category.save() # 保存子分类 + + # 3. 创建基础标签(供所有测试文章共用) + basetag = Tag() + basetag.name = "标签" # 标签名称 + basetag.save() + + # 4. 批量创建测试文章(1-19共19篇) + for i in range(1, 20): + # 创建或获取文章 + article = Article.objects.get_or_create( + category=category, # 关联到子分类 + title=f'nice title {i}', # 文章标题(带序号) + body=f'nice content {i}', # 文章内容(带序号) + author=user # 关联到测试用户 + )[0] + + # 为每篇文章创建专属标签 + tag = Tag() + tag.name = f"标签{i}" # 标签名称(带序号) + tag.save() + + # 给文章添加标签(专属标签 + 基础标签) + article.tags.add(tag) + article.tags.add(basetag) + article.save() # 保存文章(更新标签关联) + + # 5. 清除缓存(确保新生成的测试数据能立即生效) + from djangoblog.utils import cache + cache.clear() + + # 输出成功信息到控制台 + self.stdout.write(self.style.SUCCESS('created test datas \n')) \ No newline at end of file diff --git a/src/blog/blog/management/commands/ping_baidu.py b/src/blog/blog/management/commands/ping_baidu.py new file mode 100644 index 00000000..98addbf4 --- /dev/null +++ b/src/blog/blog/management/commands/ping_baidu.py @@ -0,0 +1,88 @@ +# 导入Django命令基类,用于创建自定义管理命令 +from django.core.management.base import BaseCommand + +# 导入搜索引擎推送工具、站点配置和博客模型 +from djangoblog.spider_notify import SpiderNotify # 搜索引擎推送工具 +from djangoblog.utils import get_current_site # 获取当前站点信息 +from blog.models import Article, Tag, Category # 博客核心模型 + +# 获取当前站点的域名(用于生成完整URL) +site = get_current_site().domain + + +class Command(BaseCommand): + """ + Django自定义管理命令:向百度搜索引擎推送URL + 用于主动告知百度爬虫网站的更新内容,加速收录 + """ + # 命令的帮助信息(执行python manage.py help ping_baidu时显示) + help = 'notify baidu url' + + def add_arguments(self, parser): + """ + 定义命令参数:指定需要推送的URL类型 + 通过parser添加命令行参数,限制可选值 + """ + parser.add_argument( + 'data_type', # 参数名称 + type=str, + choices=[ # 可选参数值 + 'all', # 推送所有类型(文章、标签、分类) + 'article', # 仅推送文章 + 'tag', # 仅推送标签页 + 'category' # 仅推送分类页 + ], + help='指定推送类型:article(所有文章)、tag(所有标签)、category(所有分类)、all(全部)' + ) + + def get_full_url(self, path): + """ + 生成完整的URL(域名+相对路径) + :param path: 模型实例的相对路径(如/article/1.html) + :return: 完整的URL字符串(如https://example.com/article/1.html) + """ + return f"https://{site}{path}" + + def handle(self, *args, **options): + """ + 命令核心执行逻辑 + 根据参数类型收集URL,推送给百度搜索引擎 + """ + # 获取用户指定的推送类型 + data_type = options['data_type'] + self.stdout.write(f'开始收集{data_type}类型的URL...') + + # 存储待推送的URL列表 + urls = [] + + # 1. 收集文章URL(已发布状态) + if data_type == 'article' or data_type == 'all': + # 筛选所有已发布的文章 + for article in Article.objects.filter(status='p'): + # 调用文章模型的get_full_url方法获取完整URL + urls.append(article.get_full_url()) + + # 2. 收集标签页URL + if data_type == 'tag' or data_type == 'all': + for tag in Tag.objects.all(): + # 获取标签页的相对路径,再生成完整URL + relative_url = tag.get_absolute_url() + urls.append(self.get_full_url(relative_url)) + + # 3. 收集分类页URL + if data_type == 'category' or data_type == 'all': + for category in Category.objects.all(): + # 获取分类页的相对路径,再生成完整URL + relative_url = category.get_absolute_url() + urls.append(self.get_full_url(relative_url)) + + # 输出待推送的URL数量 + self.stdout.write( + self.style.SUCCESS(f'准备推送{len(urls)}条URL...') + ) + + # 调用工具类向百度推送URL + SpiderNotify.baidu_notify(urls) + + # 推送完成,输出成功信息 + self.stdout.write(self.style.SUCCESS('URL推送完成!')) \ No newline at end of file diff --git a/src/blog/blog/management/commands/sync_user_avatar.py b/src/blog/blog/management/commands/sync_user_avatar.py new file mode 100644 index 00000000..6bade0d6 --- /dev/null +++ b/src/blog/blog/management/commands/sync_user_avatar.py @@ -0,0 +1,86 @@ +import requests # 用于发送HTTP请求,验证图片URL有效性 +from django.core.management.base import BaseCommand +from django.templatetags.static import static # 生成静态文件URL + +# 导入项目工具和模型:用户头像保存、OAuth用户模型、OAuth管理工具 +from djangoblog.utils import save_user_avatar # 保存用户头像到本地的工具函数 +from oauth.models import OAuthUser # OAuth关联用户模型(存储第三方登录用户信息) +from oauth.oauthmanager import get_manager_by_type # 根据 OAuth 类型获取对应管理器 + + +class Command(BaseCommand): + """ + Django自定义管理命令:同步用户头像 + 用于检查并更新OAuth用户的头像URL,确保头像可访问(无效则重新获取或使用默认头像) + """ + # 命令的帮助信息(执行python manage.py help sync_user_avatar时显示) + help = 'sync user avatar' + + def test_picture(self, url): + """ + 验证图片URL是否有效(可访问且返回200状态码) + :param url: 头像图片的URL + :return: 有效则返回True,否则返回False + """ + try: + # 发送GET请求,超时2秒,检查状态码是否为200 + if requests.get(url, timeout=2).status_code == 200: + return True + except: + # 任何异常(超时、连接错误等)均视为无效 + pass + return False + + def handle(self, *args, **options): + """ + 命令核心执行逻辑 + 遍历所有OAuth用户,检查并同步头像URL + """ + # 获取项目静态文件的基础URL(用于判断头像是否为本地静态文件) + static_url = static("../") + + # 获取所有OAuth用户 + users = OAuthUser.objects.all() + self.stdout.write(f'开始同步{len(users)}个用户头像') + + # 遍历每个用户处理头像 + for u in users: + self.stdout.write(f'开始同步:{u.nickname}') # 输出当前处理的用户名 + url = u.picture # 获取用户当前的头像URL + + if url: # 如果用户已有头像URL + # 情况1:头像URL是本地静态文件(以static_url开头) + if url.startswith(static_url): + # 验证本地头像是否有效 + if self.test_picture(url): + self.stdout.write(f' 头像有效,跳过:{url}') + continue # 有效则跳过处理 + else: + # 本地头像无效,尝试重新获取 + self.stdout.write(f' 本地头像无效,尝试重新获取') + if u.metadata: # 如果存在第三方平台返回的元数据(可能包含头像信息) + # 根据OAuth类型(如qq、weibo)获取对应的管理器 + manage = get_manager_by_type(u.type) + # 从元数据中提取最新头像URL + url = manage.get_picture(u.metadata) + # 保存头像到本地并返回新的URL + url = save_user_avatar(url) + else: + # 无元数据,使用默认头像 + url = static('blog/img/avatar.png') + else: + # 情况2:头像URL是第三方链接(非本地文件),保存到本地 + self.stdout.write(f' 第三方头像,保存到本地') + url = save_user_avatar(url) + else: + # 情况3:用户无头像URL,使用默认头像 + self.stdout.write(f' 无头像,使用默认头像') + url = static('blog/img/avatar.png') + + # 更新用户头像并保存 + if url: + self.stdout.write(f' 结束同步:{u.nickname}.url:{url}') + u.picture = url + u.save() # 保存更新后的头像URL + + self.stdout.write('所有用户头像同步完成') \ No newline at end of file diff --git a/src/blog/blog/middleware.py b/src/blog/blog/middleware.py new file mode 100644 index 00000000..6e496e1a --- /dev/null +++ b/src/blog/blog/middleware.py @@ -0,0 +1,90 @@ +# 导入日志模块,用于记录中间件运行过程中的日志信息 +import logging +# 导入时间模块,用于计算页面渲染耗时 +import time + +# 从ipware工具导入获取客户端IP的函数 +from ipware import get_client_ip +# 从user_agents工具导入解析用户代理的函数 +from user_agents import parse + +# 导入博客相关的ES配置和文档管理器(用于记录页面加载时间) +from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager + +# 创建当前模块的日志记录器 +logger = logging.getLogger(__name__) + + +class OnlineMiddleware(object): + """ + 自定义Django中间件,用于: + 1. 计算页面渲染耗时 + 2. 收集客户端信息(IP、用户代理) + 3. 在启用Elasticsearch时记录访问性能数据 + 4. 替换响应中的特定标记为实际加载时间 + """ + + def __init__(self, get_response=None): + """ + 中间件初始化方法 + :param get_response: Django框架传入的处理响应的函数,用于链式调用中间件 + """ + self.get_response = get_response + # 调用父类初始化方法(Python 2兼容写法,在Python 3中可省略) + super().__init__() + + def __call__(self, request): + """ + 中间件核心处理方法,在请求到达视图前和响应返回客户端前执行 + :param request: Django的请求对象,包含客户端请求信息 + :return: 处理后的响应对象 + """ + # 记录请求处理开始时间(用于计算耗时) + start_time = time.time() + + # 调用下一个中间件或视图函数,获取响应对象 + response = self.get_response(request) + + # 从请求头中获取用户代理字符串(如浏览器型号、系统等信息) + http_user_agent = request.META.get('HTTP_USER_AGENT', '') + # 获取客户端IP地址(第二个返回值为是否是公开IP,此处暂不使用) + ip, _ = get_client_ip(request) + # 解析用户代理字符串,转换为可操作的对象(方便提取浏览器、系统等信息) + user_agent = parse(http_user_agent) + + # 判断响应是否为非流式响应(流式响应无法修改内容,如文件下载) + if not response.streaming: + try: + # 计算页面渲染总耗时(当前时间 - 开始时间) + cast_time = time.time() - start_time + + # 如果启用了Elasticsearch,记录性能数据 + if ELASTICSEARCH_ENABLED: + # 将耗时转换为毫秒并保留2位小数 + time_taken = round((cast_time) * 1000, 2) + # 获取当前请求的URL路径 + url = request.path + # 导入Django的时区工具,用于记录当前时间 + from django.utils import timezone + # 通过文档管理器向Elasticsearch插入一条性能记录 + ElaspedTimeDocumentManager.create( + url=url, # 访问的URL + time_taken=time_taken, # 页面加载耗时(毫秒) + log_datetime=timezone.now(), # 记录时间(当前时区) + useragent=user_agent, # 解析后的用户代理信息 + ip=ip # 客户端IP地址 + ) + + # 将响应内容中的标记替换为实际耗时(保留前5位字符) + # 注:需确保响应内容为bytes类型,因此使用str.encode转换 + response.content = response.content.replace( + b'', str.encode(str(cast_time)[:5]) + ) + + # 捕获所有异常,避免中间件错误导致请求失败 + except Exception as e: + # 记录异常信息到日志 + logger.error("Error in OnlineMiddleware: %s" % e) + + # 返回处理后的响应对象 + return response \ No newline at end of file diff --git a/src/blog/blog/migrations/0001_initial.py b/src/blog/blog/migrations/0001_initial.py new file mode 100644 index 00000000..66b3230d --- /dev/null +++ b/src/blog/blog/migrations/0001_initial.py @@ -0,0 +1,202 @@ +# 生成信息:由Django 4.1.7在2023-03-02 07:14自动生成的迁移文件 +# 迁移文件用于定义数据库表结构,通过Django的迁移系统创建或修改数据库表 +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion # 用于定义外键删除时的行为 +import django.utils.timezone # 用于处理时间字段的默认值 +import mdeditor.fields # 导入Markdown编辑器字段(用于文章正文) + + +class Migration(migrations.Migration): + """ + 数据库迁移类:定义博客系统初始表结构的迁移操作 + 所有模型的首次迁移,会创建对应的数据库表 + """ + # 标记为初始迁移(首次创建表结构) + initial = True + + # 依赖关系:当前迁移依赖于Django用户模型的迁移 + # 因为Article模型关联了用户表(作者),需确保用户表先创建 + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + # 迁移操作:创建所有模型对应的数据库表 + operations = [ + # 创建"网站配置"表(BlogSettings) + migrations.CreateModel( + name='BlogSettings', + fields=[ + # 自增主键(BigAutoField支持更大的数值范围,适合大数据量) + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')), # 网站名称 + ('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')), # 网站描述(用于前端展示) + ('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')), # 用于搜索引擎优化的描述 + ('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')), # SEO关键字,多个用逗号分隔 + ('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')), # 文章列表页显示的摘要长度 + ('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')), # 侧边栏显示的文章数量 + ('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='广告内容')), # 谷歌广告代码 + ('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')), # 开关:是否允许评论 + ('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')), # 网站备案号(ICP备案) + ('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='公安备案号')), # 公安备案号 + ], + options={ + 'verbose_name': '网站配置', # 模型的单数显示名称 + 'verbose_name_plural': '网站配置', # 模型的复数显示名称(因配置通常只有一条记录,复数同单数) + }, + ), + + # 创建"友情链接"表(Links) + migrations.CreateModel( + name='Links', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')), # 友情链接名称(唯一) + ('link', models.URLField(verbose_name='链接地址')), # 链接的URL地址 + ('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='显示类型' + )), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # 创建时间 + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # 最后修改时间 + ], + options={ + 'verbose_name': '友情链接', + 'verbose_name_plural': '友情链接', + 'ordering': ['sequence'], # 默认按排序序号升序排列 + }, + ), + + # 创建"侧边栏"表(SideBar) + migrations.CreateModel( + name='SideBar', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='标题')), # 侧边栏模块标题 + ('content', models.TextField(verbose_name='内容')), # 侧边栏内容(支持HTML) + ('sequence', models.IntegerField(unique=True, 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='修改时间')), + ], + options={ + 'verbose_name': '侧边栏', + 'verbose_name_plural': '侧边栏', + 'ordering': ['sequence'], # 按排序序号升序排列 + }, + ), + + # 创建"标签"表(Tag) + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), # 自增主键 + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')), # 标签名称(唯一) + ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)), # URL友好的标识符(用于生成标签页URL) + ], + options={ + 'verbose_name': '标签', + 'verbose_name_plural': '标签', + 'ordering': ['name'], # 按标签名升序排列 + }, + ), + + # 创建"分类"表(Category) + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')), # 分类名称(唯一) + ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)), # 用于生成分类页URL的标识符 + ('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')), # 权重值,控制分类在页面的显示优先级 + # 自关联外键:支持分类层级(父分类->子分类) + # on_delete=models.CASCADE表示:若父分类删除,子分类也会被删除 + ('parent_category', models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='blog.category', + verbose_name='父级分类' + )), + ], + options={ + 'verbose_name': '分类', + 'verbose_name_plural': '分类', + 'ordering': ['-index'], # 按权重降序排列(权重越大越靠前) + }, + ), + + # 创建"文章"表(Article) + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ('title', models.CharField(max_length=200, unique=True, verbose_name='标题')), # 文章标题(唯一) + ('body', mdeditor.fields.MDTextField(verbose_name='正文')), # 文章正文(使用Markdown编辑器) + ('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')), # 发布时间 + # 文章状态:草稿(d)/发表(p) + ('status', models.CharField( + choices=[('d', '草稿'), ('p', '发表')], + default='p', + max_length=1, + verbose_name='文章状态' + )), + # 评论状态:打开(o)/关闭(c) + ('comment_status', models.CharField( + choices=[('o', '打开'), ('c', '关闭')], + default='o', + max_length=1, + verbose_name='评论状态' + )), + # 内容类型:文章(a)/页面(p,如关于页、联系页) + ('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目录')), # 开关:是否显示文章目录 + # 外键:关联作者(Django用户模型) + # on_delete=models.CASCADE表示:若作者账号删除,其文章也会被删除 + ('author', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name='作者' + )), + # 外键:关联分类 + # on_delete=models.CASCADE表示:若分类删除,该分类下的文章也会被删除 + ('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={ + 'verbose_name': '文章', + 'verbose_name_plural': '文章', + # 默认排序规则:先按排序值降序(值越大越靠前),再按发布时间降序(最新的在前) + 'ordering': ['-article_order', '-pub_time'], + 'get_latest_by': 'id', # 按ID获取最新记录(ID自增,越大越新) + }, + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/0002_blogsettings_global_footer_and_more.py b/src/blog/blog/migrations/0002_blogsettings_global_footer_and_more.py new file mode 100644 index 00000000..4bb685d9 --- /dev/null +++ b/src/blog/blog/migrations/0002_blogsettings_global_footer_and_more.py @@ -0,0 +1,45 @@ +# 生成信息:由Django 4.1.7在2023-03-29 06:08自动生成的迁移文件 +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + 数据库迁移类:为网站配置表添加新字段 + 用于扩展网站配置功能,支持全局头部和尾部内容的设置 + """ + + # 依赖关系:当前迁移依赖于博客应用的初始迁移(0001_initial) + # 确保在初始表结构创建之后再执行此迁移 + dependencies = [ + ('blog', '0001_initial'), # 依赖blog应用的第一个迁移文件 + ] + + # 迁移操作:为BlogSettings模型添加两个新字段 + operations = [ + # 为BlogSettings添加"公共尾部"字段 + migrations.AddField( + model_name='blogsettings', # 目标模型:网站配置表 + name='global_footer', # 新字段名称 + field=models.TextField( + blank=True, # 允许表单提交为空 + default='', # 默认值为空字符串 + null=True, # 数据库中允许为NULL + verbose_name='公共尾部' # 管理界面显示的字段名称 + ), + # 字段作用:存储网站全局共用的尾部HTML内容(如版权信息、备案号等) + # 可在所有页面底部统一显示,避免重复开发 + ), + # 为BlogSettings添加"公共头部"字段 + migrations.AddField( + model_name='blogsettings', # 目标模型:网站配置表 + name='global_header', # 新字段名称 + field=models.TextField( + blank=True, + default='', + null=True, + verbose_name='公共头部' + ), + # 字段作用:存储网站全局共用的头部HTML内容(如公共导航、统计代码等) + # 可在所有页面顶部统一显示,方便全局修改 + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/0003_blogsettings_comment_need_review.py b/src/blog/blog/migrations/0003_blogsettings_comment_need_review.py new file mode 100644 index 00000000..eb6e36a3 --- /dev/null +++ b/src/blog/blog/migrations/0003_blogsettings_comment_need_review.py @@ -0,0 +1,31 @@ +# 生成信息:由Django 4.2.1版本在2023-05-09 07:45自动生成的迁移文件 +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + 数据库迁移类:为网站配置表添加评论审核开关字段 + 用于控制用户评论是否需要管理员审核后才显示,增强内容管理能力 + """ + + # 依赖关系:当前迁移依赖于博客应用的上一个迁移文件(0002_...) + # 确保在之前的表结构变更完成后再执行本次迁移 + dependencies = [ + ('blog', '0002_blogsettings_global_footer_and_more'), + ] + + # 迁移操作:为BlogSettings模型添加评论审核开关字段 + operations = [ + migrations.AddField( + model_name='blogsettings', # 目标模型:网站配置表(BlogSettings) + name='comment_need_review', # 新字段名称:评论是否需要审核 + field=models.BooleanField( + default=False, # 默认值为False:评论无需审核,提交后直接显示 + verbose_name='评论是否需要审核' # 管理后台显示的字段名称 + ), + # 字段作用: + # - 当值为True时:用户提交的评论需管理员在后台审核通过后才会在前端显示 + # - 当值为False时:评论提交后立即显示,无需审核 + # 用于防止垃圾评论或违规内容直接展示,提升网站内容安全性 + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py b/src/blog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py new file mode 100644 index 00000000..8f8c1ab1 --- /dev/null +++ b/src/blog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py @@ -0,0 +1,40 @@ +# 生成信息:由Django 4.2.1版本在2023-05-09 07:51自动生成的迁移文件 +from django.db import migrations + + +class Migration(migrations.Migration): + """ + 数据库迁移类:重命名BlogSettings模型中的多个字段 + 目的是统一字段命名规范(采用下划线命名法),提升代码可读性和一致性 + """ + + # 依赖关系:当前迁移依赖于上一个迁移文件(0003_...) + # 确保在添加评论审核字段之后执行字段重命名操作 + dependencies = [ + ('blog', '0003_blogsettings_comment_need_review'), + ] + + # 迁移操作:批量重命名BlogSettings模型的字段 + operations = [ + # 重命名"analyticscode"字段为"analytics_code" + migrations.RenameField( + model_name='blogsettings', # 目标模型:网站配置表 + old_name='analyticscode', # 旧字段名(驼峰式命名,不规范) + new_name='analytics_code', # 新字段名(下划线命名,符合Python规范) + # 字段含义:存储网站统计代码(如百度统计、Google Analytics) + ), + # 重命名"beiancode"字段为"beian_code" + migrations.RenameField( + model_name='blogsettings', + old_name='beiancode', # 旧字段名(连写,不规范) + new_name='beian_code', # 新字段名(下划线分隔,更清晰) + # 字段含义:存储网站ICP备案号 + ), + # 重命名"sitename"字段为"site_name" + migrations.RenameField( + model_name='blogsettings', + old_name='sitename', # 旧字段名(连写,不规范) + new_name='site_name', # 新字段名(下划线分隔,符合命名习惯) + # 字段含义:存储网站名称 + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py b/src/blog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py new file mode 100644 index 00000000..18f8d4f3 --- /dev/null +++ b/src/blog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py @@ -0,0 +1,107 @@ +# 生成信息:由Django 4.2.5版本在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 # Markdown编辑器字段 + + +class Migration(migrations.Migration): + """ + 数据库迁移类:统一模型的字段命名和 verbose_name 为英文 + 可能是为了国际化适配或代码规范统一,将中文标识改为英文 + """ + + # 依赖关系:依赖用户模型和上一个迁移文件 + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'), + ] + + operations = [ + # 1. 修改模型的元数据选项(verbose_name 改为英文) + migrations.AlterModelOptions( + name='article', + options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], + 'verbose_name': 'article', 'verbose_name_plural': 'article'}, + ), + migrations.AlterModelOptions( + name='category', + options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'}, + ), + migrations.AlterModelOptions( + name='links', + options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'}, + ), + migrations.AlterModelOptions( + name='sidebar', + options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'}, + ), + migrations.AlterModelOptions( + name='tag', + options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'}, + ), + + # 2. 删除旧的时间字段(中文命名相关) + migrations.RemoveField(model_name='article', name='created_time'), + migrations.RemoveField(model_name='article', name='last_mod_time'), + migrations.RemoveField(model_name='category', name='created_time'), + migrations.RemoveField(model_name='category', name='last_mod_time'), + migrations.RemoveField(model_name='links', name='created_time'), + migrations.RemoveField(model_name='sidebar', name='created_time'), + migrations.RemoveField(model_name='tag', name='created_time'), + migrations.RemoveField(model_name='tag', name='last_mod_time'), + + # 3. 添加新的时间字段(英文命名) + migrations.AddField( + model_name='article', + name='creation_time', # 新创建时间字段(英文命名) + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='article', + name='last_modify_time', # 新最后修改时间字段 + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), + ), + migrations.AddField(model_name='category', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time')), + migrations.AddField(model_name='category', name='last_modify_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time')), + migrations.AddField(model_name='links', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time')), + migrations.AddField(model_name='sidebar', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time')), + migrations.AddField(model_name='tag', name='creation_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time')), + migrations.AddField(model_name='tag', name='last_modify_time', field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time')), + + # 4. 修改所有字段的 verbose_name 为英文(仅列举部分代表性字段) + migrations.AlterField( + model_name='article', + name='article_order', + field=models.IntegerField(default=0, verbose_name='order'), # 原"排序"改为"order" + ), + 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'), # 原"作者"改为"author" + ), + migrations.AlterField( + model_name='article', + name='body', + field=mdeditor.fields.MDTextField(verbose_name='body'), # 原"正文"改为"body" + ), + 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'), # 状态选项和描述均改为英文 + ), + # ... 省略其他字段的AlterField(均为verbose_name改为英文) + + # 友情链接模型的显示类型选项修改(中文场景改为英文场景) + 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' + ), + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/0006_alter_blogsettings_options.py b/src/blog/blog/migrations/0006_alter_blogsettings_options.py new file mode 100644 index 00000000..eec61592 --- /dev/null +++ b/src/blog/blog/migrations/0006_alter_blogsettings_options.py @@ -0,0 +1,30 @@ +# 生成信息:由Django 4.2.7版本在2024-01-26 02:41自动生成的迁移文件 +from django.db import migrations + + +class Migration(migrations.Migration): + """ + 数据库迁移类:修改BlogSettings模型的元数据选项 + 将模型的显示名称从之前的命名(可能为中文或其他语言)统一改为英文,适配国际化需求 + """ + + # 依赖关系:当前迁移依赖于博客应用的上一个迁移文件(0005_...) + # 确保在之前的模型结构调整完成后再执行本次元数据修改 + dependencies = [ + ('blog', '0005_alter_article_options_alter_category_options_and_more'), + ] + + # 迁移操作:修改BlogSettings模型的元数据选项 + operations = [ + migrations.AlterModelOptions( + name='blogsettings', # 目标模型:网站配置表(BlogSettings) + options={ + 'verbose_name': 'Website configuration', # 模型单数显示名称(改为英文) + 'verbose_name_plural': 'Website configuration' # 模型复数显示名称(改为英文,因配置通常为单条记录,复数同单数) + }, + # 修改目的: + # 1. 统一模型显示名称为英文,适配国际化场景(如多语言网站后台) + # 2. 使模型名称更符合英文开发环境的命名习惯,提升代码一致性 + # 3. 之前的版本可能使用中文(如"网站配置")或其他命名,此处统一规范化 + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/__init__.py b/src/blog/blog/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blog/blog/migrations/__pycache__/0001_initial.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0001_initial.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6dbcf70de57b3ed433bfe5d2cc5c1bece2bf89e7 GIT binary patch literal 9174 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 HQC@!q`^uyW literal 0 HcmV?d00001 diff --git a/src/blog/blog/migrations/__pycache__/0002_blogsettings_global_footer_and_more.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0002_blogsettings_global_footer_and_more.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb650fa82de46c7a1e49b6bce4905ca655bc2246 GIT binary patch literal 1007 zcmb7CO=uHA7@gVOYarq@WZ9(O&Fn2@JED$!632iL)DNZi+2> z?5#IX9xCmjf_N0~UMvU+bCn)?GLVYUlQWydq)-$GX7{~szQ1|jd?^+!AlBlWAN4H- zfbWuwR&EIT%L42G0tgI12pt$Ijsk%K9sr_j0-{pb&__Yz|DHO)j@s7j+_5CK!mZHv z7*72-O1K`zga%1<90NdpNctZI*Z~wckN`&^(1VVu0ID^zh9^USXoJWzy>a2JT{5DP zoY?yQ)Sfmt-8V+9{Ux9Ik9^j7`8>rj+IhRc&Bvo#iYsew5PQ5(E|+g2Kk`!_2fQF{ z2~AT!@)BP3g4o3Ys>N|i*%}9}f@t+m&t$pUXbhyts&$qkgnA;c|&M4nba z1uh`;w1oqiOEN;6G$J&r`ZQs3#N4<`$bDIZNrAb!MmN%ob1QDr;jrIQYHqb zc4rURXYK0~{H^4kOlhX|zwX6+Nq_N>&5ycR6%#fG5-brn6E>4`(C3_V%X}{hbZXog mOlO&078Ht|$itorA^fESXrJgaP~WyX(~F1Gi$8%NS@dsV><=LT literal 0 HcmV?d00001 diff --git a/src/blog/blog/migrations/__pycache__/0003_blogsettings_comment_need_review.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0003_blogsettings_comment_need_review.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90ae061c43a88246cef6dc2bef853ba3641d35d3 GIT binary patch literal 869 zcmZuwL2DC16rS1LY&L5uH40VIV^Yk)67(jeU?>$t`vXE`*koR|8+Lcn-K3gZ3pseq ztsX@5Qk0hVD*gpM7!VTXBuGnoGLVYUlQWxyw9q-se&3t-X1@8}?6f}SF(v=LPS*Ys)8?0cY&aZzn)x!9Q`Ifa{^ zV+U5lVSdPUk5O>L%B%(JZENXVdaO|RX&8kdY} zG&Aa00!Et+V^!4=Z@kwX@?A^{_fA{3*b9Ks?~HGJh=@)nT0@n%Y&RMtdjObjB7H+V^s%Sx*klo4{--0H VJJ)Yuy<_&~Gu`>jUnEFW{U5W<_E!J^ literal 0 HcmV?d00001 diff --git a/src/blog/blog/migrations/__pycache__/0004_rename_analyticscode_blogsettings_analytics_code_and_more.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0004_rename_analyticscode_blogsettings_analytics_code_and_more.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3fd37da2ed0b982a484d92e7154bda3ede2ebe5b GIT binary patch literal 946 zcma)4y^GX96rV}5*(7%>i@2PM+z0FyDLfXm@em6+6hyDE3=2ax^T=gMCTo)OOs8;# zmDpXSy=d(}VRh9lQwdg9F6>=%l{1t55YiCT z?h3b`%(8YhZuH|cF1BU1sox&-}P7q96}a z3jEUC6tNUsUC1N$m@q#9Zly7uA_j*QvilJEOa(g%sz7e#Q2{E*mPm2;+&Z_2f`o8~ z5OMWE3<9qb^6bEmC0|+;hQKJWAc8!TP2($1)nyMNh$(l{Axt~=0Zn8BF5d0#^mYe6 z3)B0e{+s2ClJql?)_#wwQ(1E_&w^!A;^zfqe>hR(#jofIU2hBZRCaTHWW~RVJ$ESu zh|j1fFoW%3naNM97E^$niscA>weE~{?TY#G@z_E3#%t%)T0gPYM{Au?t37IU&gzEk z{O;NuwYL7^+8DJqE7w}{via&!c5U|To@k_9DX~raO42H)tCFE;*9E$;?p7_im)#I1 g7BiP%tYeJNO@z13%oaA^I;YL{Nwa;9grTB;0$s2Hf&c&j literal 0 HcmV?d00001 diff --git a/src/blog/blog/migrations/__pycache__/0005_alter_article_options_alter_category_options_and_more.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0005_alter_article_options_alter_category_options_and_more.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c811d2b356667e91c33f71196877b6182123e91 GIT binary patch literal 11005 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 MZR_}%!B2$iKLX=dxc~qF literal 0 HcmV?d00001 diff --git a/src/blog/blog/migrations/__pycache__/0006_alter_blogsettings_options.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0006_alter_blogsettings_options.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d142fe119551cb9635e39f0e7be207679cb8d06a GIT binary patch literal 786 zcmZuvzi-n(6nw?%(`72mi$kggqCMF`KT{>}RJ4y!5()oSwzW3|9`?xJn}0OuBI%@Y#Qi*GnFtqL z0ILSGU1gjB1rHJM41yx$nFi3tuxxTU1XOuqs;}|I_})A>sx!s63ah&!?frSBt|1#i z!Xnx4^?Hx7pX8KdpXYIyP|QYUGqpGLa~d&TEZiBvDdSYObp}E6JkBCyel3zq>HO9!iZWUM;FvW@ZE zv7czRMud)NMransRB)}XTz{r#*=K|%ht(X?VIx`#{0(MWlbiQ@{lVVQ&my*~W_VZ} z7dYU479I^q>D7}B1P_-f}+#VHNKcaHemn 父分类 -> 顶级分类 + """ + categorys = [] + + def parse(category): + categorys.append(category) + if category.parent_category: # 若存在父分类,继续递归 + parse(category.parent_category) + + parse(self) + return categorys + + @cache_decorator(60 * 60 * 10) # 缓存10小时 + def get_sub_categorys(self): + """ + 递归获取当前分类的所有子分类(含多级子分类) + """ + categorys = [] + all_categorys = Category.objects.all() # 获取所有分类 + + def parse(category): + if category not in categorys: + categorys.append(category) + # 获取当前分类的直接子分类 + childs = all_categorys.filter(parent_category=category) + for child in childs: + if child not in categorys: + categorys.append(child) + parse(child) # 递归处理子分类 + + parse(self) + return categorys + + +class Tag(BaseModel): + """ + 标签模型 + 用于文章的标签管理(多对多关系) + """ + name = models.CharField(_('tag name'), max_length=30, unique=True) # 标签名称(唯一) + slug = models.SlugField(default='no-slug', max_length=60, blank=True) # 标签的URL标识符 + + def __str__(self): + return self.name + + def get_absolute_url(self): + """生成标签详情页的相对URL""" + return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) + + @cache_decorator(60 * 60 * 10) # 缓存10小时 + def get_article_count(self): + """获取该标签关联的文章数量(去重)""" + return Article.objects.filter(tags__name=self.name).distinct().count() + + class Meta: + ordering = ['name'] # 按名称升序排列 + verbose_name = _('tag') + verbose_name_plural = verbose_name + + +class Links(models.Model): + """ + 友情链接模型 + 存储网站的友情链接信息 + """ + name = models.CharField(_('link name'), max_length=30, unique=True) # 链接名称(唯一) + link = models.URLField(_('link')) # 链接URL + sequence = models.IntegerField(_('order'), unique=True) # 排序序号(唯一,用于控制显示顺序) + is_enable = models.BooleanField( + _('is show'), default=True, blank=False, null=False) # 是否启用(显示) + show_type = models.CharField( + _('show type'), + max_length=1, + choices=LinkShowType.choices, + default=LinkShowType.I # 默认为首页展示 + ) + creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间 + last_mod_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间 + + class Meta: + ordering = ['sequence'] # 按排序序号升序排列 + verbose_name = _('link') + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + +class SideBar(models.Model): + """ + 侧边栏模型 + 用于展示网站侧边栏内容(支持HTML) + """ + name = models.CharField(_('title'), max_length=100) # 侧边栏标题 + content = models.TextField(_('content')) # 侧边栏内容(HTML格式) + sequence = models.IntegerField(_('order'), unique=True) # 排序序号(控制显示顺序) + is_enable = models.BooleanField(_('is enable'), default=True) # 是否启用 + creation_time = models.DateTimeField(_('creation time'), default=now) + last_mod_time = models.DateTimeField(_('modify time'), default=now) + + class Meta: + ordering = ['sequence'] # 按排序序号升序排列 + verbose_name = _('sidebar') + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + +class BlogSettings(models.Model): + """ + 博客配置模型 + 存储网站的全局设置(单例模式,仅允许一条记录) + """ + 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='') # SEO描述 + site_keywords = models.TextField( + _('site keywords'), max_length=1000, null=False, blank=False, default='') # 网站关键词(SEO) + article_sub_length = models.IntegerField(_('article sub length'), default=300) # 文章摘要长度 + sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) # 侧边栏文章数量 + sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5) # 侧边栏评论数量 + article_comment_count = models.IntegerField(_('article comment count'), default=5) # 文章页显示评论数量 + show_google_adsense = models.BooleanField(_('show adsense'), default=False) # 是否显示谷歌广告 + google_adsense_codes = models.TextField( + _('adsense code'), max_length=2000, null=True, blank=True, default='') # 谷歌广告代码 + open_site_comment = models.BooleanField(_('open site comment'), default=True) # 是否开启全站评论 + global_header = models.TextField("公共头部", null=True, blank=True, default='') # 全局头部HTML代码 + global_footer = models.TextField("公共尾部", null=True, blank=True, default='') # 全局尾部HTML代码 + beian_code = models.CharField( + '备案号', max_length=2000, null=True, blank=True, default='') # 网站备案号 + analytics_code = models.TextField( + "网站统计代码", max_length=1000, null=False, blank=False, default='') # 统计代码(如百度统计) + show_gongan_code = models.BooleanField( + '是否显示公安备案号', default=False, null=False) # 是否显示公安备案号 + gongan_beiancode = models.TextField( + '公安备案号', max_length=2000, null=True, blank=True, default='') # 公安备案号 + comment_need_review = models.BooleanField( + '评论是否需要审核', default=False, null=False) # 评论是否需要审核后显示 + + class Meta: + verbose_name = _('Website configuration') + verbose_name_plural = verbose_name + + def __str__(self): + return self.site_name + + def clean(self): + """ + 数据验证:确保仅存在一条配置记录 + 在保存前调用,用于防止创建多条配置 + """ + if BlogSettings.objects.exclude(id=self.id).count(): + raise ValidationError(_('There can only be one configuration')) # 仅允许一个配置记录 + + def save(self, *args, **kwargs): + """保存配置后清除缓存(确保配置实时生效)""" + super().save(*args, **kwargs) + from djangoblog.utils import cache + cache.clear() # 清除所有缓存 \ No newline at end of file diff --git a/src/blog/blog/search_indexes.py b/src/blog/blog/search_indexes.py new file mode 100644 index 00000000..70c4e08e --- /dev/null +++ b/src/blog/blog/search_indexes.py @@ -0,0 +1,32 @@ +# 导入Haystack的索引模块,用于定义搜索索引 +from haystack import indexes + +# 导入博客文章模型,作为搜索索引的数据源 +from blog.models import Article + + +class ArticleIndex(indexes.SearchIndex, indexes.Indexable): + """ + 文章搜索索引类,用于配置Haystack搜索的索引规则 + 继承自Haystack的SearchIndex(搜索索引基类)和Indexable(可索引接口) + """ + # 定义主搜索字段: + # - document=True:标记为主要搜索字段(Haystack默认以此字段作为全文检索的基础) + # - use_template=True:指定使用模板来构建索引内容(模板通常存放于templates/search/indexes/[app名]/[模型名]_text.txt) + text = indexes.CharField(document=True, use_template=True) + + def get_model(self): + """ + 必须实现的方法:指定该索引对应的模型 + 返回值为需要被索引的Django模型类 + """ + return Article + + def index_queryset(self, using=None): + """ + 定义需要被索引的数据集 + 筛选出状态为"已发布"(status='p')的文章,仅对这些文章建立搜索索引 + :param using: 可选参数,指定搜索引擎(多引擎场景下使用) + :return: 查询集(QuerySet),包含需要被索引的模型实例 + """ + return self.get_model().objects.filter(status='p') \ No newline at end of file diff --git a/src/blog/blog/templatetags/__init__.py b/src/blog/blog/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blog/blog/templatetags/__pycache__/__init__.cpython-312.pyc b/src/blog/blog/templatetags/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a73bf38867f594b9b062d2a859ebbde6a9678197 GIT binary patch literal 202 zcmX@j%ge<81S<31XMpI(AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdmFZ#?KECB8UIZ6Nk literal 0 HcmV?d00001 diff --git a/src/blog/blog/templatetags/__pycache__/blog_tags.cpython-312.pyc b/src/blog/blog/templatetags/__pycache__/blog_tags.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f17209d5e4dd250d3c4ab4c56e03f0292dfd18a6 GIT binary patch literal 14753 zcmdTrZE#c9mG9|WmSjn`RVaLz{G_J0aO7O}9JS%ywqiiaaCnVy4+CCNQ%zH6@+dW_M=y zocr`7S%^TozxIOeJNMpm&;2_0+;h&o{+-EG#6W2L!5;^I>tdKcU_uVcG;q_SVwh75 z$8cp)GXpCgPM@mqouGas0-;mdJ3z9MInR7Kw(YL z7&3WG6xIfdLuQYe!n&X(Wc64ntPhriN&oT;If{mdjPZP^1 z7(YAIBTq#3>tFr`E2TUwfTJFIC|f2&Z=?aw3jd0sN9Ek?m$P0dk*RQ{&oCMMm4N?9 z7EgvaTMka!ZE);4IBvl4%C)m!xgEcWE6ZZbxt>+GpC{Zjr0W*7DqQ8|>N)c3lqk`rBYTGuZVx*c)zx z-H^dvmV>?VHrS0B?4}&-&f8!&XRw#&U~dBK$K{#Gez}!g%QI|dwKm@t%awz*1+WV7 z>WZB9w%&$PYYxu6$5~g~b?nSO7c1%ne`q-96Z{DCenAL?2Y7(l2O+W!_yupIcgWud zAL8v;)d87CULb(f9|#J50%;8|_=G?oKw9E|!cTZ4r4RYY5ijrS_q#Nrwu=Bl&@bwC zKtlr&a$Hm%^bLsWot zDi;|IizeO|4hVrUzjsgw1p!yx=j$89N*3&M-v}X4(aQ(u!1VjOMufqKdD>s~U?g&c zKM1HM!FR;Zd;20`!GBEXON$x0Oj?Lh7~k9u(J4mAlnJtkW`;mS=Le4E6jA1lG>*&? z2N;)fpQs=7`#5jNFZe`A6FdY+IYgq#H#{6X?v*AK=U$uc5s$4pKOgGuJk&kp3lBuL z2O|S*$B!NN_7Y#XZ}1RDb9*885KsDYkwQKmR;NG?MV8lG@D1>XFvp9Kd-yo1!d_wI zfFF<11ark;d+Fh%y(MmMIamEw@5SW_`}Txk$E<3{O(5Qv0D4b0Ji zpNMKdArW$3A*whZtRw5^aUf17sm0nwNT@$TLOy}4fQ)+h@tqJ&FpDacx+G;TJuyBx ze&(ymn$_`|)d};OS>2ldoHyA1#3X5vrce2TpH2t}WAzj&5OE6`XnqiV>Ye7qHayWjt?_tV@ie-AL{*?sbM zF9Bkr5d{>AM-*h|<=q|@q}?uYlDTh$7b2ky8tH(VYvIS^-ke}Q(=bN+OOGe*tK#-m zZ;nKaN)S*%Cd7`jsBuNvLfbDph(o~L7x?Rr6M>i2~X7<;oxY1 zG9GuM6M{4yRHn;QM(g6P+6X@$nPq}mP_?Hl9aF_gOGn(&@z$dW%a*71PISkX!RDGe z@WR$vRpqTylUd_~KqGgJhj3JRAv-DKjDR(1$eEY|D^FGd5{wXMhpfhk3jI=q)X295nShy+ZlACLi#jbd<>)i50@tTR@cyZ*-+S)UH)a;T`Ru~a-ud+Jp1FE*`r2zx(eln=AMu5p^neQsol+k03nL^f zCDI%vmI!1d+y}e8Z_r10ddkCIp^QKethB1)Qj4_`)4AnC&CU7yeIr33eV&qgp>O0K zjJhBaOMH?t;^jwry+MC?Kp2#nkYZ9K1GXV+LpXIfHi;MQOFNbx3E2rqyWz*9;F@6m zY+=;f*~_Zcmr5EEC5;pJUozB856nE7T)s2Dd}qS2YgV=ER*uYU)t@aJAR2pB z3{Jhg1;rv%%ZBMee*Jq;9h4?(dS>WWbfcm~u!}B*wC8AdaTVpE>Exl2`ylsW`0|rBQO+6~XH|`K0ArOnSM8`kwD%HFar($esF$|3gaI?V?JAHw zbsN1P!+IDv6OR&m$S-pePj3GVk86~WaZe$xIZrK>uzyHI97oT1D`N`ZZk!H)=Z3%*u zK4;taUH=~l*YT8bDMfYI7xMF>S_lZBUaR{eBcLtoCA}Rt59-=pf%yH|4M6NrRn%~i zkS_p=4d|Z!FemC!Q}!Y?@)ays!p?xbrA_yW?1xOce@HhLs}P;XCB==A-Wld#1QgsX z50J;8{4o6ZVTeEnDrQW!q_HV(Y)TlHPwdRu9rK3jgrO#BXp9>gC%RKA{c}A}^-Q%U zOP0q=mY*w~wYJTv+$p2=WaHHKsm96sCw4Asn3D38wc=FG$(nhqZR+9gR89L%*51&n zjG9G8rPfHyS(jx_dEW!4dV>L8xRpiq&`l~M{~G|xqVUZRLx_X4LKy0AR(s!&3RN?$N2NpW8< z9|?{C(~Xdz#ISNL%Ebf^Wl;lm4sfcd=^Km$`qE0eef$3X2M-*4sOu}<9s7IsKjc05 z*jIKQ0L>CTKjaYB(_>?_Wl$%;(o{ktHYd>knM3AX+%<6`TSUkEr4R zBd7qNp%9mH8BGxUcpxm%4>_)+8iX>-;X+2(780OC838J{1fo-OK`Zx)(?wN7s6FL{ zDrQ_Qpk+iuLCR=Z=Y>qKG(v@3RIqNLUg<9s$-Npv+c7V22Dx-x`+pmALFz`C`WMWI z?hftNoZ0^L_0Gt03RB-(&tNsm2yGBw!erMjht@W6g83y zLc2^Onq6(xk!}!1KZqK;nG?l*iUFoiaTxf1yf_N~hE~fpvR|3U`xPe@FP5nQf4n%S zF4!Mfm$RW_!?Q2yqs4${R59b`@nQijF&d)goH{Ve**bu&m#AiD-V(KNsv|J_yrIw- zcm-z*o1&INWn*xLJzoOSLl>+Xw+eXX&>(D-p;$?*v|qv5pV39lvc-{U*#!29bqGzt zybv|XIH1g_qnfBLs*f6?MX-t}-0}kCqLrT)Aa`3{79=5edx2b2RLhl}F^(&;j=~{W zx%?nYU68Wn@}6VwBxfszP*KkdGNr6PKWD^Cq<9}#b;t#y5Z?;9szz{+72%P%@chiR z_ujkm{_EG?f9LAV37XfLYnzGM^Z_KQM!=UkX2i48na-nz8`V-pb{MQs>J}d1_o1Uz z4;HCE?CV7rFz-J);t%)v$24tE1pK4CXzBy20ljQqbnJ*~aPta-qN;o6fgMm)9U&Zb z+v<6640{Fe438<>0^D^}tOz>4M_hMzdb^8Yo z#anmBYxhV7y-_q1e;<0Y(^?8ITGK6fWgVXvOF|J|@S^>dMKj476?)Bh(UPepxf)>V zA;$*_1!zj>h%cp6JP?Tt1pQtg$NR&)U$iYvgSVUmTGj}d4b;638PrJ+81XlsbdkZ%~a z&)`UZf6z~!LcDKa^fX4#Af6It0{Tw9$1WQQV4e=w1zBEHKj90G__G!mc@ir|G5RJ( zsH=)d`JkGPE9OuT{wn?53y4~gV zBQt=DP4oW@is)a!KV@W0B`5Yy?wtzEw8YC+B#f;SJ1=RCsR~C-9qXSorOdXN@d`M( z4U>kceRH~Hmv!qdl{F{IT4ILzs`^*jPq!zlR>iAUrAo^dl}z>On+#iBk*esNu1;2V z#w$A)m2CCqnE8sMG3i(tcdSf0R>d8w5)Sa9SI$>DU#UA?m#l1$SGK3hs(?V%Dj-nh z_`Rd?^qv_{!m$eS^yOD-8ej3A_9koA#%tDITsvE{Hc@kLvSv@bW>3m!yQySr%Ti?< zrk+feZ-|#~fF^4@XLVIqEaj)nC(TKVD{gU}YrnYu!{%9wD`9ysrk=OhPnk}dl9tA} zrSY6FYiUeaI+B)6am%K8qv?!tde6(IIb-t$>n{$y{rJxxpL2D`jor|Wbr%~`U#X~j z#eCYFtZ>IG+!t-L74AgE)?~%*c*Sn8b^)uhG*!B8>i(o{UEH<~dQ-VRW?IxS_Oh6M z-du9x$;l^Ae0}ok39~b%N|jf~jF$}Nmv&66zO#3R{q_Twx^^ZUyMArE*fs5&u_o&~ z;`JSg`g=aAS^t6UBgd|UWq;hUYwGBS^&fWquJPN}S;wwe*Q{m#ysiA~(L{yo?9rL} zY3uarmu)jg;}x!iZAENns@wsT$1aqfJ9we;r1hfxpGrSC_?yc3#`|L1KQ>m*SJ%y3 zO6P2QKCDkzdZ7P~eQd1f6Ju3u*NJ_T`=&R~8C$?|cebbM*Q6RcQZ)@JXB)=L+{tCz z)|9g~>D(N5ZcaH`Qnf2@RGO?sG4*E-MqhkFKdGN` zO%KLPn$M|c+wYyzZNp0x;ICQR>ZEN$+_vH3V;@$;w>&s&+mNt5G^abTlpFu@r?pJY zZuT!9S@!=$tZ?yY7W}rYvPY}>&`{K4RDD>c!nn!UW7Um0vv!y)QSdU)i^AwuiwqA4 z{M|%(gU1j!mDn?2Re^1dE=GE~=6Xw!)|FqKz53Lf(!nb^O4BVB@Cuz*NcJuY3i1R- zKZIz>ZAnS4ZA)(+dGaGb`bR{>y$;r`g|StqEVdhJB^sSd+32jlb+E&}fk2oZ)Y52i zbaK!DKu7zp5ToOATnRsL!Er2VhUD?W*MN8)Vjy);o=Z>4&b-qAYY(L{4V;2gM%4lK zf=bp0p&h1>g}Q`#0Y8ddKCa25u317|38PTcQ9PUVxnymvsN#Yq(-W|u3te~0Es+&p zVw_sgT(yPl2~KxL@g+u&ZQkwZwV*5CO`A9;dO9axyTgc`Ut+{XFk%H~_)?=o`gc3J zA{bpheH@ALmetMp%$BUK?ha%9GOKHXvFZx0Zt<5I|Cd=^6Iw!d(I)sU!N5g{ zN`=E6>jCvZXPvdRe57reFRpu4d9G!ycrCbAzo^ZWrTG)b@|1Jg>$Bs!P^UGg4jr{I)8Kq* zM6qK6@BD(19ITcNZOidaZf&9P41z#Y|;W%I1Lo`S&Q* zx9S5~y(jnN#&eo#0@A~Y%zJ}dJj19FMHS#rQ9+Hoz$wqD&=?;1S`RB}tl7fTyCW^Xl}Q3sbKx#7CW|Pd;2_Dl6F`ZT7Yd!kQRYIHjw-4m%X74-%*o9u zp8?;RtVg{81II9~j;e_{s^(Pax+!$ebpSN$AvEOk0;kTp!;Mz(eik~pNeiW)1Pt1 zB=co}_@02raAe`tUo1R*VoaY-3+?E=^TRE3aEXTw8I5}A@TTE*m#iE!0@skz`x$u9 zG92iG7b2i*kWr|V^K6D7ACJ|_UBIImoNaktx3##9@MxW2W_+&)&IaBR;?0|=bB%88 zG4+Vh-?m|_l*+h3XrNshkb5{hFyd&sr_^IUU+K3a^w|g zAu#hI^v8itEg3i8t3{pzJeQsNw8$jDqzt1MFoMbqc?qLmK_qIxPYT{PqQit1l!(D# zpch<8+&WPmjEus2M0itrj0+6-!Na71v173s#2>ir^pH$fpF)DCFlxf+d5oqZa%stX z@WIRcF>uUM?;H5rh#xbH(8C*o)`PsrMnrA;Q4J5b0kRU2_6o@Z#mOhAHn?!UZvp`3 zrG~LoPd^+tH%#oAFEXESO}eJ`B#LS#c3jdHrOYL$+sNq{=I%ttW+3(UZ|x%d!-BUyLV>)9~V} z3EU|q6|zp8$x8oVec?w`E>g$KYF(OLcb>c-O>U*2>B;N|lz?l+C+ zjmeg+@s_P0JUVyak=aN8CUM|U{NBTJEsujnV*VN%Q(n=TsF$H-ddG}oPUp_}7uF6W;r7X=vgboP++rz@Pvy*WertaKa!3w*oe#3lcTq})E)B;h%G@twC*vJ19IAE%FZhK@@G zP2)uwZP*Ew!1YS2I0NCrI8LYA?Os|3p3Y#kG%4za0U?Fk7-WLtC{JDkaEVVwO~*jy zApbs`X>>VJH;mC9TXSOLvMfGLs6h=}Eo{fTheNGfMVc$etc3C4qt1 zv2u8>hV}sXar{m&+EzQ8@v{axjZy+St-@RyuUXP>jgRBd7oGyNhq2>$I7$yu%`xGH zjW6-j9p5<)dj71+N&D?$uY>1@acv{Nfsd%g7u2v|lFRwGn5B`Prx6^GXh^?RgAVqm zUk9gixL&s;ha``D`i-d@ZX1ZuHzM!^cNp1@5eg@b6yBrvBh1$Y_VibYg2MC+K~ z!6!+6`U=Y!LAI1}@f;82C$}a0${Qy+zk~sln{^N|z$q zo&fw+0Q_l%j`#=Q$^lM?B0d0r+u_IS7wKXD3T)nu5j|--5bDGTWfOhENMHVmCb;Md zj_~wO4(dLJrTrMqVDux5-p1%%jHsAF$w8T#GKl1lMCC@lAcGYg3HrB^|A0J{5&TKe z13<{K?8i(o{crl1F)c9Fml@|}ruH)9zRaw@%(Pu*>LC7<=}9s@mzib1XF4x44KyF( z6`$y+y&<7*oX~t?sE%#OzSPmd0i-BOvbH#Do9UQkZ3(vJhJt0g*_%p6 z;aa31_|s*a8wxdBf<^X4gupxcMJ3PGJyn-rOH!-}X>P)L#TbV<0V<(P+a{omTH1!5-A(&V8>w4VVS2Hm0HTs@%FqKmNYW;gYKlp;tR3sGSwv{@ z33&Mf6D<+wOt717DD)D6&5H=pe#+&W671$1iY`_{*s@5$#XX7~66+G|JvS6)31j^t zLbQ)KZGTeO*ivlUx`@!C8|q2 技术 > Python > 文章标题) + :param article: 文章对象 + :return: 包含导航层级和标题的上下文 + """ + names = article.get_category_tree() # 获取分类层级列表 + from djangoblog.utils import get_blog_setting + blogsetting = get_blog_setting() + site = get_current_site().domain + names.append((blogsetting.site_name, '/')) # 添加首页 + names = names[::-1] # 反转层级顺序(从顶级到当前) + + return { + 'names': names, + 'title': article.title, + 'count': len(names) + 1 + } + + +@register.inclusion_tag('blog/tags/article_tag_list.html') +def load_articletags(article): + """ + 文章标签列表标签 + 生成文章关联的标签列表,包含标签URL、文章数量和随机样式 + :param article: 文章对象 + :return: 包含标签信息的上下文 + """ + tags = article.tags.all() + tags_list = [] + for tag in tags: + url = tag.get_absolute_url() # 标签页URL + count = tag.get_article_count() # 标签关联的文章数 + # 随机选择Bootstrap样式(如primary、success等) + tags_list.append(( + url, count, tag, random.choice(settings.BOOTSTRAP_COLOR_TYPES) + )) + return {'article_tags_list': tags_list} + + +@register.inclusion_tag('blog/tags/sidebar.html') +def load_sidebar(user, linktype): + """ + 侧边栏内容标签 + 加载侧边栏所需数据(热门文章、分类、标签云等),并使用缓存优化性能 + :param user: 当前用户 + :param linktype: 链接显示类型(控制友情链接显示场景) + :return: 侧边栏数据上下文 + """ + # 缓存键:区分不同链接类型的侧边栏 + cachekey = "sidebar" + linktype + value = cache.get(cachekey) + if value: # 命中缓存直接返回 + value['user'] = user + return value + else: # 未命中缓存,重新计算并缓存 + logger.info('load sidebar') + from djangoblog.utils import get_blog_setting + blogsetting = get_blog_setting() # 网站配置 + + # 侧边栏数据查询 + recent_articles = Article.objects.filter(status='p')[:blogsetting.sidebar_article_count] # 最新文章 + sidebar_categorys = Category.objects.all() # 所有分类 + extra_sidebars = SideBar.objects.filter(is_enable=True).order_by('sequence') # 自定义侧边栏 + most_read_articles = Article.objects.filter(status='p').order_by('-views')[ + :blogsetting.sidebar_article_count] # 热门文章 + dates = Article.objects.datetimes('creation_time', 'month', order='DESC') # 文章归档日期 + # 符合显示类型的友情链接 + links = Links.objects.filter(is_enable=True).filter( + Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A) + ) + # 最新评论 + commment_list = Comment.objects.filter(is_enable=True).order_by('-id')[:blogsetting.sidebar_comment_count] + + # 标签云(根据文章数量计算字体大小) + sidebar_tags = None + tags = Tag.objects.all() + if tags and len(tags) > 0: + # 过滤有文章的标签 + tag_with_count = [(t, t.get_article_count()) for t in tags if t.get_article_count()] + if tag_with_count: + total = sum([t[1] for t in tag_with_count]) + avg = total / len(tag_with_count) # 平均文章数 + increment = 5 # 字体大小增量 + # 计算每个标签的字体大小(与平均数量成正比) + sidebar_tags = [ + (t[0], t[1], (t[1] / avg) * increment + 10) + for t in tag_with_count + ] + random.shuffle(sidebar_tags) # 随机排序 + + # 组装侧边栏数据 + value = { + 'recent_articles': recent_articles, + 'sidebar_categorys': sidebar_categorys, + 'most_read_articles': most_read_articles, + 'article_dates': dates, + 'sidebar_comments': commment_list, + 'sidabar_links': links, + 'show_google_adsense': blogsetting.show_google_adsense, + 'google_adsense_codes': blogsetting.google_adsense_codes, + 'open_site_comment': blogsetting.open_site_comment, + 'show_gongan_code': blogsetting.show_gongan_code, + 'sidebar_tags': sidebar_tags, + 'extra_sidebars': extra_sidebars + } + # 缓存3小时 + cache.set(cachekey, value, 60 * 60 * 3) + logger.info(f'set sidebar cache.key:{cachekey}') + value['user'] = user + return value + + +@register.inclusion_tag('blog/tags/article_meta_info.html') +def load_article_metas(article, user): + """ + 文章元信息标签 + 加载文章的元数据(作者、发布时间、分类等) + :param article: 文章对象 + :param user: 当前用户 + :return: 包含文章和用户的上下文 + """ + return {'article': article, 'user': user} + + +@register.inclusion_tag('blog/tags/article_pagination.html') +def load_pagination_info(page_obj, page_type, tag_name): + """ + 分页导航标签 + 根据不同页面类型(首页、标签页、分类页等)生成上一页/下一页链接 + :param page_obj: Django分页对象 + :param page_type: 页面类型(如分类标签归档、作者文章归档等) + :param tag_name: 标签/分类/作者名称(用于URL参数) + :return: 包含分页链接的上下文 + """ + previous_url = '' + next_url = '' + + # 首页分页 + if page_type == '': + if page_obj.has_next(): + next_url = reverse('blog:index_page', kwargs={'page': page_obj.next_page_number()}) + if page_obj.has_previous(): + previous_url = reverse('blog:index_page', kwargs={'page': page_obj.previous_page_number()}) + + # 标签页分页 + elif page_type == '分类标签归档': + tag = get_object_or_404(Tag, name=tag_name) + if page_obj.has_next(): + next_url = reverse('blog:tag_detail_page', + kwargs={'page': page_obj.next_page_number(), 'tag_name': tag.slug}) + if page_obj.has_previous(): + previous_url = reverse('blog:tag_detail_page', + kwargs={'page': page_obj.previous_page_number(), 'tag_name': tag.slug}) + + # 作者文章分页 + elif page_type == '作者文章归档': + if page_obj.has_next(): + next_url = reverse('blog:author_detail_page', + kwargs={'page': page_obj.next_page_number(), 'author_name': tag_name}) + if page_obj.has_previous(): + previous_url = reverse('blog:author_detail_page', + kwargs={'page': page_obj.previous_page_number(), 'author_name': tag_name}) + + # 分类页分页 + elif page_type == '分类目录归档': + category = get_object_or_404(Category, name=tag_name) + if page_obj.has_next(): + next_url = reverse('blog:category_detail_page', + kwargs={'page': page_obj.next_page_number(), 'category_name': category.slug}) + if page_obj.has_previous(): + previous_url = reverse('blog:category_detail_page', + kwargs={'page': page_obj.previous_page_number(), 'category_name': category.slug}) + + return { + 'previous_url': previous_url, + 'next_url': next_url, + 'page_obj': page_obj + } + + +@register.inclusion_tag('blog/tags/article_info.html') +def load_article_detail(article, isindex, user): + """ + 文章详情标签 + 加载文章详情页或列表页的展示内容(列表页显示摘要,详情页显示完整内容) + :param article: 文章对象 + :param isindex: 是否为列表页(True/False) + :param user: 当前用户 + :return: 包含文章展示信息的上下文 + """ + from djangoblog.utils import get_blog_setting + blogsetting = get_blog_setting() + return { + 'article': article, + 'isindex': isindex, + 'user': user, + 'open_site_comment': blogsetting.open_site_comment, # 是否允许评论 + } + + +@register.filter +def gravatar_url(email, size=40): + """ + Gravatar头像URL过滤器 + 生成用户的Gravatar头像URL(优先使用OAuth用户的头像) + :param email: 用户邮箱 + :param size: 头像尺寸 + :return: 头像URL字符串 + """ + cachekey = f'gravatat/{email}' + url = cache.get(cachekey) + if url: # 缓存命中 + return url + else: # 缓存未命中 + # 优先使用OAuth用户的头像 + oauth_users = OAuthUser.objects.filter(email=email) + if oauth_users: + valid_avatars = [user for user in oauth_users if user.picture] + if valid_avatars: + return valid_avatars[0].picture + + # 生成Gravatar URL(邮箱MD5哈希 + 尺寸 + 默认头像) + email = email.encode('utf-8') + default_avatar = static('blog/img/avatar.png') # 本地默认头像 + url = f"https://www.gravatar.com/avatar/{hashlib.md5(email.lower()).hexdigest()}?{urllib.parse.urlencode({'d': default_avatar, 's': str(size)})}" + + # 缓存10小时 + cache.set(cachekey, url, 60 * 60 * 10) + logger.info(f'set gravatar cache.key:{cachekey}') + return url + + +@register.filter +def gravatar(email, size=40): + """ + Gravatar头像标签 + 生成包含头像图片的HTML标签 + :param email: 用户邮箱 + :param size: 头像尺寸 + :return: 安全的img标签HTML字符串 + """ + url = gravatar_url(email, size) + return mark_safe(f'') + + +@register.simple_tag +def query(qs, **kwargs): + """ + 查询集过滤标签 + 在模板中对查询集进行过滤(如{% query books author=author as mybooks %}) + :param qs: Django查询集 + :param kwargs: 过滤条件(键值对) + :return: 过滤后的查询集 + """ + return qs.filter(**kwargs) + + +@register.filter +def addstr(arg1, arg2): + """ + 字符串拼接过滤器 + 将两个参数转换为字符串并拼接 + :param arg1: 第一个参数 + :param arg2: 第二个参数 + :return: 拼接后的字符串 + """ + return str(arg1) + str(arg2) \ No newline at end of file diff --git a/src/blog/blog/tests.py b/src/blog/blog/tests.py new file mode 100644 index 00000000..8eb99a81 --- /dev/null +++ b/src/blog/blog/tests.py @@ -0,0 +1,331 @@ +import os +import requests + +# 导入Django核心模块:配置、文件上传、命令调用、分页、静态文件、测试工具、URL反转、时区 +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.core.paginator import Paginator +from django.templatetags.static import static +from django.test import Client, RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone + +# 导入项目相关模型和工具:用户、博客模型、表单、模板标签、工具函数、OAuth相关 +from accounts.models import BlogUser +from blog.forms import BlogSearchForm +from blog.models import Article, Category, Tag, SideBar, Links +from blog.templatetags.blog_tags import load_pagination_info, load_articletags +from djangoblog.utils import get_current_site, get_sha256 +from oauth.models import OAuthUser, OAuthConfig + + +class ArticleTest(TestCase): + """ + 博客核心功能测试类 + 测试文章、分类、标签、搜索、权限等核心业务逻辑 + """ + + def setUp(self): + """ + 测试前的初始化方法 + 创建测试客户端和请求工厂,用于模拟HTTP请求 + """ + self.client = Client() # 模拟用户浏览器的客户端 + self.factory = RequestFactory() # 用于构造请求对象的工厂 + + def test_validate_article(self): + """ + 测试文章相关核心功能: + - 用户模型操作 + - 分类、标签、侧边栏、链接等模型CRUD + - 文章发布、分页、搜索、评论等流程 + - 页面访问状态码验证 + """ + # 获取当前站点域名 + site = get_current_site().domain + + # 创建或获取测试用户(管理员) + user = BlogUser.objects.get_or_create( + email="liangliangyy@gmail.com", + username="liangliangyy")[0] + user.set_password("liangliangyy") # 设置密码 + user.is_staff = True # 允许登录admin + user.is_superuser = True # 超级管理员权限 + user.save() + + # 测试用户个人页面访问 + response = self.client.get(user.get_absolute_url()) + self.assertEqual(response.status_code, 200) # 验证页面正常访问 + + # 测试admin后台页面访问(未登录状态,实际会跳转登录页) + self.client.get('/admin/servermanager/emailsendlog/') + self.client.get('admin/admin/logentry/') + + # 创建测试侧边栏 + s = SideBar() + s.sequence = 1 # 排序序号 + s.name = 'test' # 名称 + s.content = 'test content' # 内容 + s.is_enable = True # 启用 + s.save() + + # 创建测试分类 + category = Category() + category.name = "category" # 分类名称 + category.creation_time = timezone.now() + category.last_mod_time = timezone.now() + category.save() + + # 创建测试标签 + tag = Tag() + tag.name = "nicetag" # 标签名称 + tag.save() + + # 创建测试文章 + article = Article() + article.title = "nicetitle" # 标题 + article.body = "nicecontent" # 内容 + article.author = user # 作者 + article.category = category # 所属分类 + article.type = 'a' # 类型为文章 + article.status = 'p' # 状态为已发布 + article.save() + + # 验证标签关联(初始无标签) + self.assertEqual(0, article.tags.count()) + # 关联标签并验证 + article.tags.add(tag) + article.save() + self.assertEqual(1, article.tags.count()) + + # 批量创建20篇测试文章(用于测试分页) + for i in range(20): + article = Article() + article.title = f"nicetitle{i}" + article.body = f"nicetitle{i}" + article.author = user + article.category = category + article.type = 'a' + article.status = 'p' + article.save() + article.tags.add(tag) + article.save() + + # 测试Elasticsearch搜索(如果启用) + from blog.documents import ELASTICSEARCH_ENABLED + if ELASTICSEARCH_ENABLED: + call_command("build_index") # 构建搜索索引 + response = self.client.get('/search', {'q': 'nicetitle'}) # 执行搜索 + self.assertEqual(response.status_code, 200) # 验证搜索页正常 + + # 测试文章详情页访问 + response = self.client.get(article.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + # 测试搜索引擎推送功能 + from djangoblog.spider_notify import SpiderNotify + SpiderNotify.notify(article.get_absolute_url()) # 推送文章URL到搜索引擎 + + # 测试标签页访问 + response = self.client.get(tag.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + # 测试分类页访问 + response = self.client.get(category.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + # 测试搜索功能(无结果场景) + response = self.client.get('/search', {'q': 'django'}) + self.assertEqual(response.status_code, 200) + + # 测试文章标签模板标签 + s = load_articletags(article) + self.assertIsNotNone(s) # 验证模板标签返回结果 + + # 登录测试用户 + self.client.login(username='liangliangyy', password='liangliangyy') + + # 测试归档页访问 + response = self.client.get(reverse('blog:archives')) + self.assertEqual(response.status_code, 200) + + # 测试各种场景下的分页功能 + # 1. 所有文章分页 + p = Paginator(Article.objects.all(), settings.PAGINATE_BY) + self.check_pagination(p, '', '') + + # 2. 标签筛选分页 + p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY) + self.check_pagination(p, '分类标签归档', tag.slug) + + # 3. 作者筛选分页 + p = Paginator( + Article.objects.filter(author__username='liangliangyy'), + settings.PAGINATE_BY + ) + self.check_pagination(p, '作者文章归档', 'liangliangyy') + + # 4. 分类筛选分页 + p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY) + self.check_pagination(p, '分类目录归档', category.slug) + + # 测试搜索表单 + f = BlogSearchForm() + f.search() # 调用搜索方法 + + # 测试百度搜索引擎推送 + SpiderNotify.baidu_notify([article.get_full_url()]) + + # 测试头像相关模板标签 + from blog.templatetags.blog_tags import gravatar_url, gravatar + u = gravatar_url('liangliangyy@gmail.com') # 获取头像URL + u = gravatar('liangliangyy@gmail.com') # 生成头像HTML + + # 测试友情链接 + link = Links( + sequence=1, + name="lylinux", + link='https://wwww.lylinux.net' + ) + link.save() + response = self.client.get('/links.html') # 访问友情链接页 + self.assertEqual(response.status_code, 200) + + # 测试RSS订阅 + response = self.client.get('/feed/') + self.assertEqual(response.status_code, 200) + + # 测试站点地图 + response = self.client.get('/sitemap.xml') + self.assertEqual(response.status_code, 200) + + # 测试admin后台操作(删除文章、访问日志) + self.client.get("/admin/blog/article/1/delete/") + self.client.get('/admin/servermanager/emailsendlog/') + self.client.get('/admin/admin/logentry/') + self.client.get('/admin/admin/logentry/1/change/') + + def check_pagination(self, p, type, value): + """ + 测试分页功能的辅助方法 + 验证分页控件生成的URL是否可正常访问 + """ + # 遍历所有分页页面 + for page in range(1, p.num_pages + 1): + # 获取分页信息(通过模板标签) + s = load_pagination_info(p.page(page), type, value) + self.assertIsNotNone(s) # 验证分页信息生成正常 + + # 测试上一页链接 + if s['previous_url']: + response = self.client.get(s['previous_url']) + self.assertEqual(response.status_code, 200) + + # 测试下一页链接 + if s['next_url']: + response = self.client.get(s['next_url']) + self.assertEqual(response.status_code, 200) + + def test_image(self): + """ + 测试图片上传功能: + - 未授权上传 + - 授权上传 + - 头像保存工具函数 + - 邮件发送工具函数 + """ + # 下载测试图片(Python官方logo) + rsp = requests.get('https://www.python.org/static/img/python-logo.png') + imagepath = os.path.join(settings.BASE_DIR, 'python.png') # 保存路径 + with open(imagepath, 'wb') as file: + file.write(rsp.content) + + # 测试未授权上传(预期403禁止访问) + rsp = self.client.post('/upload') + self.assertEqual(rsp.status_code, 403) + + # 生成上传签名(基于SECRET_KEY的双重SHA256加密) + sign = get_sha256(get_sha256(settings.SECRET_KEY)) + + # 测试授权上传 + with open(imagepath, 'rb') as file: + # 构造上传文件对象 + imgfile = SimpleUploadedFile( + 'python.png', file.read(), content_type='image/jpg' + ) + form_data = {'python.png': imgfile} + # 带签名上传 + rsp = self.client.post( + f'/upload?sign={sign}', form_data, follow=True + ) + self.assertEqual(rsp.status_code, 200) # 验证上传成功 + + # 清理测试文件 + os.remove(imagepath) + + # 测试用户头像保存和邮件发送工具函数 + from djangoblog.utils import save_user_avatar, send_email + send_email(['qq@qq.com'], 'testTitle', 'testContent') # 测试发送邮件 + save_user_avatar('https://www.python.org/static/img/python-logo.png') # 测试保存头像 + + def test_errorpage(self): + """测试错误页面(404页面)""" + rsp = self.client.get('/eee') # 访问不存在的URL + self.assertEqual(rsp.status_code, 404) # 验证返回404 + + def test_commands(self): + """ + 测试Django自定义命令: + - 索引构建、缓存清理、数据同步等 + """ + # 创建测试用户 + user = BlogUser.objects.get_or_create( + email="liangliangyy@gmail.com", + username="liangliangyy")[0] + user.set_password("liangliangyy") + user.is_staff = True + user.is_superuser = True + user.save() + + # 创建OAuth配置 + c = OAuthConfig() + c.type = 'qq' # QQ登录 + c.appkey = 'appkey' + c.appsecret = 'appsecret' + c.save() + + # 创建关联用户的OAuth账号 + u = OAuthUser() + u.type = 'qq' + u.openid = 'openid' + u.user = user # 关联本地用户 + u.picture = static("/blog/img/avatar.png") # 头像 + u.metadata = ''' +{ +"figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30" +}''' # 第三方平台返回的元数据 + u.save() + + # 创建未关联本地用户的OAuth账号 + u = OAuthUser() + u.type = 'qq' + u.openid = 'openid1' + u.picture = 'https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30' + u.metadata = ''' + { + "figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30" + }''' + u.save() + + # 测试Elasticsearch索引构建命令 + from blog.documents import ELASTICSEARCH_ENABLED + if ELASTICSEARCH_ENABLED: + call_command("build_index") + + # 测试其他自定义命令 + call_command("ping_baidu", "all") # 百度链接推送 + call_command("create_testdata") # 创建测试数据 + call_command("clear_cache") # 清理缓存 + call_command("sync_user_avatar") # 同步用户头像 + call_command("build_search_words") # 构建搜索关键词 \ No newline at end of file diff --git a/src/blog/blog/urls.py b/src/blog/blog/urls.py new file mode 100644 index 00000000..f4314778 --- /dev/null +++ b/src/blog/blog/urls.py @@ -0,0 +1,98 @@ +# 导入Django URL路径处理和缓存装饰器 +from django.urls import path +from django.views.decorators.cache import cache_page + +# 导入当前应用的视图模块 +from . import views + +# 定义应用命名空间,用于模板中URL反向解析(如{% url 'blog:index' %}) +app_name = "blog" + +# URL路由配置列表,映射URL路径到对应的视图 +urlpatterns = [ + # 首页路由 + path( + r'', # 匹配根路径(如域名/) + views.IndexView.as_view(), # 关联首页视图(基于类的视图) + name='index' # 路由名称,用于反向解析 + ), + # 首页分页路由(带页码参数) + path( + r'page//', # 匹配带页码的路径(如/page/2/) + views.IndexView.as_view(), # 复用首页视图处理分页 + name='index_page' # 路由名称 + ), + # 文章详情页路由(按日期和ID) + path( + r'article////.html', + # 匹配路径格式:article/年/月/日/文章ID.html(如article/2023/10/01/1.html) + views.ArticleDetailView.as_view(), # 关联文章详情视图 + name='detailbyid' # 路由名称 + ), + # 分类详情页路由 + path( + r'category/.html', + # 匹配路径:category/分类别名.html(如category/tech.html),slug表示URL友好的字符串 + views.CategoryDetailView.as_view(), # 关联分类详情视图 + name='category_detail' # 路由名称 + ), + # 分类详情页分页路由 + path( + r'category//.html', + # 匹配带页码的分类路径(如category/tech/2.html) + views.CategoryDetailView.as_view(), # 复用分类视图处理分页 + name='category_detail_page' # 路由名称 + ), + # 作者文章列表路由 + path( + r'author/.html', + # 匹配路径:author/用户名.html(如author/admin.html) + views.AuthorDetailView.as_view(), # 关联作者文章列表视图 + name='author_detail' # 路由名称 + ), + # 作者文章列表分页路由 + path( + r'author//.html', + # 匹配带页码的作者路径(如author/admin/2.html) + views.AuthorDetailView.as_view(), # 复用作者视图处理分页 + name='author_detail_page' # 路由名称 + ), + # 标签详情页路由 + path( + r'tag/.html', + # 匹配路径:tag/标签别名.html(如tag/python.html) + views.TagDetailView.as_view(), # 关联标签详情视图 + name='tag_detail' # 路由名称 + ), + # 标签详情页分页路由 + path( + r'tag//.html', + # 匹配带页码的标签路径(如tag/python/2.html) + views.TagDetailView.as_view(), # 复用标签视图处理分页 + name='tag_detail_page' # 路由名称 + ), + # 文章归档页路由(带缓存) + path( + 'archives.html', # 匹配路径:archives.html + cache_page(60 * 60)(views.ArchivesView.as_view()), # 缓存60分钟(60秒*60) + name='archives' # 路由名称 + ), + # 友情链接页路由 + path( + 'links.html', # 匹配路径:links.html + views.LinkListView.as_view(), # 关联友情链接视图 + name='links' # 路由名称 + ), + # 文件上传接口路由 + path( + r'upload', # 匹配路径:upload + views.fileupload, # 关联文件上传视图函数(基于函数的视图) + name='upload' # 路由名称 + ), + # 清理缓存接口路由 + path( + r'clean', # 匹配路径:clean + views.clean_cache_view, # 关联清理缓存视图函数 + name='clean' # 路由名称 + ), +] diff --git a/src/blog/blog/views.py b/src/blog/blog/views.py new file mode 100644 index 00000000..09c7c520 --- /dev/null +++ b/src/blog/blog/views.py @@ -0,0 +1,498 @@ +import logging +import os +import uuid # 用于生成唯一文件名 + +# 导入Django核心模块:配置、分页、HTTP响应、视图工具、翻译等 +from django.conf import settings +from django.core.paginator import Paginator +from django.http import HttpResponse, HttpResponseForbidden +from django.shortcuts import get_object_or_404, render +from django.templatetags.static import static # 生成静态文件URL +from django.utils import timezone # 处理时间 +from django.utils.translation import gettext_lazy as _ # 国际化翻译 +from django.views.decorators.csrf import csrf_exempt # 豁免CSRF验证(用于文件上传) +from django.views.generic.detail import DetailView # 详情页通用视图 +from django.views.generic.list import ListView # 列表页通用视图 +from haystack.views import SearchView # 搜索视图 + +# 导入项目模型、表单、工具和插件 +from blog.models import Article, Category, LinkShowType, Links, Tag +from comments.forms import CommentForm # 评论表单 +from djangoblog.plugin_manage import hooks # 插件钩子 +from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME # 文章内容钩子常量 +from djangoblog.utils import cache, get_blog_setting, get_sha256 # 缓存、配置和加密工具 + +# 创建当前模块的日志记录器 +logger = logging.getLogger(__name__) + + +class ArticleListView(ListView): + """ + 文章列表基类视图 + 封装文章列表页的通用逻辑(分页、缓存、上下文处理) + 被首页、分类、标签、作者等列表页继承 + """ + # 模板路径:所有文章列表页共用此模板 + template_name = 'blog/article_index.html' + + # 上下文变量名:模板中用{{ article_list }}访问列表数据 + context_object_name = 'article_list' + + # 页面类型描述(如"分类目录归档"),子类需重写 + page_type = '' + # 分页大小:从配置中获取 + paginate_by = settings.PAGINATE_BY + # 分页参数名:URL中页码的参数名(如?page=2) + page_kwarg = 'page' + # 友情链接显示类型:默认为列表页(L) + link_type = LinkShowType.L + + def get_view_cache_key(self): + """获取视图缓存的key(未实际使用,预留扩展)""" + return self.request.get['pages'] + + @property + def page_number(self): + """获取当前页码(从URL参数或kwargs中提取)""" + page_kwarg = self.page_kwarg + # 优先从URL路径参数获取,再从GET参数获取,默认1 + page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1 + return page + + def get_queryset_cache_key(self): + """ + 抽象方法:获取查询集的缓存key + 子类必须实现,用于区分不同页面的缓存 + """ + raise NotImplementedError() + + def get_queryset_data(self): + """ + 抽象方法:获取查询集数据 + 子类必须实现,定义具体的文章筛选逻辑 + """ + raise NotImplementedError() + + def get_queryset_from_cache(self, cache_key): + """ + 从缓存获取或生成查询集数据 + :param cache_key: 缓存唯一标识 + :return: 文章查询集 + """ + # 尝试从缓存获取 + value = cache.get(cache_key) + if value: + logger.info(f'从缓存获取数据,key: {cache_key}') + return value + else: + # 缓存未命中,执行查询并缓存 + article_list = self.get_queryset_data() + cache.set(cache_key, article_list) + logger.info(f'设置缓存,key: {cache_key}') + return article_list + + def get_queryset(self): + """ + 重写父类方法:从缓存获取查询集 + 优化性能,减少数据库查询 + """ + cache_key = self.get_queryset_cache_key() + return self.get_queryset_from_cache(cache_key) + + def get_context_data(self, **kwargs): + """ + 扩展上下文数据:添加友情链接显示类型 + """ + kwargs['linktype'] = self.link_type + return super().get_context_data(** kwargs) + + +class IndexView(ArticleListView): + """ + 首页视图 + 继承文章列表基类,展示所有已发布的文章 + """ + # 友情链接显示类型:首页(I) + link_type = LinkShowType.I + + def get_queryset_data(self): + """获取首页文章列表:已发布的普通文章(type='a')""" + return Article.objects.filter(type='a', status='p') + + def get_queryset_cache_key(self): + """生成首页缓存key,包含页码""" + return f'index_{self.page_number}' + + +class ArticleDetailView(DetailView): + """ + 文章详情页视图 + 展示单篇文章的详细内容、评论等 + """ + template_name = 'blog/article_detail.html' # 详情页模板 + model = Article # 关联的模型 + pk_url_kwarg = 'article_id' # URL中主键的参数名 + context_object_name = "article" # 模板中文章对象的变量名 + + def get_context_data(self, **kwargs): + """ + 构建详情页上下文数据: + - 评论表单 + - 评论分页 + - 上下篇文章 + - 插件钩子处理 + """ + # 初始化评论表单 + comment_form = CommentForm() + + # 获取当前文章的所有有效评论 + article_comments = self.object.comment_list() + # 筛选顶级评论(无父评论) + parent_comments = article_comments.filter(parent_comment=None) + + # 获取博客配置(评论分页大小) + blog_setting = get_blog_setting() + # 初始化评论分页器 + paginator = Paginator(parent_comments, blog_setting.article_comment_count) + + # 处理评论页码参数 + page = self.request.GET.get('comment_page', '1') + if not page.isnumeric(): + page = 1 + else: + page = int(page) + page = max(1, min(page, paginator.num_pages)) # 限制页码范围 + + # 获取当前页的评论 + p_comments = paginator.page(page) + + # 生成上下页评论的URL + next_page = p_comments.next_page_number() if p_comments.has_next() else None + prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None + + if next_page: + kwargs['comment_next_page_url'] = f'{self.object.get_absolute_url()}?comment_page={next_page}#commentlist-container' + if prev_page: + kwargs['comment_prev_page_url'] = f'{self.object.get_absolute_url()}?comment_page={prev_page}#commentlist-container' + + # 添加上下文数据 + kwargs['form'] = comment_form # 评论表单 + kwargs['article_comments'] = article_comments # 所有评论 + kwargs['p_comments'] = p_comments # 当前页评论 + kwargs['comment_count'] = len(article_comments) if article_comments else 0 # 评论总数 + + # 上下篇文章 + kwargs['next_article'] = self.object.next_article + kwargs['prev_article'] = self.object.prev_article + + # 调用父类方法获取基础上下文 + context = super().get_context_data(**kwargs) + + # 获取当前文章对象 + article = self.object + + # 执行插件动作钩子:通知插件"文章详情已获取" + hooks.run_action('after_article_body_get', article=article, request=self.request) + + # 执行插件过滤钩子:允许插件修改文章正文(如添加水印、解析特殊标签等) + article.body = hooks.apply_filters( + ARTICLE_CONTENT_HOOK_NAME, + article.body, + article=article, + request=self.request + ) + + return context + + +class CategoryDetailView(ArticleListView): + """ + 分类详情页视图 + 展示指定分类及子分类下的所有文章 + """ + page_type = "分类目录归档" # 页面类型描述 + + def get_queryset_data(self): + """ + 获取分类下的文章列表: + 1. 根据URL中的分类slug获取分类对象 + 2. 包含所有子分类的文章 + 3. 仅展示已发布状态 + """ + slug = self.kwargs['category_name'] + category = get_object_or_404(Category, slug=slug) # 获取分类,不存在则404 + + # 记录分类名称(用于上下文) + self.categoryname = category.name + # 获取当前分类及所有子分类的名称列表 + categorynames = [c.name for c in category.get_sub_categorys()] + + # 筛选属于这些分类且已发布的文章 + return Article.objects.filter(category__name__in=categorynames, status='p') + + def get_queryset_cache_key(self): + """生成分类页面的缓存key,包含分类名和页码""" + slug = self.kwargs['category_name'] + category = get_object_or_404(Category, slug=slug) + self.categoryname = category.name + return f'category_list_{self.categoryname}_{self.page_number}' + + def get_context_data(self, **kwargs): + """扩展上下文:添加页面类型和分类名称""" + # 处理分类名称(去除路径前缀,仅保留最后一级) + try: + categoryname = self.categoryname.split('/')[-1] + except: + categoryname = self.categoryname + + kwargs['page_type'] = self.page_type + kwargs['tag_name'] = categoryname # 模板中统一用tag_name显示当前分类/标签/作者名 + return super().get_context_data(** kwargs) + + +class AuthorDetailView(ArticleListView): + """ + 作者详情页视图 + 展示指定作者发布的所有文章 + """ + page_type = '作者文章归档' # 页面类型描述 + + def get_queryset_cache_key(self): + """生成作者页面的缓存key,包含作者名和页码""" + from uuslug import slugify # 确保作者名URL友好 + author_name = slugify(self.kwargs['author_name']) + return f'author_{author_name}_{self.page_number}' + + def get_queryset_data(self): + """获取指定作者的已发布文章""" + author_name = self.kwargs['author_name'] + return Article.objects.filter(author__username=author_name, type='a', status='p') + + def get_context_data(self, **kwargs): + """扩展上下文:添加页面类型和作者名""" + kwargs['page_type'] = self.page_type + kwargs['tag_name'] = self.kwargs['author_name'] + return super().get_context_data(** kwargs) + + +class TagDetailView(ArticleListView): + """ + 标签详情页视图 + 展示指定标签关联的所有文章 + """ + page_type = '分类标签归档' # 页面类型描述 + + def get_queryset_data(self): + """获取指定标签的已发布文章""" + slug = self.kwargs['tag_name'] + tag = get_object_or_404(Tag, slug=slug) # 获取标签,不存在则404 + self.name = tag.name # 记录标签名 + return Article.objects.filter(tags__name=self.name, type='a', status='p') + + def get_queryset_cache_key(self): + """生成标签页面的缓存key,包含标签名和页码""" + slug = self.kwargs['tag_name'] + tag = get_object_or_404(Tag, slug=slug) + self.name = tag.name + return f'tag_{self.name}_{self.page_number}' + + def get_context_data(self, **kwargs): + """扩展上下文:添加页面类型和标签名""" + kwargs['page_type'] = self.page_type + kwargs['tag_name'] = self.name + return super().get_context_data(** kwargs) + + +class ArchivesView(ArticleListView): + """ + 文章归档页面视图 + 展示所有已发布文章的归档列表(按时间分组) + """ + page_type = '文章归档' + paginate_by = None # 归档页不分页 + page_kwarg = None # 无需页码参数 + template_name = 'blog/article_archives.html' # 归档页专用模板 + + def get_queryset_data(self): + """获取所有已发布文章(用于归档)""" + return Article.objects.filter(status='p').all() + + def get_queryset_cache_key(self): + """归档页缓存key(固定值,因不分页)""" + return 'archives' + + +class LinkListView(ListView): + """ + 友情链接页面视图 + 展示所有启用的友情链接 + """ + model = Links # 关联链接模型 + template_name = 'blog/links_list.html' # 链接页模板 + + def get_queryset(self): + """仅获取启用的友情链接""" + return Links.objects.filter(is_enable=True) + + +class EsSearchView(SearchView): + """ + 搜索视图(基于Haystack) + 处理全文搜索请求并返回结果 + """ + def get_context(self): + """构建搜索结果页面的上下文数据""" + # 构建分页器和当前页数据 + paginator, page = self.build_page() + context = { + "query": self.query, # 搜索关键词 + "form": self.form, # 搜索表单 + "page": page, # 当前页结果 + "paginator": paginator, # 分页器 + "suggestion": None, # 搜索建议(默认无) + } + + # 如果搜索引擎支持拼写建议,添加建议内容 + if hasattr(self.results, "query") and self.results.query.backend.include_spelling: + context["suggestion"] = self.results.query.get_spelling_suggestion() + + # 添加额外上下文 + context.update(self.extra_context()) + return context + + +@csrf_exempt # 豁免CSRF验证(用于外部调用上传) +def fileupload(request): + """ + 文件上传接口(图床功能) + 仅允许POST请求,且需验证签名 + """ + if request.method == 'POST': + # 获取签名参数 + sign = request.GET.get('sign', None) + if not sign: + return HttpResponseForbidden() # 无签名则禁止 + + # 验证签名(双重SHA256加密,基于SECRET_KEY) + if sign != get_sha256(get_sha256(settings.SECRET_KEY)): + return HttpResponseForbidden() # 签名无效则禁止 + + # 存储上传文件的URL + response = [] + + # 处理每个上传的文件 + for filename in request.FILES: + # 生成时间目录(按年/月/日) + timestr = timezone.now().strftime('%Y/%m/%d') + # 图片文件扩展名 + imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] + # 检查是否为图片 + fname = str(filename) + isimage = any(ext in fname.lower() for ext in imgextensions) + + # 确定存储目录(图片和普通文件分开) + base_dir = os.path.join( + settings.STATICFILES, + "image" if isimage else "files", + timestr + ) + # 确保目录存在 + if not os.path.exists(base_dir): + os.makedirs(base_dir) + + # 生成唯一文件名(UUID+原扩展名) + file_ext = os.path.splitext(filename)[-1] + savepath = os.path.normpath( + os.path.join(base_dir, f"{uuid.uuid4().hex}{file_ext}") + ) + + # 安全检查:防止路径穿越 + if not savepath.startswith(base_dir): + return HttpResponse("Invalid path") + + # 保存文件 + with open(savepath, 'wb+') as wfile: + for chunk in request.FILES[filename].chunks(): + wfile.write(chunk) + + # 压缩图片(如果是图片文件) + if isimage: + from PIL import Image + try: + with Image.open(savepath) as image: + # 优化图片质量(20%质量,启用优化) + image.save(savepath, quality=20, optimize=True) + except Exception as e: + logger.error(f"图片压缩失败: {e}") + + # 生成文件的访问URL + url = static(savepath) + response.append(url) + + # 返回所有上传文件的URL + return HttpResponse(response) + else: + # 仅允许POST请求 + return HttpResponse("only for post") + + +def page_not_found_view(request, exception, template_name='blog/error_page.html'): + """ + 404错误页面视图 + 处理页面未找到的情况 + """ + if exception: + logger.error(exception) # 记录错误详情 + url = request.get_full_path() # 获取请求的URL + return render( + request, + template_name, + { + 'message': _('Sorry, the page you requested is not found. Please click the home page to see others.'), + 'statuscode': '404' + }, + status=404 + ) + + +def server_error_view(request, template_name='blog/error_page.html'): + """ + 500错误页面视图 + 处理服务器内部错误 + """ + return render( + request, + template_name, + { + 'message': _('Sorry, the server is busy. Please click the home page to see others.'), + 'statuscode': '500' + }, + status=500 + ) + + +def permission_denied_view(request, exception, template_name='blog/error_page.html'): + """ + 403错误页面视图 + 处理权限不足的情况 + """ + if exception: + logger.error(exception) # 记录错误详情 + return render( + request, + template_name, + { + 'message': _('Sorry, you do not have permission to access this page.'), + 'statuscode': '403' + }, + status=403 + ) + + +def clean_cache_view(request): + """ + 清理缓存接口 + 调用后清除所有缓存数据 + """ + cache.clear() + return HttpResponse('ok') \ No newline at end of file