Compare commits

..

14 Commits

@ -0,0 +1,209 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="e8035e2c-ba64-447a-bf28-8de264fb16dc" name="更改" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="DjangoConsoleOptions" custom-start-script="import sys; print('Python %s on %s' % (sys.version, sys.platform))&#10;import django; print('Django %s' % django.get_version())&#10;sys.path.extend([WORKING_DIR_AND_PYTHON_PATHS])&#10;if 'setup' in dir(django): django.setup()&#10;import django_manage_shell; django_manage_shell.run(PROJECT_ROOT)">
<option name="myCustomStartScript" value="import sys; print('Python %s on %s' % (sys.version, sys.platform))&#10;import django; print('Django %s' % django.get_version())&#10;sys.path.extend([WORKING_DIR_AND_PYTHON_PATHS])&#10;if 'setup' in dir(django): django.setup()&#10;import django_manage_shell; django_manage_shell.run(PROJECT_ROOT)" />
</component>
<component name="Git.Settings">
<excluded-from-favorite>
<branch-storage>
<map>
<entry type="LOCAL">
<value>
<list>
<branch-info repo="$PROJECT_DIR$" source="master" />
</list>
</value>
</entry>
</map>
</branch-storage>
</excluded-from-favorite>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$/Django" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$PROJECT_DIR$/.venv/Lib/site-packages/zope.event-5.0-py3.11-nspkg.pth" root0="SKIP_INSPECTION" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 5
}</component>
<component name="ProjectId" id="338CfiAmNqSHmrZv6qZudWZrv2e" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Django 服务器.DjangoBlog.executor&quot;: &quot;Run&quot;,
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;Python.manage.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenDjangoStructureViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.pycharm.django.structure.promotion.once.per.project&quot;: &quot;true&quot;,
&quot;django.template.preview.state&quot;: &quot;SHOW_EDITOR_AND_PREVIEW&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;last_opened_file_path&quot;: &quot;C:/Users/yna1217/PyCharmMiscProject/DjangoBlog&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
},
&quot;keyToStringList&quot;: {
&quot;DatabaseDriversLRU&quot;: [
&quot;mysql&quot;
]
}
}</component>
<component name="RunManager" selected="Django Server.DjangoBlog">
<configuration name="manage" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="DjangoBlog" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/manage.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<configuration name="DjangoBlog" type="Python.DjangoServer" factoryName="Django server">
<module name="DjangoBlog" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="DJANGO_SETTINGS_MODULE" value="djangoblog.settings" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="launchJavascriptDebuger" value="false" />
<option name="port" value="8000" />
<option name="host" value="" />
<option name="additionalOptions" value="" />
<option name="browserUrl" value="" />
<option name="runTestServer" value="false" />
<option name="runNoReload" value="false" />
<option name="useCustomRunCommand" value="false" />
<option name="customRunCommand" value="" />
<method v="2" />
</configuration>
<recent_temporary>
<list>
<item itemvalue="Python.manage" />
</list>
</recent_temporary>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-python-sdk-ce6832f46686-7b97d883f26b-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-252.25557.178" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="e8035e2c-ba64-447a-bf28-8de264fb16dc" name="更改" comment="" />
<created>1758689416626</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1758689416626</updated>
<workItem from="1758689417849" duration="100000" />
<workItem from="1758689545060" duration="159000" />
<workItem from="1758708446505" duration="3865000" />
<workItem from="1758851928726" duration="832000" />
<workItem from="1758856639268" duration="185000" />
<workItem from="1758857247480" duration="193000" />
<workItem from="1758857598801" duration="198000" />
<workItem from="1758857817520" duration="211000" />
<workItem from="1758858285608" duration="58000" />
<workItem from="1758858344637" duration="277000" />
<workItem from="1758858690274" duration="112000" />
<workItem from="1758858864968" duration="332000" />
<workItem from="1758860426104" duration="38000" />
<workItem from="1759054941669" duration="4414000" />
<workItem from="1759060288711" duration="88000" />
<workItem from="1759060378869" duration="17000" />
<workItem from="1759060418007" duration="10000" />
<workItem from="1759060429271" duration="14000" />
<workItem from="1759060459878" duration="69000" />
<workItem from="1759060555342" duration="280000" />
<workItem from="1759060844083" duration="84000" />
<workItem from="1759060943990" duration="5000" />
<workItem from="1759060957737" duration="235000" />
<workItem from="1759061201787" duration="2067000" />
<workItem from="1759073162856" duration="46000" />
<workItem from="1759150320935" duration="647000" />
<workItem from="1759710973039" duration="11000" />
<workItem from="1760000933752" duration="5725000" />
<workItem from="1760350751794" duration="16000" />
<workItem from="1760350815312" duration="456000" />
<workItem from="1760602552091" duration="29000" />
<workItem from="1760602590721" duration="4000" />
<workItem from="1760602902283" duration="35000" />
<workItem from="1760602954585" duration="136000" />
<workItem from="1760603264850" duration="4000" />
<workItem from="1760603279281" duration="5000" />
<workItem from="1760603363730" duration="3000" />
<workItem from="1760603379644" duration="65000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State>
<option name="FILTERS">
<map>
<entry key="branch">
<value>
<list>
<option value="origin/master" />
</list>
</value>
</entry>
</map>
</option>
</State>
</value>
</entry>
</map>
</option>
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/DjangoBlog$manage.coverage" NAME="manage 覆盖结果" MODIFIED="1759061084650" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
</component>
</project>

@ -0,0 +1,10 @@
[run]
source = .
include = *.py
omit =
*migrations*
*tests*
*.html
*whoosh_cn_backend*
*settings.py*
*venv*

@ -0,0 +1,11 @@
bin/data/
# virtualenv
venv/
collectedstatic/
djangoblog/whoosh_index/
uploads/
settings_production.py
*.md
docs/
logs/
static/

@ -0,0 +1,6 @@
blog/static/* linguist-vendored
*.js linguist-vendored
*.css linguist-vendored
* text=auto
*.sh text eol=lf
*.conf text eol=lf

@ -0,0 +1,18 @@
<!--
如果你不认真勾选下面的内容,我可能会直接关闭你的 Issue。
提问之前,建议先阅读 https://github.com/ruby-china/How-To-Ask-Questions-The-Smart-Way
-->
**我确定我已经查看了** (标注`[ ]`为`[x]`)
- [ ] [DjangoBlog的readme](https://github.com/liangliangyy/DjangoBlog/blob/master/README.md)
- [ ] [配置说明](https://github.com/liangliangyy/DjangoBlog/blob/master/bin/config.md)
- [ ] [其他 Issues](https://github.com/liangliangyy/DjangoBlog/issues)
----
**我要申请** (标注`[ ]`为`[x]`)
- [ ] BUG 反馈
- [ ] 添加新的特性或者功能
- [ ] 请求技术支持

@ -0,0 +1,47 @@
name: "CodeQL"
on:
push:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
- '**/*.yml'
- '**/*.txt'
pull_request:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
- '**/*.yml'
- '**/*.txt'
schedule:
- cron: '30 1 * * 0'
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

@ -0,0 +1,136 @@
name: Django CI
on:
push:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
pull_request:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
jobs:
build-normal:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10","3.11" ]
steps:
- name: Start MySQL
uses: samin/mysql-action@v1.3
with:
host port: 3306
container port: 3306
character set server: utf8mb4
collation server: utf8mb4_general_ci
mysql version: latest
mysql root password: root
mysql database: djangoblog
mysql user: root
mysql password: root
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
run: |
python manage.py makemigrations
python manage.py migrate
python manage.py test
build-with-es:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10","3.11" ]
steps:
- name: Start MySQL
uses: samin/mysql-action@v1.3
with:
host port: 3306
container port: 3306
character set server: utf8mb4
collation server: utf8mb4_general_ci
mysql version: latest
mysql root password: root
mysql database: djangoblog
mysql user: root
mysql password: root
- name: Configure sysctl limits
run: |
sudo swapoff -a
sudo sysctl -w vm.swappiness=1
sudo sysctl -w fs.file-max=262144
sudo sysctl -w vm.max_map_count=262144
- uses: miyataka/elasticsearch-github-actions@1
with:
stack-version: '7.12.1'
plugins: 'https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-7.12.1.zip'
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: 127.0.0.1:9200
run: |
python manage.py makemigrations
python manage.py migrate
coverage run manage.py test
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
push: false
tags: djangoblog/djangoblog:dev

@ -0,0 +1,43 @@
name: docker
on:
push:
paths-ignore:
- '**/*.md'
- '**/*.yml'
branches:
- 'master'
- 'dev'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set env to docker dev tag
if: endsWith(github.ref, '/dev')
run: |
echo "DOCKER_TAG=test" >> $GITHUB_ENV
- name: Set env to docker latest tag
if: endsWith(github.ref, '/master')
run: |
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{env.DOCKER_TAG}}

@ -0,0 +1,39 @@
name: publish release
on:
release:
types: [ published ]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: name/app
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: |
linux/amd64
linux/arm64
linux/arm/v7
linux/arm/v6
linux/386
tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{ github.event.release.tag_name }}

@ -0,0 +1,80 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
# Translations
*.pot
# Django stuff:
*.log
logs/
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# PyCharm
# http://www.jetbrains.com/pycharm/webhelp/project.html
.idea
.iml
static/
# virtualenv
venv/
collectedstatic/
djangoblog/whoosh_index/
google93fd32dbd906620a.html
baidu_verify_FlHL7cUyC9.html
BingSiteAuth.xml
cb9339dbe2ff86a5aa169d28dba5f615.txt
werobot_session.*
django.jpg
uploads/
settings_production.py
werobot_session.db
bin/datas/

@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>
<option name="rootFolder" value="$MODULE_DIR$" />
<option name="settingsModule" value="djangoblog/settings.py" />
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
<option name="trackFilePattern" value="migrations" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (pythonProject)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
</list>
</option>
</component>
</module>

@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12 (DjangoBlog)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (pythonProject)" project-jdk-type="Python SDK" />
</project>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/DjangoBlog.iml" filepath="$PROJECT_DIR$/.idea/DjangoBlog.iml" />
</modules>
</component>
</project>

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Django" vcs="Git" />
</component>
</project>

@ -0,0 +1,15 @@
FROM python:3.11
ENV PYTHONUNBUFFERED 1
WORKDIR /code/djangoblog/
RUN apt-get update && \
apt-get install default-libmysqlclient-dev gettext -y && \
rm -rf /var/lib/apt/lists/*
ADD requirements.txt requirements.txt
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir gunicorn[gevent] && \
pip cache purge
ADD . .
RUN chmod +x /code/djangoblog/deploy/entrypoint.sh
ENTRYPOINT ["/code/djangoblog/deploy/entrypoint.sh"]

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2014 Code Charm Ltd
Copyright (c) 2025 车亮亮
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
@ -17,4 +17,4 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -0,0 +1,158 @@
# DjangoBlog
<p align="center">
<a href="https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml"><img src="https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml/badge.svg" alt="Django CI"></a>
<a href="https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml"><img src="https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml/badge.svg" alt="CodeQL"></a>
<a href="https://codecov.io/gh/liangliangyy/DjangoBlog"><img src="https://codecov.io/gh/liangliangyy/DjangoBlog/branch/master/graph/badge.svg" alt="codecov"></a>
<a href="https://github.com/liangliangyy/DjangoBlog/blob/master/LICENSE"><img src="https://img.shields.io/github/license/liangliangyy/djangoblog.svg" alt="license"></a>
</p>
<p align="center">
<b>一款功能强大、设计优雅的现代化博客系统</b>
<br>
<a href="/docs/README-en.md">English</a><b>简体中文</b>
</p>
---
DjangoBlog 是一款基于 Python 3.10 和 Django 4.0 构建的高性能博客平台。它不仅提供了传统博客的所有核心功能还通过一个灵活的插件系统让您可以轻松扩展和定制您的网站。无论您是个人博主、技术爱好者还是内容创作者DjangoBlog 都旨在为您提供一个稳定、高效且易于维护的写作和发布环境。
## ✨ 特性亮点
- **强大的内容管理**: 支持文章、独立页面、分类和标签的完整管理。内置强大的 Markdown 编辑器,支持代码语法高亮。
- **全文搜索**: 集成搜索引擎,提供快速、精准的文章内容搜索。
- **互动评论系统**: 支持回复、邮件提醒等功能,评论内容同样支持 Markdown。
- **灵活的侧边栏**: 可自定义展示最新文章、最多阅读、标签云等模块。
- **社交化登录**: 内置 OAuth 支持,已集成 Google, GitHub, Facebook, 微博, QQ 等主流平台。
- **高性能缓存**: 原生支持 Redis 缓存,并提供自动刷新机制,确保网站高速响应。
- **SEO 友好**: 具备基础 SEO 功能,新内容发布后可自动通知 Google 和百度。
- **便捷的插件系统**: 通过创建独立的插件来扩展博客功能代码解耦易于维护。我们已经通过插件实现了文章浏览计数、SEO 优化等功能!
- **集成图床**: 内置简单的图床功能,方便图片上传和管理。
- **自动化前端**: 集成 `django-compressor`,自动压缩和优化 CSS 及 JavaScript 文件。
- **健壮的运维**: 内置网站异常邮件提醒和微信公众号管理功能。
## 🛠️ 技术栈
- **后端**: Python 3.10, Django 4.0
- **数据库**: MySQL, SQLite (可配置)
- **缓存**: Redis
- **前端**: HTML5, CSS3, JavaScript
- **搜索**: Whoosh, Elasticsearch (可配置)
- **编辑器**: Markdown (mdeditor)
## 🚀 快速开始
### 1. 环境准备
确保您的系统中已安装 Python 3.10+ 和 MySQL/MariaDB。
### 2. 克隆与安装
```bash
# 克隆项目到本地
git clone https://github.com/liangliangyy/DjangoBlog.git
cd DjangoBlog
# 安装依赖
pip install -r requirements.txt
```
### 3. 项目配置
- **数据库**:
打开 `djangoblog/settings.py` 文件,找到 `DATABASES` 配置项,修改为您的 MySQL 连接信息。
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangoblog',
'USER': 'root',
'PASSWORD': 'your_password',
'HOST': '127.0.0.1',
'PORT': 3306,
}
}
```
在 MySQL 中创建数据库:
```sql
CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
- **更多配置**:
关于邮件发送、OAuth 登录、缓存等更多高级配置,请参阅我们的 [详细配置文档](/docs/config.md)。
### 4. 初始化数据库
```bash
python manage.py makemigrations
python manage.py migrate
# 创建一个超级管理员账户
python manage.py createsuperuser
```
### 5. 运行项目
```bash
# (可选) 生成一些测试数据
python manage.py create_testdata
# (可选) 收集和压缩静态文件
python manage.py collectstatic --noinput
python manage.py compress --force
# 启动开发服务器
python manage.py runserver
```
现在,在您的浏览器中访问 `http://127.0.0.1:8000/`,您应该能看到 DjangoBlog 的首页了!
## 部署
- **传统部署**: 我们为您准备了非常详细的 [服务器部署教程](https://www.lylinux.net/article/2019/8/5/58.html)。
- **Docker 部署**: 项目已全面支持 Docker。如果您熟悉容器化技术请参考 [Docker 部署文档](/docs/docker.md) 来快速启动。
- **Kubernetes 部署**: 我们也提供了完整的 [Kubernetes 部署指南](/docs/k8s.md),助您轻松上云。
## 🧩 插件系统
插件系统是 DjangoBlog 的核心特色之一。它允许您在不修改核心代码的情况下,通过编写独立的插件来为您的博客添加新功能。
- **工作原理**: 插件通过在预定义的“钩子”上注册回调函数来工作。例如,当一篇文章被渲染时,`after_article_body_get` 钩子会被触发,所有注册到此钩子的函数都会被执行。
- **现有插件**: `view_count`(浏览计数), `seo_optimizer`SEO优化等都是通过插件系统实现的。
- **开发您自己的插件**: 只需在 `plugins` 目录下创建一个新的文件夹,并编写您的 `plugin.py`。欢迎探索并为 DjangoBlog 社区贡献您的创意!
## 🤝 贡献指南
我们热烈欢迎任何形式的贡献!如果您有好的想法或发现了 Bug请随时提交 Issue 或 Pull Request。
## 📄 许可证
本项目基于 [MIT License](LICENSE) 开源。
---
## ❤️ 支持与赞助
如果您觉得这个项目对您有帮助,并且希望支持我继续维护和开发新功能,欢迎请我喝杯咖啡!您的每一份支持都是我前进的最大动力。
<p align="center">
<img src="/docs/imgs/alipay.jpg" width="150" alt="支付宝赞助">
<img src="/docs/imgs/wechat.jpg" width="150" alt="微信赞助">
</p>
<p align="center">
<i>(左) 支付宝 / (右) 微信</i>
</p>
## 🙏 鸣谢
特别感谢 **JetBrains** 为本项目提供的免费开源许可证。
<p align="center">
<a href="https://www.jetbrains.com/?from=DjangoBlog">
<img src="/docs/imgs/pycharm_logo.png" width="150" alt="JetBrains Logo">
</a>
</p>
---
> 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。您的回复将会是我继续更新维护下去的动力。

@ -0,0 +1,59 @@
from django import forms
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import UserChangeForm
from django.contrib.auth.forms import UsernameField
from django.utils.translation import gettext_lazy as _
# Register your models here.
from .models import BlogUser
class BlogUserCreationForm(forms.ModelForm):
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta:
model = BlogUser
fields = ('email',)
def clean_password2(self):
# Check that the two password entries match
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
if password1 and password2 and password1 != password2:
raise forms.ValidationError(_("passwords do not match"))
return password2
def save(self, commit=True):
# Save the provided password in hashed format
user = super().save(commit=False)
user.set_password(self.cleaned_data["password1"])
if commit:
user.source = 'adminsite'
user.save()
return user
class BlogUserChangeForm(UserChangeForm):
class Meta:
model = BlogUser
fields = '__all__'
field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class BlogUserAdmin(UserAdmin):
form = BlogUserChangeForm
add_form = BlogUserCreationForm
list_display = (
'id',
'nickname',
'username',
'email',
'last_login',
'date_joined',
'source')
list_display_links = ('id', 'username')
ordering = ('-id',)

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = 'accounts'

@ -1,71 +1,47 @@
# 导入Django表单基类
from django import forms
# 导入Django用户模型工具及密码验证功能
from django.contrib.auth import get_user_model, password_validation
# 导入Django内置的认证表单登录、用户创建
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
# 导入Django验证错误类用于自定义表单验证
from django.core.exceptions import ValidationError
# 导入Django表单控件用于自定义输入框样式
from django.forms import widgets
# 导入翻译工具,实现字段名称/提示的国际化
from django.utils.translation import gettext_lazy as _
# 导入自定义工具类(可能用于验证码验证等)
from . import utils
# 导入自定义用户模型
from .models import BlogUser
class LoginForm(AuthenticationForm):
"""自定义登录表单继承自Django内置的AuthenticationForm"""
def __init__(self, *args, **kwargs):
"""初始化方法,重写父类初始化逻辑以自定义表单控件样式"""
super(LoginForm, self).__init__(*args, **kwargs)
# 自定义用户名字段的输入控件设置占位符和CSS类
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 自定义密码字段的输入控件使用密码输入框设置占位符和CSS类
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
class RegisterForm(UserCreationForm):
"""自定义注册表单继承自Django内置的UserCreationForm"""
def __init__(self, *args, **kwargs):
"""初始化方法,重写父类初始化逻辑以自定义表单控件样式"""
super(RegisterForm, self).__init__(*args, **kwargs)
# 自定义用户名字段控件文本输入框设置占位符和CSS类
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 自定义邮箱字段控件邮箱输入框设置占位符和CSS类
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
# 自定义密码1字段控件密码输入框设置占位符和CSS类
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
# 自定义密码2字段控件密码输入框确认密码设置占位符和CSS类
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
def clean_email(self):
"""自定义邮箱验证:检查邮箱是否已被注册"""
email = self.cleaned_data['email']
# 如果该邮箱已存在于用户表中,抛出验证错误
if get_user_model().objects.filter(email=email).exists():
raise ValidationError(_("email already exists"))
return email
class Meta:
# 关联的用户模型通过get_user_model获取项目配置的用户模型
model = get_user_model()
# 注册表单需填写的字段:用户名和邮箱
fields = ("username", "email")
class ForgetPasswordForm(forms.Form):
"""忘记密码表单,用于用户重置密码(包含密码重置、邮箱验证、验证码验证)"""
# 新密码字段标签国际化使用密码输入框设置CSS类和占位符
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(
@ -76,7 +52,6 @@ class ForgetPasswordForm(forms.Form):
),
)
# 确认新密码字段:标签为“确认密码”,使用密码输入框,设置样式
new_password2 = forms.CharField(
label="确认密码",
widget=forms.PasswordInput(
@ -87,7 +62,6 @@ class ForgetPasswordForm(forms.Form):
),
)
# 邮箱字段:用于验证用户身份,使用文本输入框,设置样式
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
@ -98,7 +72,6 @@ class ForgetPasswordForm(forms.Form):
),
)
# 验证码字段:用于验证用户真实性,使用文本输入框,设置样式
code = forms.CharField(
label=_('Code'),
widget=forms.TextInput(
@ -110,44 +83,35 @@ class ForgetPasswordForm(forms.Form):
)
def clean_new_password2(self):
"""验证两次输入的新密码是否一致,并验证密码强度"""
password1 = self.data.get("new_password1") # 获取第一次输入的密码
password2 = self.data.get("new_password2") # 获取第二次输入的密码
# 如果两次密码都存在且不一致,抛出验证错误
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match"))
# 使用Django内置密码验证器验证密码强度如长度、复杂度等
password_validation.validate_password(password2)
return password2
def clean_email(self):
"""验证邮箱是否已注册(存在于用户表中)"""
user_email = self.cleaned_data.get("email")
# 如果该邮箱不存在于BlogUser表中抛出验证错误
if not BlogUser.objects.filter(
email=user_email
).exists():
# 注意:此处提示可能暴露邮箱是否注册,可根据需求修改
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
raise ValidationError(_("email does not exist"))
return user_email
def clean_code(self):
"""验证验证码是否有效调用自定义工具类的verify方法"""
code = self.cleaned_data.get("code")
# 调用utils.verify验证邮箱和验证码是否匹配返回错误信息若有
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
)
# 如果验证失败(有错误信息),抛出验证错误
if error:
raise ValidationError(error)
return code
class ForgetPasswordCodeForm(forms.Form):
"""获取忘记密码验证码的表单,用于提交邮箱以发送验证码"""
# 邮箱字段:用于指定需要发送验证码的邮箱,标签国际化
email = forms.EmailField(
label=_('Email'),
)
)

@ -1,77 +1,49 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 以上注释为Django自动生成标识该迁移文件由Django 4.1.7版本在2023-03-02 07:14生成
# 导入Django内置的用户模型相关模块
import django.contrib.auth.models
# 导入Django内置的用户验证器
import django.contrib.auth.validators
# 从django.db导入迁移和模型相关类
from django.db import migrations, models
# 导入Django的时区工具
import django.utils.timezone
# 定义迁移类继承自migrations.Migration
class Migration(migrations.Migration):
# 标识这是初始迁移(首次创建模型的迁移)
initial = True
# 依赖的其他迁移文件这里依赖auth应用的0012_alter_user_first_name_max_length迁移
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
# 迁移操作列表,包含要执行的数据库操作
operations = [
# 创建BlogUser模型的迁移操作
migrations.CreateModel(
name='BlogUser', # 模型名称
fields=[ # 模型字段定义列表
# 自增主键字段BigAutoField适用于大数据量场景
name='BlogUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 密码字段最大长度128显示名称为'password'
('password', models.CharField(max_length=128, verbose_name='password')),
# 最后登录时间字段,可为空且允许空白,显示名称为'last login'
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
# 是否为超级用户字段默认False包含帮助文本和显示名称
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
# 用户名字段包含错误信息、帮助文本、最大长度150、唯一约束、验证器和显示名称
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
# 名字段允许空白最大长度150显示名称为'first name'
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
# 姓字段允许空白最大长度150显示名称为'last name'
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
# 邮箱字段允许空白最大长度254显示名称为'email address'
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
# 是否为管理员可登录admin站点默认False包含帮助文本和显示名称
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
# 是否激活字段默认True包含帮助文本建议通过此选项禁用账户而非删除和显示名称
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
# 加入日期字段,默认值为当前时区时间,显示名称为'date joined'
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
# 自定义昵称字段允许空白最大长度100显示名称为'昵称'
('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
# 自定义创建时间字段,默认值为当前时区时间,显示名称为'创建时间'
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 自定义最后修改时间字段,默认值为当前时区时间,显示名称为'修改时间'
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 自定义创建来源字段允许空白最大长度100显示名称为'创建来源'
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
# 与auth.Group的多对多关系用于用户组权限管理包含帮助文本、关联名称和显示名称
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
# 与auth.Permission的多对多关系用于用户单独权限管理包含帮助文本、关联名称和显示名称
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={ # 模型的元数据配置
'verbose_name': '用户', # 模型的单数显示名称
'verbose_name_plural': '用户', # 模型的复数显示名称
'ordering': ['-id'], # 排序方式按id降序
'get_latest_by': 'id', # 获取最新记录的依据字段id
options={
'verbose_name': '用户',
'verbose_name_plural': '用户',
'ordering': ['-id'],
'get_latest_by': 'id',
},
managers=[ # 模型的管理器配置
# 使用Django内置的UserManager作为模型的管理器
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]
]

@ -0,0 +1,46 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
),
migrations.RemoveField(
model_name='bloguser',
name='created_time',
),
migrations.RemoveField(
model_name='bloguser',
name='last_mod_time',
),
migrations.AddField(
model_name='bloguser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='bloguser',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
migrations.AlterField(
model_name='bloguser',
name='nickname',
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
),
migrations.AlterField(
model_name='bloguser',
name='source',
field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
),
]

@ -0,0 +1,35 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site
# Create your models here.
class BlogUser(AbstractUser):
nickname = models.CharField(_('nick name'), max_length=100, blank=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
source = models.CharField(_('create source'), max_length=100, blank=True)
def get_absolute_url(self):
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
def __str__(self):
return self.email
def get_full_url(self):
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
ordering = ['-id']
verbose_name = _('user')
verbose_name_plural = verbose_name
get_latest_by = 'id'

@ -1,276 +1,207 @@
# 导入Django测试所需的核心类Client模拟HTTP请求、RequestFactory创建请求对象、TestCase测试基类
from django.test import Client, RequestFactory, TestCase
# 导入reverse通过URL名称生成路径用于测试中定位接口
from django.urls import reverse
# 导入timezone处理时间相关字段用于创建测试数据
from django.utils import timezone
# 导入翻译工具,用于处理国际化文本(测试中未直接使用,但保持原导入)
from django.utils.translation import gettext_lazy as _
# 导入需要测试的自定义模型:用户模型、文章模型、分类模型
from accounts.models import BlogUser
from blog.models import Article, Category
# 导入项目工具函数(如获取当前站点、加密、缓存操作等)
from djangoblog.utils import *
# 导入当前模块的工具函数(如验证码处理)
from . import utils
# 定义账户相关测试类继承Django的TestCase提供测试框架支持
# Create your tests here.
class AccountTest(TestCase):
def setUp(self):
"""
测试初始化方法在每个测试方法执行前自动调用
用于创建共用的测试对象避免代码重复
"""
# 创建测试客户端用于模拟用户发送HTTP请求
self.client = Client()
# 创建请求工厂,用于生成原始请求对象(按需使用)
self.factory = RequestFactory()
# 创建普通测试用户用户名test、邮箱admin@admin.com、密码12345678
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
# 定义测试用的新密码,后续忘记密码测试中使用
self.new_test = "xxx123--="
def test_validate_account(self):
"""测试账户基础功能超级用户创建、登录验证、admin访问、内容创建与管理"""
# 获取当前站点域名(测试中未实际使用,保持原逻辑)
site = get_current_site().domain
# 创建超级用户拥有admin管理权限
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="qwer!@#$ggg")
# 通过用户名查询刚创建的超级用户,验证创建成功
testuser = BlogUser.objects.get(username='liangliangyy1')
# 使用测试客户端模拟超级用户登录
loginresult = self.client.login(
username='liangliangyy1',
password='qwer!@#$ggg')
# 断言登录成功返回True
self.assertEqual(loginresult, True)
# 模拟访问admin后台页面
response = self.client.get('/admin/')
# 断言admin页面访问成功状态码200
self.assertEqual(response.status_code, 200)
# 创建测试分类:设置名称、创建时间、修改时间并保存
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建测试文章:关联作者(超级用户)和分类,设置标题、内容、类型、状态并保存
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
article.author = user
article.category = category
article.type = 'a' # 假设'a'代表普通文章类型
article.status = 'p' # 假设'p'代表已发布状态
article.type = 'a'
article.status = 'p'
article.save()
# 模拟访问文章的admin管理页面通过文章模型的get_admin_url方法获取路径
response = self.client.get(article.get_admin_url())
# 断言文章管理页面访问成功状态码200
self.assertEqual(response.status_code, 200)
def test_validate_register(self):
"""测试用户注册流程:注册请求、注册后用户存在性、邮箱验证、登录与权限升级、内容管理"""
# 注册前断言邮箱为user123@user.com的用户不存在初始状态
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 模拟发送注册请求向account:register接口提交用户名、邮箱、两次一致的密码
response = self.client.post(reverse('account:register'), {
'username': 'user1233',
'email': 'user123@user.com',
'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T',
})
# 注册后断言邮箱为user123@user.com的用户存在注册成功
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 获取刚注册的用户,生成邮箱验证链接(使用项目加密逻辑生成签名)
user = BlogUser.objects.filter(email='user123@user.com')[0]
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) # 双重SHA256加密签名
path = reverse('accounts:result') # 验证结果页的URL路径
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
path = reverse('accounts:result')
url = '{path}?type=validation&id={id}&sign={sign}'.format(
path=path, id=user.id, sign=sign) # 拼接完整验证URL
# 模拟访问邮箱验证链接
path=path, id=user.id, sign=sign)
response = self.client.get(url)
# 断言验证页面访问成功状态码200
self.assertEqual(response.status_code, 200)
# 模拟刚注册的用户登录
self.client.login(username='user1233', password='password123!q@wE#R$T')
# 升级用户权限为超级用户便于测试admin功能
user = BlogUser.objects.filter(email='user123@user.com')[0]
user.is_superuser = True # 设为超级用户
user.is_staff = True # 允许登录admin
user.is_superuser = True
user.is_staff = True
user.save()
# 删除侧边栏缓存(项目自定义缓存操作,测试中保持原逻辑)
delete_sidebar_cache()
# 创建测试分类(用于后续创建文章)
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建测试文章(关联升级权限后的用户)
article = Article()
article.category = category
article.title = "nicetitle333"
article.body = "nicecontentttt"
article.author = user
article.type = 'a'
article.status = 'p'
article.save()
# 模拟访问文章的admin管理页面
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
# 模拟用户登出
response = self.client.get(reverse('account:logout'))
# 断言登出请求响应正常允许301/302重定向或200成功
self.assertIn(response.status_code, [301, 302, 200])
# 登出后尝试访问文章admin页面应被拒绝或重定向
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
# 模拟使用错误密码登录(密码不匹配)
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123'
})
# 断言登录请求响应正常(无论成功失败,状态码合法)
self.assertIn(response.status_code, [301, 302, 200])
# 错误登录后尝试访问文章admin页面应被拒绝或重定向
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
def test_verify_email_code(self):
"""测试邮箱验证码验证逻辑:正确验证码验证成功,错误邮箱验证失败"""
# 定义测试邮箱和生成随机验证码
to_email = "admin@admin.com"
code = generate_code() # 调用工具函数生成验证码
# 存储验证码(关联邮箱,用于后续验证)
code = generate_code()
utils.set_code(to_email, code)
# 发送验证邮件(测试中仅执行流程,不实际校验邮件发送结果)
utils.send_verify_email(to_email, code)
# 验证1使用正确的邮箱和验证码断言无错误返回验证成功
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
# 验证2使用错误的邮箱与存储的验证码不匹配断言返回错误信息字符串类型
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
def test_forget_password_email_code_success(self):
"""测试获取忘记密码验证码的成功场景:提交正确邮箱,返回成功响应"""
# 模拟向account:forget_password_code接口提交正确邮箱
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com")
)
# 断言请求成功状态码200且返回内容为"ok"(表示验证码发送成功)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok")
def test_forget_password_email_code_fail(self):
"""测试获取忘记密码验证码的失败场景:无邮箱、邮箱格式错误"""
# 失败场景1不提交邮箱空数据
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict()
)
# 断言返回"错误的邮箱"(参数缺失)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
# 失败场景2提交格式错误的邮箱admin@com不符合标准格式
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@com")
)
# 断言返回"错误的邮箱"(格式校验失败)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
def test_forget_password_email_success(self):
"""测试忘记密码重置成功场景:提交正确验证码和新密码,密码修改生效"""
# 生成验证码并关联测试用户的邮箱(模拟用户已获取验证码)
code = generate_code()
utils.set_code(self.blog_user.email, code)
# 构造忘记密码重置请求数据:新密码(两次一致)、用户邮箱、正确验证码
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code=code,
)
# 模拟发送密码重置请求
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
# 断言重置成功重定向到结果页状态码302
self.assertEqual(resp.status_code, 302)
# 验证密码是否真的修改成功:查询用户并校验新密码
# 验证用户密码是否修改成功
blog_user = BlogUser.objects.filter(
email=self.blog_user.email,
).first() # 获取用户实例
self.assertNotEqual(blog_user, None) # 断言用户存在
# 使用check_password方法验证新密码是否匹配Django内置密码校验自动处理哈希
).first() # type: BlogUser
self.assertNotEqual(blog_user, None)
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
def test_forget_password_email_not_user(self):
"""测试忘记密码重置失败场景:使用不存在的邮箱"""
# 构造请求数据:新密码、不存在的邮箱、任意验证码
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email="123@123.com",
code="123456",
)
# 模拟发送密码重置请求
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
# 断言请求响应正常页面返回错误提示状态码200
self.assertEqual(resp.status_code, 200)
def test_forget_password_email_code_error(self):
"""测试忘记密码重置失败场景:验证码错误"""
# 生成正确验证码并关联用户邮箱,但请求时提交错误验证码
code = generate_code()
utils.set_code(self.blog_user.email, code)
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code="111111", # 错误的验证码
code="111111",
)
# 模拟发送密码重置请求
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
# 断言请求响应正常页面返回验证码错误提示状态码200
self.assertEqual(resp.status_code, 200)

@ -0,0 +1,28 @@
from django.urls import path
from django.urls import re_path
from . import views
from .forms import LoginForm
app_name = "accounts"
urlpatterns = [re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login',
kwargs={'authentication_form': LoginForm}),
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"),
name='register'),
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
path(r'account/result.html',
views.account_result,
name='result'),
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'),
]

@ -0,0 +1,26 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
class EmailOrUsernameModelBackend(ModelBackend):
"""
允许使用用户名或邮箱登录
"""
def authenticate(self, request, username=None, password=None, **kwargs):
if '@' in username:
kwargs = {'email': username}
else:
kwargs = {'username': username}
try:
user = get_user_model().objects.get(**kwargs)
if user.check_password(password):
return user
except get_user_model().DoesNotExist:
return None
def get_user(self, username):
try:
return get_user_model().objects.get(pk=username)
except get_user_model().DoesNotExist:
return None

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

@ -0,0 +1,204 @@
import logging
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.contrib import auth
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth import get_user_model
from django.contrib.auth import logout
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.hashers import make_password
from django.http import HttpResponseRedirect, HttpResponseForbidden
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, RedirectView
from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
from . import utils
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
from .models import BlogUser
logger = logging.getLogger(__name__)
# Create your views here.
class RegisterView(FormView):
form_class = RegisterForm
template_name = 'account/registration_form.html'
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
return super(RegisterView, self).dispatch(*args, **kwargs)
def form_valid(self, form):
if form.is_valid():
user = form.save(False)
user.is_active = False
user.source = 'Register'
user.save(True)
site = get_current_site().domain
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
if settings.DEBUG:
site = '127.0.0.1:8000'
path = reverse('account:result')
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign)
content = """
<p>请点击下面链接验证您的邮箱</p>
<a href="{url}" rel="bookmark">{url}</a>
再次感谢您
<br />
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
send_email(
emailto=[
user.email,
],
title='验证您的电子邮箱',
content=content)
url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id)
return HttpResponseRedirect(url)
else:
return self.render_to_response({
'form': form
})
class LogoutView(RedirectView):
url = '/login/'
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
return super(LogoutView, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
logout(request)
delete_sidebar_cache()
return super(LogoutView, self).get(request, *args, **kwargs)
class LoginView(FormView):
form_class = LoginForm
template_name = 'account/login.html'
success_url = '/'
redirect_field_name = REDIRECT_FIELD_NAME
login_ttl = 2626560 # 一个月的时间
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
redirect_to = self.request.GET.get(self.redirect_field_name)
if redirect_to is None:
redirect_to = '/'
kwargs['redirect_to'] = redirect_to
return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form):
form = AuthenticationForm(data=self.request.POST, request=self.request)
if form.is_valid():
delete_sidebar_cache()
logger.info(self.redirect_field_name)
auth.login(self.request, form.get_user())
if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl)
return super(LoginView, self).form_valid(form)
# return HttpResponseRedirect('/')
else:
return self.render_to_response({
'form': form
})
def get_success_url(self):
redirect_to = self.request.POST.get(self.redirect_field_name)
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
redirect_to = self.success_url
return redirect_to
def account_result(request):
type = request.GET.get('type')
id = request.GET.get('id')
user = get_object_or_404(get_user_model(), id=id)
logger.info(type)
if user.is_active:
return HttpResponseRedirect('/')
if type and type in ['register', 'validation']:
if type == 'register':
content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
'''
title = '注册成功'
else:
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
sign = request.GET.get('sign')
if sign != c_sign:
return HttpResponseForbidden()
user.is_active = True
user.save()
content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功'
return render(request, 'account/result.html', {
'title': title,
'content': content
})
else:
return HttpResponseRedirect('/')
class ForgetPasswordView(FormView):
form_class = ForgetPasswordForm
template_name = 'account/forget_password.html'
def form_valid(self, form):
if form.is_valid():
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
blog_user.password = make_password(form.cleaned_data["new_password2"])
blog_user.save()
return HttpResponseRedirect('/login/')
else:
return self.render_to_response({'form': form})
class ForgetPasswordEmailCode(View):
def post(self, request: HttpRequest):
form = ForgetPasswordCodeForm(request.POST)
if not form.is_valid():
return HttpResponse("错误的邮箱")
to_email = form.cleaned_data["email"]
code = generate_code()
utils.send_verify_email(to_email, code)
utils.set_code(to_email, code)
return HttpResponse("ok")

@ -0,0 +1,112 @@
from django import forms
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
# Register your models here.
from .models import Article
class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta:
model = Article
fields = '__all__'
def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p')
def draft_article(modeladmin, request, queryset):
queryset.update(status='d')
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c')
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
open_article_commentstatus.short_description = _('Open article comments')
class ArticlelAdmin(admin.ModelAdmin):
list_per_page = 20
search_fields = ('body', 'title')
form = ArticleForm
list_display = (
'id',
'title',
'author',
'link_to_category',
'creation_time',
'views',
'status',
'type',
'article_order')
list_display_links = ('id', 'title')
list_filter = ('status', 'type', 'category')
filter_horizontal = ('tags',)
exclude = ('creation_time', 'last_modify_time')
view_on_site = True
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
def link_to_category(self, obj):
info = (obj.category._meta.app_label, obj.category._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
return format_html(u'<a href="%s">%s</a>' % (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):
if obj:
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):
exclude = ('slug', 'last_mod_time', 'creation_time')
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time')
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
class BlogSettingsAdmin(admin.ModelAdmin):
pass

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
name = 'blog'

@ -0,0 +1,43 @@
import logging
from django.utils import timezone
from djangoblog.utils import cache, get_blog_setting
from .models import Category, Article
logger = logging.getLogger(__name__)
def seo_processor(requests):
key = 'seo_processor'
value = cache.get(key)
if value:
return value
else:
logger.info('set processor cache.')
setting = get_blog_setting()
value = {
'SITE_NAME': setting.site_name,
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
'SITE_SEO_DESCRIPTION': setting.site_seo_description,
'SITE_DESCRIPTION': setting.site_description,
'SITE_KEYWORDS': setting.site_keywords,
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
'ARTICLE_SUB_LENGTH': setting.article_sub_length,
'nav_category_list': Category.objects.all(),
'nav_pages': Article.objects.filter(
type='p',
status='p'),
'OPEN_SITE_COMMENT': setting.open_site_comment,
'BEIAN_CODE': setting.beian_code,
'ANALYTICS_CODE': setting.analytics_code,
"BEIAN_CODE_GONGAN": setting.gongan_beiancode,
"SHOW_GONGAN_CODE": setting.show_gongan_code,
"CURRENT_YEAR": timezone.now().year,
"GLOBAL_HEADER": setting.global_header,
"GLOBAL_FOOTER": setting.global_footer,
"COMMENT_NEED_REVIEW": setting.comment_need_review,
}
cache.set(key, value, 60 * 60 * 10)
return value

@ -0,0 +1,213 @@
import time
import elasticsearch.client
from django.conf import settings
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_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
if ELASTICSEARCH_ENABLED:
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
from elasticsearch.client import IngestClient
c = IngestClient(es)
try:
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
{
"geoip" : {
"field" : "ip"
}
}
]
}''')
class GeoIp(InnerDoc):
continent_name = Keyword()
country_iso_code = Keyword()
country_name = Keyword()
location = GeoPoint()
class UserAgentBrowser(InnerDoc):
Family = Keyword()
Version = Keyword()
class UserAgentOS(UserAgentBrowser):
pass
class UserAgentDevice(InnerDoc):
Family = Keyword()
Brand = Keyword()
Model = Keyword()
class UserAgent(InnerDoc):
browser = Object(UserAgentBrowser, required=False)
os = Object(UserAgentOS, required=False)
device = Object(UserAgentDevice, required=False)
string = Text()
is_bot = Boolean()
class ElapsedTimeDocument(Document):
url = Keyword()
time_taken = Long()
log_datetime = Date()
ip = Keyword()
geoip = Object(GeoIp, required=False)
useragent = Object(UserAgent, required=False)
class Index:
name = 'performance'
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
}
class Meta:
doc_type = 'ElapsedTime'
class ElaspedTimeDocumentManager:
@staticmethod
def build_index():
from elasticsearch import Elasticsearch
client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
res = client.indices.exists(index="performance")
if not res:
ElapsedTimeDocument.init()
@staticmethod
def delete_index():
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='performance', ignore=[400, 404])
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
ElaspedTimeDocumentManager.build_index()
ua = UserAgent()
ua.browser = UserAgentBrowser()
ua.browser.Family = useragent.browser.family
ua.browser.Version = useragent.browser.version_string
ua.os = UserAgentOS()
ua.os.Family = useragent.os.family
ua.os.Version = useragent.os.version_string
ua.device = UserAgentDevice()
ua.device.Family = useragent.device.family
ua.device.Brand = useragent.device.brand
ua.device.Model = useragent.device.model
ua.string = useragent.ua_string
ua.is_bot = useragent.is_bot
doc = ElapsedTimeDocument(
meta={
'id': int(
round(
time.time() *
1000))
},
url=url,
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua, ip=ip)
doc.save(pipeline="geoip")
class ArticleDocument(Document):
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
author = Object(properties={
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
category = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
tags = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
pub_time = Date()
status = Text()
comment_status = Text()
type = Text()
views = Integer()
article_order = Integer()
class Index:
name = 'blog'
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
}
class Meta:
doc_type = 'Article'
class ArticleDocumentManager():
def __init__(self):
self.create_index()
def create_index(self):
ArticleDocument.init()
def delete_index(self):
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='blog', ignore=[400, 404])
def convert_to_doc(self, articles):
return [
ArticleDocument(
meta={
'id': article.id},
body=article.body,
title=article.title,
author={
'nickname': article.author.username,
'id': article.author.id},
category={
'name': article.category.name,
'id': article.category.id},
tags=[
{
'name': t.name,
'id': t.id} for t in article.tags.all()],
pub_time=article.pub_time,
status=article.status,
comment_status=article.comment_status,
type=article.type,
views=article.views,
article_order=article.article_order) for article in articles]
def rebuild(self, articles=None):
ArticleDocument.init()
articles = articles if articles else Article.objects.all()
docs = self.convert_to_doc(articles)
for doc in docs:
doc.save()
def update_docs(self, docs):
for doc in docs:
doc.save()

@ -0,0 +1,19 @@
import logging
from django import forms
from haystack.forms import SearchForm
logger = logging.getLogger(__name__)
class BlogSearchForm(SearchForm):
querydata = forms.CharField(required=True)
def search(self):
datas = super(BlogSearchForm, self).search()
if not self.is_valid():
return self.no_query_found()
if self.cleaned_data['querydata']:
logger.info(self.cleaned_data['querydata'])
return datas

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

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

@ -0,0 +1,13 @@
from haystack import indexes
from blog.models import Article
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
text = indexes.CharField(document=True, use_template=True)
def get_model(self):
return Article
def index_queryset(self, using=None):
return self.get_model().objects.filter(status='p')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

@ -1,91 +0,0 @@
/**
* Created by liangliang on 2016/11/20.
*/
function do_reply(parentid) {
console.log(parentid);
$("#id_parent_comment_id").val(parentid)
$("#commentform").appendTo($("#div-comment-" + parentid));
$("#reply-title").hide();
$("#cancel_comment").show();
}
function cancel_reply() {
$("#reply-title").show();
$("#cancel_comment").hide();
$("#id_parent_comment_id").val('')
$("#commentform").appendTo($("#respond"));
}
NProgress.start();
NProgress.set(0.4);
//Increment
var interval = setInterval(function () {
NProgress.inc();
}, 1000);
$(document).ready(function () {
NProgress.done();
clearInterval(interval);
});
/** 侧边栏回到顶部 */
var rocket = $('#rocket');
$(window).on('scroll', debounce(slideTopSet, 300));
function debounce(func, wait) {
var timeout;
return function () {
clearTimeout(timeout);
timeout = setTimeout(func, wait);
};
}
function slideTopSet() {
var top = $(document).scrollTop();
if (top > 200) {
rocket.addClass('show');
} else {
rocket.removeClass('show');
}
}
$(document).on('click', '#rocket', function (event) {
rocket.addClass('move');
$('body, html').animate({
scrollTop: 0
}, 800);
});
$(document).on('animationEnd', function () {
setTimeout(function () {
rocket.removeClass('move');
}, 400);
});
$(document).on('webkitAnimationEnd', function () {
setTimeout(function () {
rocket.removeClass('move');
}, 400);
});
window.onload = function () {
var replyLinks = document.querySelectorAll(".comment-reply-link");
for (var i = 0; i < replyLinks.length; i++) {
replyLinks[i].onclick = function () {
var pk = this.getAttribute("data-pk");
do_reply(pk);
};
}
};
// $(document).ready(function () {
// var form = $('#i18n-form');
// var selector = $('.i18n-select');
// selector.on('change', function () {
// form.submit();
// });
// });

@ -1,480 +0,0 @@
/* NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress
* @license MIT */
;(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define(factory);
} else if (typeof exports === 'object') {
module.exports = factory();
} else {
root.NProgress = factory();
}
})(this, function() {
var NProgress = {};
NProgress.version = '0.2.0';
var Settings = NProgress.settings = {
minimum: 0.08,
easing: 'linear',
positionUsing: '',
speed: 200,
trickle: true,
trickleSpeed: 200,
showSpinner: true,
barSelector: '[role="bar"]',
spinnerSelector: '[role="spinner"]',
parent: 'body',
template: '<div class="bar" role="bar"><div class="peg"></div></div><div class="spinner" role="spinner"><div class="spinner-icon"></div></div>'
};
/**
* Updates configuration.
*
* NProgress.configure({
* minimum: 0.1
* });
*/
NProgress.configure = function(options) {
var key, value;
for (key in options) {
value = options[key];
if (value !== undefined && options.hasOwnProperty(key)) Settings[key] = value;
}
return this;
};
/**
* Last number.
*/
NProgress.status = null;
/**
* Sets the progress bar status, where `n` is a number from `0.0` to `1.0`.
*
* NProgress.set(0.4);
* NProgress.set(1.0);
*/
NProgress.set = function(n) {
var started = NProgress.isStarted();
n = clamp(n, Settings.minimum, 1);
NProgress.status = (n === 1 ? null : n);
var progress = NProgress.render(!started),
bar = progress.querySelector(Settings.barSelector),
speed = Settings.speed,
ease = Settings.easing;
progress.offsetWidth; /* Repaint */
queue(function(next) {
// Set positionUsing if it hasn't already been set
if (Settings.positionUsing === '') Settings.positionUsing = NProgress.getPositioningCSS();
// Add transition
css(bar, barPositionCSS(n, speed, ease));
if (n === 1) {
// Fade out
css(progress, {
transition: 'none',
opacity: 1
});
progress.offsetWidth; /* Repaint */
setTimeout(function() {
css(progress, {
transition: 'all ' + speed + 'ms linear',
opacity: 0
});
setTimeout(function() {
NProgress.remove();
next();
}, speed);
}, speed);
} else {
setTimeout(next, speed);
}
});
return this;
};
NProgress.isStarted = function() {
return typeof NProgress.status === 'number';
};
/**
* Shows the progress bar.
* This is the same as setting the status to 0%, except that it doesn't go backwards.
*
* NProgress.start();
*
*/
NProgress.start = function() {
if (!NProgress.status) NProgress.set(0);
var work = function() {
setTimeout(function() {
if (!NProgress.status) return;
NProgress.trickle();
work();
}, Settings.trickleSpeed);
};
if (Settings.trickle) work();
return this;
};
/**
* Hides the progress bar.
* This is the *sort of* the same as setting the status to 100%, with the
* difference being `done()` makes some placebo effect of some realistic motion.
*
* NProgress.done();
*
* If `true` is passed, it will show the progress bar even if its hidden.
*
* NProgress.done(true);
*/
NProgress.done = function(force) {
if (!force && !NProgress.status) return this;
return NProgress.inc(0.3 + 0.5 * Math.random()).set(1);
};
/**
* Increments by a random amount.
*/
NProgress.inc = function(amount) {
var n = NProgress.status;
if (!n) {
return NProgress.start();
} else if(n > 1) {
} else {
if (typeof amount !== 'number') {
if (n >= 0 && n < 0.2) { amount = 0.1; }
else if (n >= 0.2 && n < 0.5) { amount = 0.04; }
else if (n >= 0.5 && n < 0.8) { amount = 0.02; }
else if (n >= 0.8 && n < 0.99) { amount = 0.005; }
else { amount = 0; }
}
n = clamp(n + amount, 0, 0.994);
return NProgress.set(n);
}
};
NProgress.trickle = function() {
return NProgress.inc();
};
/**
* Waits for all supplied jQuery promises and
* increases the progress as the promises resolve.
*
* @param $promise jQUery Promise
*/
(function() {
var initial = 0, current = 0;
NProgress.promise = function($promise) {
if (!$promise || $promise.state() === "resolved") {
return this;
}
if (current === 0) {
NProgress.start();
}
initial++;
current++;
$promise.always(function() {
current--;
if (current === 0) {
initial = 0;
NProgress.done();
} else {
NProgress.set((initial - current) / initial);
}
});
return this;
};
})();
/**
* (Internal) renders the progress bar markup based on the `template`
* setting.
*/
NProgress.render = function(fromStart) {
if (NProgress.isRendered()) return document.getElementById('nprogress');
addClass(document.documentElement, 'nprogress-busy');
var progress = document.createElement('div');
progress.id = 'nprogress';
progress.innerHTML = Settings.template;
var bar = progress.querySelector(Settings.barSelector),
perc = fromStart ? '-100' : toBarPerc(NProgress.status || 0),
parent = document.querySelector(Settings.parent),
spinner;
css(bar, {
transition: 'all 0 linear',
transform: 'translate3d(' + perc + '%,0,0)'
});
if (!Settings.showSpinner) {
spinner = progress.querySelector(Settings.spinnerSelector);
spinner && removeElement(spinner);
}
if (parent != document.body) {
addClass(parent, 'nprogress-custom-parent');
}
parent.appendChild(progress);
return progress;
};
/**
* Removes the element. Opposite of render().
*/
NProgress.remove = function() {
removeClass(document.documentElement, 'nprogress-busy');
removeClass(document.querySelector(Settings.parent), 'nprogress-custom-parent');
var progress = document.getElementById('nprogress');
progress && removeElement(progress);
};
/**
* Checks if the progress bar is rendered.
*/
NProgress.isRendered = function() {
return !!document.getElementById('nprogress');
};
/**
* Determine which positioning CSS rule to use.
*/
NProgress.getPositioningCSS = function() {
// Sniff on document.body.style
var bodyStyle = document.body.style;
// Sniff prefixes
var vendorPrefix = ('WebkitTransform' in bodyStyle) ? 'Webkit' :
('MozTransform' in bodyStyle) ? 'Moz' :
('msTransform' in bodyStyle) ? 'ms' :
('OTransform' in bodyStyle) ? 'O' : '';
if (vendorPrefix + 'Perspective' in bodyStyle) {
// Modern browsers with 3D support, e.g. Webkit, IE10
return 'translate3d';
} else if (vendorPrefix + 'Transform' in bodyStyle) {
// Browsers without 3D support, e.g. IE9
return 'translate';
} else {
// Browsers without translate() support, e.g. IE7-8
return 'margin';
}
};
/**
* Helpers
*/
function clamp(n, min, max) {
if (n < min) return min;
if (n > max) return max;
return n;
}
/**
* (Internal) converts a percentage (`0..1`) to a bar translateX
* percentage (`-100%..0%`).
*/
function toBarPerc(n) {
return (-1 + n) * 100;
}
/**
* (Internal) returns the correct CSS for changing the bar's
* position given an n percentage, and speed and ease from Settings
*/
function barPositionCSS(n, speed, ease) {
var barCSS;
if (Settings.positionUsing === 'translate3d') {
barCSS = { transform: 'translate3d('+toBarPerc(n)+'%,0,0)' };
} else if (Settings.positionUsing === 'translate') {
barCSS = { transform: 'translate('+toBarPerc(n)+'%,0)' };
} else {
barCSS = { 'margin-left': toBarPerc(n)+'%' };
}
barCSS.transition = 'all '+speed+'ms '+ease;
return barCSS;
}
/**
* (Internal) Queues a function to be executed.
*/
var queue = (function() {
var pending = [];
function next() {
var fn = pending.shift();
if (fn) {
fn(next);
}
}
return function(fn) {
pending.push(fn);
if (pending.length == 1) next();
};
})();
/**
* (Internal) Applies css properties to an element, similar to the jQuery
* css method.
*
* While this helper does assist with vendor prefixed property names, it
* does not perform any manipulation of values prior to setting styles.
*/
var css = (function() {
var cssPrefixes = [ 'Webkit', 'O', 'Moz', 'ms' ],
cssProps = {};
function camelCase(string) {
return string.replace(/^-ms-/, 'ms-').replace(/-([\da-z])/gi, function(match, letter) {
return letter.toUpperCase();
});
}
function getVendorProp(name) {
var style = document.body.style;
if (name in style) return name;
var i = cssPrefixes.length,
capName = name.charAt(0).toUpperCase() + name.slice(1),
vendorName;
while (i--) {
vendorName = cssPrefixes[i] + capName;
if (vendorName in style) return vendorName;
}
return name;
}
function getStyleProp(name) {
name = camelCase(name);
return cssProps[name] || (cssProps[name] = getVendorProp(name));
}
function applyCss(element, prop, value) {
prop = getStyleProp(prop);
element.style[prop] = value;
}
return function(element, properties) {
var args = arguments,
prop,
value;
if (args.length == 2) {
for (prop in properties) {
value = properties[prop];
if (value !== undefined && properties.hasOwnProperty(prop)) applyCss(element, prop, value);
}
} else {
applyCss(element, args[1], args[2]);
}
}
})();
/**
* (Internal) Determines if an element or space separated list of class names contains a class name.
*/
function hasClass(element, name) {
var list = typeof element == 'string' ? element : classList(element);
return list.indexOf(' ' + name + ' ') >= 0;
}
/**
* (Internal) Adds a class to an element.
*/
function addClass(element, name) {
var oldList = classList(element),
newList = oldList + name;
if (hasClass(oldList, name)) return;
// Trim the opening space.
element.className = newList.substring(1);
}
/**
* (Internal) Removes a class from an element.
*/
function removeClass(element, name) {
var oldList = classList(element),
newList;
if (!hasClass(element, name)) return;
// Replace the class name.
newList = oldList.replace(' ' + name + ' ', ' ');
// Trim the opening and closing spaces.
element.className = newList.substring(1, newList.length - 1);
}
/**
* (Internal) Gets a space separated list of the class names on the element.
* The list is wrapped with a single space on each end to facilitate finding
* matches within the list.
*/
function classList(element) {
return (' ' + (element && element.className || '') + ' ').replace(/\s+/gi, ' ');
}
/**
* (Internal) Removes an element from the DOM.
*/
function removeElement(element) {
element && element.parentNode && element.parentNode.removeChild(element);
}
return NProgress;
});

@ -18,12 +18,18 @@ from djangoblog.utils import CommonMarkdown, sanitize_html
from djangoblog.utils import cache
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
from djangoblog.plugin_manage import hooks
logger = logging.getLogger(__name__)
register = template.Library()
@register.simple_tag(takes_context=True)
def head_meta(context):
return mark_safe(hooks.apply_filters('head_meta', '', context))
@register.simple_tag
def timeformat(data):
try:

@ -0,0 +1,232 @@
import os
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
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
# Create your tests here.
class ArticleTest(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
def test_validate_article(self):
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
user.is_superuser = True
user.save()
response = self.client.get(user.get_absolute_url())
self.assertEqual(response.status_code, 200)
response = self.client.get('/admin/servermanager/emailsendlog/')
response = 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())
for i in range(20):
article = Article()
article.title = "nicetitle" + str(i)
article.body = "nicetitle" + str(i)
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
article.tags.add(tag)
article.save()
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())
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)
p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
self.check_pagination(p, '', '')
p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
self.check_pagination(p, '分类标签归档', tag.slug)
p = Paginator(
Article.objects.filter(
author__username='liangliangyy'), settings.PAGINATE_BY)
self.check_pagination(p, '作者文章归档', 'liangliangyy')
p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
self.check_pagination(p, '分类目录归档', category.slug)
f = BlogSearchForm()
f.search()
# self.client.login(username='liangliangyy', password='liangliangyy')
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.baidu_notify([article.get_full_url()])
from blog.templatetags.blog_tags import gravatar_url, gravatar
u = gravatar_url('liangliangyy@gmail.com')
u = gravatar('liangliangyy@gmail.com')
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)
response = self.client.get('/feed/')
self.assertEqual(response.status_code, 200)
response = self.client.get('/sitemap.xml')
self.assertEqual(response.status_code, 200)
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):
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):
import requests
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)
rsp = self.client.post('/upload')
self.assertEqual(rsp.status_code, 403)
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(
'/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):
rsp = self.client.get('/eee')
self.assertEqual(rsp.status_code, 404)
def test_commands(self):
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()
c = OAuthConfig()
c.type = 'qq'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
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()
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()
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")

@ -0,0 +1,62 @@
from django.urls import path
from django.views.decorators.cache import cache_page
from . import views
app_name = "blog"
urlpatterns = [
path(
r'',
views.IndexView.as_view(),
name='index'),
path(
r'page/<int:page>/',
views.IndexView.as_view(),
name='index_page'),
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
path(
r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
path(
r'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
path(
r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
path(
r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
path(
'archives.html',
cache_page(
60 * 60)(
views.ArchivesView.as_view()),
name='archives'),
path(
'links.html',
views.LinkListView.as_view(),
name='links'),
path(
r'upload',
views.fileupload,
name='upload'),
path(
r'clean',
views.clean_cache_view,
name='clean'),
]

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

Loading…
Cancel
Save