Compare commits

...

90 Commits

Author SHA1 Message Date
plhw57tbe b1359b18c1 ADD file via upload
3 months ago
plhw57tbe f1c373cf26 Delete 'doc/实践考评-开源软件大作业项目的自评报告.xlsx'
3 months ago
plhw57tbe f60e19d825 Merge pull request '合并分支' (#11) from smy_branch into master
3 months ago
plhw57tbe 6d1e58bfbf ADD file via upload
3 months ago
plhw57tbe 25cf8b846f ADD file via upload
3 months ago
pa2g3nmk9 9b20495c6e 上传文件至 'doc'
4 months ago
pa2g3nmk9 027dfca121 上传文件至 'doc'
4 months ago
plhw57tbe ed6be2080f ADD file via upload
4 months ago
plhw57tbe 7c71e55689 ADD file via upload
4 months ago
pa2g3nmk9 4f4a5253ce 上传文件至 'doc'
4 months ago
plhw57tbe 68d571203a ADD file via upload
4 months ago
zxc 1c792651b0 Update urls.py
4 months ago
zxc 1a1515649c Update tests.py
4 months ago
zxc 8e224aab0e Update robot.py
4 months ago
zxc bb83dc7a1f Update models.py
4 months ago
zxc 1fee210e0b Update apps.py
4 months ago
zxc 235351d277 Update admin.py
4 months ago
zxc 5e97ac4351 Update MemcacheStorage.py
4 months ago
zxc 06981cdda4 Update 0002_alter_emailsendlog_options_and_more.py
4 months ago
zxc 7fdc0a59be Update 0001_initial.py
4 months ago
zxc ee81ca1a4d Update commonapi.py
4 months ago
zxc 4e8ad15d2f Update blogapi.py
4 months ago
zxc 59810b588d Update plugin.py
4 months ago
zxc 74c83abfde Update plugin.py
4 months ago
zxc 94c7dbed19 Update plugin.py
4 months ago
zxc 84127fe0af Update plugin.py
4 months ago
zxc f83339d3eb Update plugin.py
4 months ago
zxc b130e7f44e Update views.py
4 months ago
zxc 7b2ec798ca Update urls.py
4 months ago
zxc b243aefa4a Update tests.py
4 months ago
zxc 8bb5d004d7 Update models.py
4 months ago
zxc 75482f7bac Update apps.py
4 months ago
zxc d12d2aba96 Update admin.py
4 months ago
zxc 5bc4b18368 Update 0002_alter_owntracklog_options_and_more.py
4 months ago
zxc 0144aa7ddf Update 0001_initial.py
4 months ago
plhw57tbe b837cfe1f8 Merge pull request '合并分支' (#10) from smy_branch into master
4 months ago
plhw57tbe a242b0638d Update wsgi.py
4 months ago
plhw57tbe 46a670b6c9 Update utils.py
4 months ago
plhw57tbe 14db64d21c Update urls.py
4 months ago
plhw57tbe c4f725f8f4 Update tests.py
4 months ago
plhw57tbe 3608f14abe Update spider_notify.py
4 months ago
plhw57tbe bce979ce7e Update sitemap.py
4 months ago
plhw57tbe bc5e04c973 Update settings.py
4 months ago
plhw57tbe 9e31e3db5e Update logentryadmin.py
4 months ago
plhw57tbe 0d0fb564d6 Update feeds.py
4 months ago
plhw57tbe f526a8d64a Update elasticsearch_backend.py
4 months ago
plhw57tbe 2d5359b2c7 Update blog_signals.py
4 months ago
plhw57tbe dd1d9aed71 Update apps.py
4 months ago
plhw57tbe a9a7bf2f0a Update admin_site.py
4 months ago
plhw57tbe da1d0ec141 Update __init__.py
4 months ago
plhw57tbe 66b18afadd Update hook_constants.py
4 months ago
plhw57tbe 526c31062b Update nginx.conf
4 months ago
plhw57tbe 19ca004489 Update storageclass.yaml
4 months ago
plhw57tbe cc830e8050 Update service.yaml
4 months ago
plhw57tbe 680adcd420 Update pvc.yaml
4 months ago
plhw57tbe 7fc0472a7d Update pv.yaml
4 months ago
plhw57tbe 3cbb7029a6 Update gateway.yaml
4 months ago
plhw57tbe 0608b6b368 Update deployment.yaml
4 months ago
plhw57tbe c70e63922d Update configmap.yaml
4 months ago
plhw57tbe 9f128c4f32 ADD file via upload
4 months ago
pa2g3nmk9 c83a06d26a 上传文件至 'doc'
4 months ago
pa2g3nmk9 9f60f12057 上传文件至 'doc'
4 months ago
plhw57tbe ba2c683180 Update docker-compose.yml
5 months ago
plhw57tbe 94dd264394 Update docker-compose.es.yml
5 months ago
plhw57tbe dc3eef1f13 Update views.py
5 months ago
plhw57tbe 11ae3570e0 Update utils.py
5 months ago
plhw57tbe aa95dc52b3 Update urls.py
5 months ago
plhw57tbe 955b0a6ead Update tests.py
5 months ago
plhw57tbe 82d4f9a98b Update models.py
5 months ago
plhw57tbe 65020e1115 Update forms.py
5 months ago
plhw57tbe 190f420c49 Update apps.py
5 months ago
plhw57tbe a8dcf5aa39 Update admin.py
5 months ago
plhw57tbe 4836e57e98 Update comments_tags.py
5 months ago
plhw57tbe 662cfd9759 Update 0003_alter_comment_options_remove_comment_created_time_and_more.py
5 months ago
plhw57tbe d2ff176400 Update 0002_alter_comment_is_enable.py
5 months ago
plhw57tbe 63328bcfcb Update 0001_initial.py
5 months ago
smy bf828e6d08 Merge branch 'smy_branch' of https://bdgit.educoder.net/plhw57tbe/SoftwareMethodology into smy_branch
5 months ago
smy f27ecb885f Merge branch 'develop' into smy_branch
5 months ago
smy 89f603335c Merge branch 'master' of https://bdgit.educoder.net/plhw57tbe/SoftwareMethodology
5 months ago
smy 3ce0187398 Merge branch 'develop'
5 months ago
smy 64cf96ce50 提交源文件
5 months ago
plhw57tbe 42884c5b0d ADD file via upload
5 months ago
smy edbc1f8c15 添加doc
5 months ago
smy 2570a1b030 删除master分支中的README.md文件
5 months ago
fz c7b1c2a3aa 合并develop分支:解决main.py冲突
5 months ago
fz f338bb2378 备份develop分支的修改
5 months ago
zxc 57eab4a72d 在src目录添加main.py文件
5 months ago
txb 3c06c750d4 在src目录添加main.py文件
5 months ago
smy 4201412bb4 在master分支添加doc目录及README.md文档
5 months ago
smy 0ef2d99dbc 删除README.md文件
5 months ago

@ -1 +1 @@
这是doc目录的说明文档
# Documentation

Binary file not shown.

Binary file not shown.

@ -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/

@ -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"]

@ -0,0 +1,20 @@
The MIT License (MIT)
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
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
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.

@ -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'

@ -0,0 +1,117 @@
from django import forms
from django.contrib.auth import get_user_model, password_validation
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.core.exceptions import ValidationError
from django.forms import widgets
from django.utils.translation import gettext_lazy as _
from . import utils
from .models import BlogUser
class LoginForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super(LoginForm, self).__init__(*args, **kwargs)
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
class RegisterForm(UserCreationForm):
def __init__(self, *args, **kwargs):
super(RegisterForm, self).__init__(*args, **kwargs)
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
def clean_email(self):
email = self.cleaned_data['email']
if get_user_model().objects.filter(email=email).exists():
raise ValidationError(_("email already exists"))
return email
class Meta:
model = get_user_model()
fields = ("username", "email")
class ForgetPasswordForm(forms.Form):
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("New password")
}
),
)
new_password2 = forms.CharField(
label="确认密码",
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("Confirm password")
}
),
)
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Email")
}
),
)
code = forms.CharField(
label=_('Code'),
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Code")
}
),
)
def clean_new_password2(self):
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match"))
password_validation.validate_password(password2)
return password2
def clean_email(self):
user_email = self.cleaned_data.get("email")
if not BlogUser.objects.filter(
email=user_email
).exists():
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
raise ValidationError(_("email does not exist"))
return user_email
def clean_code(self):
code = self.cleaned_data.get("code")
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
)
if error:
raise ValidationError(error)
return code
class ForgetPasswordCodeForm(forms.Form):
email = forms.EmailField(
label=_('Email'),
)

@ -0,0 +1,49 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='BlogUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': '用户',
'verbose_name_plural': '用户',
'ordering': ['-id'],
'get_latest_by': 'id',
},
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'

@ -0,0 +1,207 @@
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from accounts.models import BlogUser
from blog.models import Article, Category
from djangoblog.utils import *
from . import utils
# Create your tests here.
class AccountTest(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
self.new_test = "xxx123--="
def test_validate_account(self):
site = get_current_site().domain
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="qwer!@#$ggg")
testuser = BlogUser.objects.get(username='liangliangyy1')
loginresult = self.client.login(
username='liangliangyy1',
password='qwer!@#$ggg')
self.assertEqual(loginresult, True)
response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200)
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
def test_validate_register(self):
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
response = self.client.post(reverse('account:register'), {
'username': 'user1233',
'email': 'user123@user.com',
'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T',
})
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
user = BlogUser.objects.filter(email='user123@user.com')[0]
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
path = reverse('accounts:result')
url = '{path}?type=validation&id={id}&sign={sign}'.format(
path=path, id=user.id, sign=sign)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.client.login(username='user1233', password='password123!q@wE#R$T')
user = BlogUser.objects.filter(email='user123@user.com')[0]
user.is_superuser = True
user.is_staff = True
user.save()
delete_sidebar_cache()
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
article = Article()
article.category = category
article.title = "nicetitle333"
article.body = "nicecontentttt"
article.author = user
article.type = 'a'
article.status = 'p'
article.save()
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('account:logout'))
self.assertIn(response.status_code, [301, 302, 200])
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123'
})
self.assertIn(response.status_code, [301, 302, 200])
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
def test_verify_email_code(self):
to_email = "admin@admin.com"
code = generate_code()
utils.set_code(to_email, code)
utils.send_verify_email(to_email, code)
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
def test_forget_password_email_code_success(self):
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com")
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok")
def test_forget_password_email_code_fail(self):
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict()
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@com")
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
def test_forget_password_email_success(self):
code = generate_code()
utils.set_code(self.blog_user.email, code)
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code=code,
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 302)
# 验证用户密码是否修改成功
blog_user = BlogUser.objects.filter(
email=self.blog_user.email,
).first() # type: BlogUser
self.assertNotEqual(blog_user, None)
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
def test_forget_password_email_not_user(self):
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email="123@123.com",
code="123456",
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200)
def test_forget_password_email_code_error(self):
code = generate_code()
utils.set_code(self.blog_user.email, code)
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code="111111",
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200)

@ -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,18 @@
from django.core.management.base import BaseCommand
from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
ELASTICSEARCH_ENABLED
# TODO 参数化
class Command(BaseCommand):
help = 'build search index'
def handle(self, *args, **options):
if ELASTICSEARCH_ENABLED:
ElaspedTimeDocumentManager.build_index()
manager = ElapsedTimeDocument()
manager.init()
manager = ArticleDocumentManager()
manager.delete_index()
manager.rebuild()

@ -0,0 +1,13 @@
from django.core.management.base import BaseCommand
from blog.models import Tag, Category
# TODO 参数化
class Command(BaseCommand):
help = 'build search words'
def handle(self, *args, **options):
datas = set([t.name for t in Tag.objects.all()] +
[t.name for t in Category.objects.all()])
print('\n'.join(datas))

@ -0,0 +1,11 @@
from django.core.management.base import BaseCommand
from djangoblog.utils import cache
class Command(BaseCommand):
help = 'clear the whole cache'
def handle(self, *args, **options):
cache.clear()
self.stdout.write(self.style.SUCCESS('Cleared cache\n'))

@ -0,0 +1,40 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand
from blog.models import Article, Tag, Category
class Command(BaseCommand):
help = 'create test datas'
def handle(self, *args, **options):
user = get_user_model().objects.get_or_create(
email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0]
pcategory = Category.objects.get_or_create(
name='我是父类目', parent_category=None)[0]
category = Category.objects.get_or_create(
name='子类目', parent_category=pcategory)[0]
category.save()
basetag = Tag()
basetag.name = "标签"
basetag.save()
for i in range(1, 20):
article = Article.objects.get_or_create(
category=category,
title='nice title ' + str(i),
body='nice content ' + str(i),
author=user)[0]
tag = Tag()
tag.name = "标签" + str(i)
tag.save()
article.tags.add(tag)
article.tags.add(basetag)
article.save()
from djangoblog.utils import cache
cache.clear()
self.stdout.write(self.style.SUCCESS('created test datas \n'))

@ -0,0 +1,50 @@
from django.core.management.base import BaseCommand
from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import get_current_site
from blog.models import Article, Tag, Category
site = get_current_site().domain
class Command(BaseCommand):
help = 'notify baidu url'
def add_arguments(self, parser):
parser.add_argument(
'data_type',
type=str,
choices=[
'all',
'article',
'tag',
'category'],
help='article : all article,tag : all tag,category: all category,all: All of these')
def get_full_url(self, path):
url = "https://{site}{path}".format(site=site, path=path)
return url
def handle(self, *args, **options):
type = options['data_type']
self.stdout.write('start get %s' % type)
urls = []
if type == 'article' or type == 'all':
for article in Article.objects.filter(status='p'):
urls.append(article.get_full_url())
if type == 'tag' or type == 'all':
for tag in Tag.objects.all():
url = tag.get_absolute_url()
urls.append(self.get_full_url(url))
if type == 'category' or type == 'all':
for category in Category.objects.all():
url = category.get_absolute_url()
urls.append(self.get_full_url(url))
self.stdout.write(
self.style.SUCCESS(
'start notify %d urls' %
len(urls)))
SpiderNotify.baidu_notify(urls)
self.stdout.write(self.style.SUCCESS('finish notify'))

@ -0,0 +1,47 @@
import requests
from django.core.management.base import BaseCommand
from django.templatetags.static import static
from djangoblog.utils import save_user_avatar
from oauth.models import OAuthUser
from oauth.oauthmanager import get_manager_by_type
class Command(BaseCommand):
help = 'sync user avatar'
def test_picture(self, url):
try:
if requests.get(url, timeout=2).status_code == 200:
return True
except:
pass
def handle(self, *args, **options):
static_url = static("../")
users = OAuthUser.objects.all()
self.stdout.write(f'开始同步{len(users)}个用户头像')
for u in users:
self.stdout.write(f'开始同步:{u.nickname}')
url = u.picture
if url:
if url.startswith(static_url):
if self.test_picture(url):
continue
else:
if u.metadata:
manage = get_manager_by_type(u.type)
url = manage.get_picture(u.metadata)
url = save_user_avatar(url)
else:
url = static('blog/img/avatar.png')
else:
url = save_user_avatar(url)
else:
url = static('blog/img/avatar.png')
if url:
self.stdout.write(
f'结束同步:{u.nickname}.url:{url}')
u.picture = url
u.save()
self.stdout.write('结束同步')

@ -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,137 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BlogSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')),
('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')),
('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
],
options={
'verbose_name': '网站配置',
'verbose_name_plural': '网站配置',
},
),
migrations.CreateModel(
name='Links',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
('link', models.URLField(verbose_name='链接地址')),
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
'verbose_name': '友情链接',
'verbose_name_plural': '友情链接',
'ordering': ['sequence'],
},
),
migrations.CreateModel(
name='SideBar',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='标题')),
('content', models.TextField(verbose_name='内容')),
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
('is_enable', models.BooleanField(default=True, verbose_name='是否启用')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
'verbose_name': '侧边栏',
'verbose_name_plural': '侧边栏',
'ordering': ['sequence'],
},
),
migrations.CreateModel(
name='Tag',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')),
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
],
options={
'verbose_name': '标签',
'verbose_name_plural': '标签',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')),
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
],
options={
'verbose_name': '分类',
'verbose_name_plural': '分类',
'ordering': ['-index'],
},
),
migrations.CreateModel(
name='Article',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
('body', mdeditor.fields.MDTextField(verbose_name='正文')),
('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')),
('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')),
('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')),
],
options={
'verbose_name': '文章',
'verbose_name_plural': '文章',
'ordering': ['-article_order', '-pub_time'],
'get_latest_by': 'id',
},
),
]

@ -0,0 +1,23 @@
# Generated by Django 4.1.7 on 2023-03-29 06:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='blogsettings',
name='global_footer',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
),
migrations.AddField(
model_name='blogsettings',
name='global_header',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
),
]

@ -0,0 +1,17 @@
# Generated by Django 4.2.1 on 2023-05-09 07:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_blogsettings_global_footer_and_more'),
]
operations = [
migrations.AddField(
model_name='blogsettings',
name='comment_need_review',
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
),
]

@ -0,0 +1,27 @@
# Generated by Django 4.2.1 on 2023-05-09 07:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('blog', '0003_blogsettings_comment_need_review'),
]
operations = [
migrations.RenameField(
model_name='blogsettings',
old_name='analyticscode',
new_name='analytics_code',
),
migrations.RenameField(
model_name='blogsettings',
old_name='beiancode',
new_name='beian_code',
),
migrations.RenameField(
model_name='blogsettings',
old_name='sitename',
new_name='site_name',
),
]

@ -0,0 +1,300 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
),
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
),
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
),
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
migrations.RemoveField(
model_name='article',
name='created_time',
),
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
migrations.RemoveField(
model_name='category',
name='created_time',
),
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
migrations.RemoveField(
model_name='links',
name='created_time',
),
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
migrations.RemoveField(
model_name='tag',
name='created_time',
),
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
migrations.AddField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
migrations.AlterField(
model_name='article',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
),
migrations.AlterField(
model_name='article',
name='comment_status',
field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
),
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
),
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
),
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'),
),
migrations.AlterField(
model_name='blogsettings',
name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'),
),
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
),
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'),
),
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='index'),
),
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
),
migrations.AlterField(
model_name='category',
name='parent_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
),
migrations.AlterField(
model_name='links',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'),
),
migrations.AlterField(
model_name='links',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AlterField(
model_name='links',
name='link',
field=models.URLField(verbose_name='link'),
),
migrations.AlterField(
model_name='links',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
),
migrations.AlterField(
model_name='links',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
),
migrations.AlterField(
model_name='sidebar',
name='content',
field=models.TextField(verbose_name='content'),
),
migrations.AlterField(
model_name='sidebar',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
migrations.AlterField(
model_name='sidebar',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
migrations.AlterField(
model_name='sidebar',
name='name',
field=models.CharField(max_length=100, verbose_name='title'),
),
migrations.AlterField(
model_name='sidebar',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
]

@ -0,0 +1,17 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('blog', '0005_alter_article_options_alter_category_options_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='blogsettings',
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
),
]

@ -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')

@ -0,0 +1,344 @@
import hashlib
import logging
import random
import urllib
from django import template
from django.conf import settings
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.template.defaultfilters import stringfilter
from django.templatetags.static import static
from django.urls import reverse
from django.utils.safestring import mark_safe
from blog.models import Article, Category, Tag, Links, SideBar, LinkShowType
from comments.models import Comment
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:
return data.strftime(settings.TIME_FORMAT)
except Exception as e:
logger.error(e)
return ""
@register.simple_tag
def datetimeformat(data):
try:
return data.strftime(settings.DATE_TIME_FORMAT)
except Exception as e:
logger.error(e)
return ""
@register.filter()
@stringfilter
def custom_markdown(content):
return mark_safe(CommonMarkdown.get_markdown(content))
@register.simple_tag
def get_markdown_toc(content):
from djangoblog.utils import CommonMarkdown
body, toc = CommonMarkdown.get_markdown_with_toc(content)
return mark_safe(toc)
@register.filter()
@stringfilter
def comment_markdown(content):
content = CommonMarkdown.get_markdown(content)
return mark_safe(sanitize_html(content))
@register.filter(is_safe=True)
@stringfilter
def truncatechars_content(content):
"""
获得文章内容的摘要
:param content:
:return:
"""
from django.template.defaultfilters import truncatechars_html
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
return truncatechars_html(content, blogsetting.article_sub_length)
@register.filter(is_safe=True)
@stringfilter
def truncate(content):
from django.utils.html import strip_tags
return strip_tags(content)[:150]
@register.inclusion_tag('blog/tags/breadcrumb.html')
def load_breadcrumb(article):
"""
获得文章面包屑
:param article:
:return:
"""
names = article.get_category_tree()
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
site = get_current_site().domain
names.append((blogsetting.site_name, '/'))
names = names[::-1]
return {
'names': names,
'title': article.title,
'count': len(names) + 1
}
@register.inclusion_tag('blog/tags/article_tag_list.html')
def load_articletags(article):
"""
文章标签
:param article:
:return:
"""
tags = article.tags.all()
tags_list = []
for tag in tags:
url = tag.get_absolute_url()
count = tag.get_article_count()
tags_list.append((
url, count, tag, random.choice(settings.BOOTSTRAP_COLOR_TYPES)
))
return {
'article_tags_list': tags_list
}
@register.inclusion_tag('blog/tags/sidebar.html')
def load_sidebar(user, linktype):
"""
加载侧边栏
:return:
"""
value = cache.get("sidebar" + linktype)
if value:
value['user'] = user
return value
else:
logger.info('load sidebar')
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
recent_articles = Article.objects.filter(
status='p')[:blogsetting.sidebar_article_count]
sidebar_categorys = Category.objects.all()
extra_sidebars = SideBar.objects.filter(
is_enable=True).order_by('sequence')
most_read_articles = Article.objects.filter(status='p').order_by(
'-views')[:blogsetting.sidebar_article_count]
dates = Article.objects.datetimes('creation_time', 'month', order='DESC')
links = Links.objects.filter(is_enable=True).filter(
Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A))
commment_list = Comment.objects.filter(is_enable=True).order_by(
'-id')[:blogsetting.sidebar_comment_count]
# 标签云 计算字体大小
# 根据总数计算出平均值 大小为 (数目/平均值)*步长
increment = 5
tags = Tag.objects.all()
sidebar_tags = None
if tags and len(tags) > 0:
s = [t for t in [(t, t.get_article_count()) for t in tags] if t[1]]
count = sum([t[1] for t in s])
dd = 1 if (count == 0 or not len(tags)) else count / len(tags)
import random
sidebar_tags = list(
map(lambda x: (x[0], x[1], (x[1] / dd) * increment + 10), s))
random.shuffle(sidebar_tags)
value = {
'recent_articles': recent_articles,
'sidebar_categorys': sidebar_categorys,
'most_read_articles': most_read_articles,
'article_dates': dates,
'sidebar_comments': commment_list,
'sidabar_links': links,
'show_google_adsense': blogsetting.show_google_adsense,
'google_adsense_codes': blogsetting.google_adsense_codes,
'open_site_comment': blogsetting.open_site_comment,
'show_gongan_code': blogsetting.show_gongan_code,
'sidebar_tags': sidebar_tags,
'extra_sidebars': extra_sidebars
}
cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3)
logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype))
value['user'] = user
return value
@register.inclusion_tag('blog/tags/article_meta_info.html')
def load_article_metas(article, user):
"""
获得文章meta信息
:param article:
:return:
"""
return {
'article': article,
'user': user
}
@register.inclusion_tag('blog/tags/article_pagination.html')
def load_pagination_info(page_obj, page_type, tag_name):
previous_url = ''
next_url = ''
if page_type == '':
if page_obj.has_next():
next_number = page_obj.next_page_number()
next_url = reverse('blog:index_page', kwargs={'page': next_number})
if page_obj.has_previous():
previous_number = page_obj.previous_page_number()
previous_url = reverse(
'blog:index_page', kwargs={
'page': previous_number})
if page_type == '分类标签归档':
tag = get_object_or_404(Tag, name=tag_name)
if page_obj.has_next():
next_number = page_obj.next_page_number()
next_url = reverse(
'blog:tag_detail_page',
kwargs={
'page': next_number,
'tag_name': tag.slug})
if page_obj.has_previous():
previous_number = page_obj.previous_page_number()
previous_url = reverse(
'blog:tag_detail_page',
kwargs={
'page': previous_number,
'tag_name': tag.slug})
if page_type == '作者文章归档':
if page_obj.has_next():
next_number = page_obj.next_page_number()
next_url = reverse(
'blog:author_detail_page',
kwargs={
'page': next_number,
'author_name': tag_name})
if page_obj.has_previous():
previous_number = page_obj.previous_page_number()
previous_url = reverse(
'blog:author_detail_page',
kwargs={
'page': previous_number,
'author_name': tag_name})
if page_type == '分类目录归档':
category = get_object_or_404(Category, name=tag_name)
if page_obj.has_next():
next_number = page_obj.next_page_number()
next_url = reverse(
'blog:category_detail_page',
kwargs={
'page': next_number,
'category_name': category.slug})
if page_obj.has_previous():
previous_number = page_obj.previous_page_number()
previous_url = reverse(
'blog:category_detail_page',
kwargs={
'page': previous_number,
'category_name': category.slug})
return {
'previous_url': previous_url,
'next_url': next_url,
'page_obj': page_obj
}
@register.inclusion_tag('blog/tags/article_info.html')
def load_article_detail(article, isindex, user):
"""
加载文章详情
:param article:
:param isindex:是否列表页若是列表页只显示摘要
:return:
"""
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
return {
'article': article,
'isindex': isindex,
'user': user,
'open_site_comment': blogsetting.open_site_comment,
}
# return only the URL of the gravatar
# TEMPLATE USE: {{ email|gravatar_url:150 }}
@register.filter
def gravatar_url(email, size=40):
"""获得gravatar头像"""
cachekey = 'gravatat/' + email
url = cache.get(cachekey)
if url:
return url
else:
usermodels = OAuthUser.objects.filter(email=email)
if usermodels:
o = list(filter(lambda x: x.picture is not None, usermodels))
if o:
return o[0].picture
email = email.encode('utf-8')
default = static('blog/img/avatar.png')
url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5(
email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)}))
cache.set(cachekey, url, 60 * 60 * 10)
logger.info('set gravatar cache.key:{key}'.format(key=cachekey))
return url
@register.filter
def gravatar(email, size=40):
"""获得gravatar头像"""
url = gravatar_url(email, size)
return mark_safe(
'<img src="%s" height="%d" width="%d">' %
(url, size, size))
@register.simple_tag
def query(qs, **kwargs):
""" template tag which allows queryset filtering. Usage:
{% query books author=author as mybooks %}
{% for book in mybooks %}
...
{% endfor %}
"""
return qs.filter(**kwargs)
@register.filter
def addstr(arg1, arg2):
"""concatenate arg1 & arg2"""
return str(arg1) + str(arg2)

@ -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'),
]

@ -0,0 +1,379 @@
import logging
import os
import uuid
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from haystack.views import SearchView
from blog.models import Article, Category, LinkShowType, Links, Tag
from comments.forms import CommentForm
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
from djangoblog.utils import cache, get_blog_setting, get_sha256
logger = logging.getLogger(__name__)
class ArticleListView(ListView):
# template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
# context_object_name属性用于给上下文变量取名在模板中使用该名字
context_object_name = 'article_list'
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
def get_view_cache_key(self):
return self.request.get['pages']
@property
def page_number(self):
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
return page
def get_queryset_cache_key(self):
"""
子类重写.获得queryset的缓存key
"""
raise NotImplementedError()
def get_queryset_data(self):
"""
子类重写.获取queryset的数据
"""
raise NotImplementedError()
def get_queryset_from_cache(self, cache_key):
'''
缓存页面数据
:param cache_key: 缓存key
:return:
'''
value = cache.get(cache_key)
if value:
logger.info('get view cache.key:{key}'.format(key=cache_key))
return value
else:
article_list = self.get_queryset_data()
cache.set(cache_key, article_list)
logger.info('set view cache.key:{key}'.format(key=cache_key))
return article_list
def get_queryset(self):
'''
重写默认从缓存获取数据
:return:
'''
key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key)
return value
def get_context_data(self, **kwargs):
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
class IndexView(ArticleListView):
'''
首页
'''
# 友情链接类型
link_type = LinkShowType.I
def get_queryset_data(self):
article_list = Article.objects.filter(type='a', status='p')
return article_list
def get_queryset_cache_key(self):
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
class ArticleDetailView(DetailView):
'''
文章详情页面
'''
template_name = 'blog/article_detail.html'
model = Article
pk_url_kwarg = 'article_id'
context_object_name = "article"
def get_context_data(self, **kwargs):
comment_form = CommentForm()
article_comments = self.object.comment_list()
parent_comments = article_comments.filter(parent_comment=None)
blog_setting = get_blog_setting()
paginator = Paginator(parent_comments, blog_setting.article_comment_count)
page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
page = 1
else:
page = int(page)
if page < 1:
page = 1
if page > paginator.num_pages:
page = paginator.num_pages
p_comments = paginator.page(page)
next_page = p_comments.next_page_number() if p_comments.has_next() else None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
if next_page:
kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
if prev_page:
kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_comments
kwargs['comment_count'] = len(
article_comments) if article_comments else 0
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# Action Hook, 通知插件"文章详情已获取"
hooks.run_action('after_article_body_get', article=article, request=self.request)
# # Filter Hook, 允许插件修改文章正文
article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article,
request=self.request)
return context
class CategoryDetailView(ArticleListView):
'''
分类目录列表
'''
page_type = "分类目录归档"
def get_queryset_data(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return article_list
def get_queryset_cache_key(self):
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
cache_key = 'category_list_{categoryname}_{page}'.format(
categoryname=categoryname, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
except BaseException:
pass
kwargs['page_type'] = CategoryDetailView.page_type
kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs)
class AuthorDetailView(ArticleListView):
'''
作者详情页
'''
page_type = '作者文章归档'
def get_queryset_cache_key(self):
from uuslug import slugify
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format(
author_name=author_name, page=self.page_number)
return cache_key
def get_queryset_data(self):
author_name = self.kwargs['author_name']
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return article_list
def get_context_data(self, **kwargs):
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name
return super(AuthorDetailView, self).get_context_data(**kwargs)
class TagDetailView(ArticleListView):
'''
标签列表页面
'''
page_type = '分类标签归档'
def get_queryset_data(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
return article_list
def get_queryset_cache_key(self):
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
cache_key = 'tag_{tag_name}_{page}'.format(
tag_name=tag_name, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
# tag_name = self.kwargs['tag_name']
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
class ArchivesView(ArticleListView):
'''
文章归档页面
'''
page_type = '文章归档'
paginate_by = None
page_kwarg = None
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
return Article.objects.filter(status='p').all()
def get_queryset_cache_key(self):
cache_key = 'archives'
return cache_key
class LinkListView(ListView):
model = Links
template_name = 'blog/links_list.html'
def get_queryset(self):
return Links.objects.filter(is_enable=True)
class EsSearchView(SearchView):
def get_context(self):
paginator, page = self.build_page()
context = {
"query": self.query,
"form": self.form,
"page": page,
"paginator": paginator,
"suggestion": None,
}
if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context())
return context
@csrf_exempt
def fileupload(request):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request:
:return:
"""
if request.method == 'POST':
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
response = []
for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
if not savepath.startswith(base_dir):
return HttpResponse("only for post")
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
if isimage:
from PIL import Image
image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True)
url = static(savepath)
response.append(url)
return HttpResponse(response)
else:
return HttpResponse("only for post")
def page_not_found_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
url = request.get_full_path()
return render(request,
template_name,
{'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
'statuscode': '404'},
status=404)
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'),
'statuscode': '500'},
status=500)
def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception)
return render(
request, template_name, {
'message': _('Sorry, you do not have permission to access this page?'),
'statuscode': '403'}, status=403)
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')

@ -0,0 +1,66 @@
from django.contrib import admin # 导入Django管理后台模块
from django.urls import reverse # 导入reverse函数用于生成URL
from django.utils.html import format_html # 导入HTML格式化函数用于生成HTML标签
from django.utils.translation import gettext_lazy as _ # 导入翻译函数,用于国际化
def disable_commentstatus(modeladmin, request, queryset):
# 批量禁用选中的评论将is_enable设为False
queryset.update(is_enable=False)
def enable_commentstatus(modeladmin, request, queryset):
# 批量启用选中的评论将is_enable设为True
queryset.update(is_enable=True)
# 为批量操作设置显示名称(支持国际化)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
class CommentAdmin(admin.ModelAdmin):
# 管理后台列表页每页显示20条记录
list_per_page = 20
# 列表页显示的字段
list_display = (
'id', # 评论ID
'body', # 评论内容
'link_to_userinfo', # 评论作者(带链接)
'link_to_article', # 关联文章(带链接)
'is_enable', # 是否启用
'creation_time' # 创建时间
)
# 列表页中可点击跳转详情页的字段
list_display_links = ('id', 'body', 'is_enable')
# 右侧筛选器,按是否启用筛选
list_filter = ('is_enable',)
# 编辑页排除的字段(不允许手动编辑)
exclude = ('creation_time', 'last_modify_time')
# 注册批量操作函数
actions = [disable_commentstatus, enable_commentstatus]
def link_to_userinfo(self, obj):
# 生成评论作者的管理后台编辑链接
# 获取用户模型的app标签和模型名称
info = (obj.author._meta.app_label, obj.author._meta.model_name)
# 生成用户编辑页的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# 返回带链接的HTML显示昵称或邮箱
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
def link_to_article(self, obj):
# 生成关联文章的管理后台编辑链接
# 获取文章模型的app标签和模型名称
info = (obj.article._meta.app_label, obj.article._meta.model_name)
# 生成文章编辑页的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
# 返回带链接的HTML显示文章标题
return format_html(
u'<a href="%s">%s</a>' % (link, obj.article.title))
# 设置自定义字段在列表页的显示名称(支持国际化)
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')

@ -0,0 +1,4 @@
from django.apps import AppConfig # 导入Django的应用配置基类
class CommentsConfig(AppConfig): # 定义评论应用的配置类继承自AppConfig
name = 'comments' # 指定应用的名称为'comments'Django通过此名称识别该应用

@ -0,0 +1,15 @@
from django import forms # 导入Django表单基础模块
from django.forms import ModelForm # 导入模型表单类,用于基于模型创建表单
from .models import Comment # 从当前应用导入Comment模型
class CommentForm(ModelForm): # 定义评论表单类继承自ModelForm
# 定义父评论ID字段用于处理评论回复功能
# 使用HiddenInput小部件前端隐藏非必填顶级评论不需要父评论ID
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False)
class Meta: # 元数据配置
model = Comment # 指定表单关联的模型为Comment
fields = ['body'] # 表单包含的字段仅包含评论内容字段body

@ -0,0 +1,37 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
from django.conf import settings # 导入Django项目配置用于获取用户模型等设置
from django.db import migrations, models # 导入迁移和模型模块,用于定义数据库迁移操作
import django.db.models.deletion # 导入外键删除行为处理模块,定义外键删除策略
import django.utils.timezone # 导入时区工具,处理时间字段默认值
class Migration(migrations.Migration): # 定义迁移类,包含数据库迁移操作
initial = True # 标记为初始迁移(该模型的首次迁移)
dependencies = [ # 迁移依赖:执行当前迁移前需完成的迁移
('blog', '0001_initial'), # 依赖blog应用的0001_initial迁移确保Article模型存在
migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型的可交换迁移(支持自定义用户模型)
]
operations = [ # 迁移操作列表:当前迁移需执行的数据库操作
migrations.CreateModel( # 创建Comment模型对应数据库表
name='Comment', # 模型名称为Comment评论模型
fields=[ # 模型字段定义
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # 自增主键,自动创建,作为表的唯一标识
('body', models.TextField(max_length=300, verbose_name='正文')), # 评论正文字段文本类型最大300字符
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # 创建时间字段,默认值为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # 最后修改时间字段,默认值为当前时间
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), # 评论显示开关默认显示True
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')), # 外键关联Article级联删除文章删则评论删
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), # 外键关联用户模型,级联删除(用户删则评论删)
('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')), # 自关联外键(支持评论回复),允许为空
],
options={ # 模型元数据配置
'verbose_name': '评论', # 模型单数显示名称
'verbose_name_plural': '评论', # 模型复数显示名称
'ordering': ['-id'], # 默认排序按id降序最新评论在前
'get_latest_by': 'id', # 获取最新记录时依据id字段
},
),
]

@ -0,0 +1,17 @@
# Generated by Django 4.1.7 on 2023-04-24 13:48
from django.db import migrations, models # 导入Django迁移和模型模块用于数据库结构变更
class Migration(migrations.Migration): # 定义迁移类,包含数据库变更操作
dependencies = [ # 迁移依赖需先执行comments应用的0001_initial迁移
('comments', '0001_initial'),
]
operations = [ # 迁移操作列表:当前需要执行的数据库变更
migrations.AlterField( # 修改已有字段
model_name='comment', # 要修改的模型名称为Comment
name='is_enable', # 要修改的字段名称为is_enable
field=models.BooleanField(default=False, verbose_name='是否显示'), # 将字段默认值从True改为False评论默认不显示
),
]

@ -0,0 +1,59 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings # 导入Django项目配置用于获取用户模型设置
from django.db import migrations, models # 导入迁移和模型模块,用于数据库结构变更
import django.db.models.deletion # 导入外键删除行为处理模块
import django.utils.timezone # 导入时区工具,处理时间字段默认值
class Migration(migrations.Migration): # 定义迁移类,包含数据库变更操作
dependencies = [ # 迁移依赖:执行当前迁移前需完成的迁移
migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型的可交换迁移
('blog', '0005_alter_article_options_alter_category_options_and_more'), # 依赖blog应用的指定迁移
('comments', '0002_alter_comment_is_enable'), # 依赖comments应用的0002迁移
]
operations = [ # 迁移操作列表:当前需要执行的数据库变更
migrations.AlterModelOptions( # 修改模型的元数据配置
name='comment', # 目标模型为Comment
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'}, # 将显示名称改为英文
),
migrations.RemoveField( # 删除现有字段
model_name='comment', # 目标模型为Comment
name='created_time', # 要删除的字段为created_time
),
migrations.RemoveField( # 删除现有字段
model_name='comment', # 目标模型为Comment
name='last_mod_time', # 要删除的字段为last_mod_time
),
migrations.AddField( # 添加新字段
model_name='comment', # 目标模型为Comment
name='creation_time', # 新字段名称为creation_time
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), # 时间字段,默认当前时间,显示名称为英文
),
migrations.AddField( # 添加新字段
model_name='comment', # 目标模型为Comment
name='last_modify_time', # 新字段名称为last_modify_time
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), # 时间字段,默认当前时间,显示名称为英文
),
migrations.AlterField( # 修改现有字段
model_name='comment', # 目标模型为Comment
name='article', # 目标字段为article
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'), # 将显示名称改为英文
),
migrations.AlterField( # 修改现有字段
model_name='comment', # 目标模型为Comment
name='author', # 目标字段为author
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), # 将显示名称改为英文
),
migrations.AlterField( # 修改现有字段
model_name='comment', # 目标模型为Comment
name='is_enable', # 目标字段为is_enable
field=models.BooleanField(default=False, verbose_name='enable'), # 将显示名称改为英文"enable"
),
migrations.AlterField( # 修改现有字段
model_name='comment', # 目标模型为Comment
name='parent_comment', # 目标字段为parent_comment
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'), # 将显示名称改为英文
),
]

@ -0,0 +1,47 @@
from django.conf import settings # 导入Django项目设置用于获取用户模型
from django.db import models # 导入Django模型模块用于定义数据模型
from django.utils.timezone import now # 导入当前时间工具,用于时间字段默认值
from django.utils.translation import gettext_lazy as _ # 导入翻译函数,支持国际化
from blog.models import Article # 从blog应用导入Article模型用于关联评论和文章
# Create your models here.
class Comment(models.Model):
# 评论内容字段文本类型最大长度300字符显示名称为"正文"
body = models.TextField('正文', max_length=300)
# 评论创建时间字段,使用国际化显示名称,默认值为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 评论最后修改时间字段,使用国际化显示名称,默认值为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 外键关联到用户模型,使用国际化显示名称,级联删除(用户删除则评论删除)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
# 外键关联到文章模型,使用国际化显示名称,级联删除(文章删除则评论删除)
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
# 自关联外键,用于实现评论回复功能,允许为空,级联删除
parent_comment = models.ForeignKey(
'self',
verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
# 评论是否启用的开关,布尔类型,默认不启用,不允许为空
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
class Meta:
ordering = ['-id'] # 默认排序方式按ID降序最新评论在前
verbose_name = _('comment') # 模型单数显示名称(国际化)
verbose_name_plural = verbose_name # 模型复数显示名称(与单数相同)
get_latest_by = 'id' # 获取最新记录时依据ID字段
def __str__(self):
# 模型实例的字符串表示,返回评论内容
return self.body

@ -0,0 +1,33 @@
from django import template # 导入Django模板模块用于创建自定义模板标签
register = template.Library() # 创建模板标签注册器,用于注册自定义标签
@register.simple_tag # 将函数注册为简单模板标签
def parse_commenttree(commentlist, comment):
"""获得当前评论子评论的列表
用法: {% parse_commenttree article_comments comment as childcomments %}
"""
datas = [] # 用于存储子评论的列表
def parse(c): # 定义递归函数,用于递归获取所有子评论
# 筛选出当前评论的直接子评论(已启用状态)
childs = commentlist.filter(parent_comment=c, is_enable=True)
for child in childs: # 遍历直接子评论
datas.append(child) # 将子评论添加到列表
parse(child) # 递归处理子评论的子评论(嵌套评论)
parse(comment) # 从当前评论开始递归获取所有子评论
return datas # 返回所有子评论列表
@register.inclusion_tag('comments/tags/comment_item.html') # 将函数注册为包含标签,指定模板文件
def show_comment_item(comment, ischild):
"""评论展示标签"""
# 根据是否为子评论设置深度(用于前端样式区分,如缩进)
depth = 1 if ischild else 2
# 返回上下文数据供模板comment_item.html使用
return {
'comment_item': comment, # 当前评论对象
'depth': depth # 评论深度(用于样式控制)
}

@ -0,0 +1,61 @@
from django.test import Client, RequestFactory, TransactionTestCase # 导入Django测试相关类
from django.urls import reverse # 导入reverse函数用于生成URL
from accounts.models import BlogUser # 从accounts应用导入BlogUser模型用户模型
from blog.models import Category, Article # 从blog应用导入分类和文章模型
from comments.models import Comment # 导入评论模型
from comments.templatetags.comment_tags import * # 导入评论相关的模板标签
from djangoblog.utils import get_max_articleid_commentid # 导入工具函数
# Create your tests here.
class CommentsTest(TransactionTestCase): # 定义评论测试类,继承事务测试类(支持数据库事务回滚)
def setUp(self): # 测试前的初始化方法,每个测试方法执行前都会调用
self.client = Client() # 创建测试客户端,用于模拟用户请求
self.factory = RequestFactory() # 创建请求工厂,用于构造请求对象
# 配置博客评论设置
from blog.models import BlogSettings
value = BlogSettings()
value.comment_need_review = True # 设置评论需要审核
value.save()
# 创建超级用户(测试用)
self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
def update_article_comment_status(self, article): # 辅助方法:更新文章所有评论为启用状态
comments = article.comment_set.all() # 获取文章的所有评论
for comment in comments: # 遍历评论
comment.is_enable = True # 设置为启用
comment.save() # 保存更改
def test_validate_comment(self): # 测试评论验证功能
# 用户登录
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 创建测试分类
category = Category()
category.name = "categoryccc"
category.save()
# 创建测试文章
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = self.user # 设置作者为测试用户
article.category = category # 设置分类
article.type = 'a' # 文章类型(假设'a'表示普通文章)
article.status = 'p' # 发布状态(假设'p'表示已发布)
article.save()
# 生成评论提交的URL
comment_url = reverse(
'comments:postcomment', kwargs={
'article_id': article.id}) # 传入文章ID参数
# 发送评论提交请求代码不完整后续应补充POST数据和断言
response = self.client.post(comment_url,

@ -0,0 +1,12 @@
from django.urls import path # 导入Django的路径函数用于定义URL路由
from . import views # 从当前应用导入视图模块
app_name = "comments" # 定义应用的命名空间用于模板中URL反向解析
urlpatterns = [ # URL模式列表定义URL与视图的映射关系
path(
'article/<int:article_id>/postcomment', # URL路径包含文章ID参数整数类型
views.CommentPostView.as_view(), # 关联的视图类使用as_view()方法转换为可调用视图
name='postcomment' # 该URL的名称用于反向解析
),
]

@ -0,0 +1,62 @@
import logging # 导入日志模块,用于记录程序运行中的日志信息
from django.utils.translation import gettext_lazy as _ # 导入翻译函数,支持国际化文本
from djangoblog.utils import get_current_site # 从自定义工具模块导入获取当前站点域名的函数
from djangoblog.utils import send_email # 从自定义工具模块导入发送邮件的函数
logger = logging.getLogger(__name__) # 创建当前模块的日志记录器,用于记录该模块的日志
def send_comment_email(comment):
"""
发送评论相关邮件
1. 向评论作者发送评论成功的感谢邮件
2. 若当前评论是回复有父评论向父评论作者发送回复通知邮件
"""
# 获取当前网站的域名(用于拼接文章链接)
site = get_current_site().domain
# 邮件主题:评论感谢(支持国际化)
subject = _('Thanks for your comment')
# 拼接评论对应的文章访问链接HTTPS协议
article_url = f"https://{site}{comment.article.get_absolute_url()}"
# 构建给评论作者的HTML格式邮件内容支持国际化通过占位符注入动态数据
html_content = _("""<p>Thank you very much for your comments on this site</p>
You can visit <a href="%(article_url)s" rel="bookmark">%(article_title)s</a>
to review your comments,
Thank you again!
<br />
If the link above cannot be opened, please copy this link to your browser.
%(article_url)s""") % {
'article_url': article_url, # 文章访问链接
'article_title': comment.article.title # 文章标题
}
# 评论作者的邮箱(收件人)
tomail = comment.author.email
# 调用发送邮件函数,向评论作者发送感谢邮件
send_email([tomail], subject, html_content)
try:
# 判断当前评论是否有父评论(即是否是回复评论)
if comment.parent_comment:
# 构建给父评论作者的HTML格式邮件内容回复通知支持国际化
html_content = _("""Your comment on <a href="%(article_url)s" rel="bookmark">%(article_title)s</a><br/> has
received a reply. <br/> %(comment_body)s
<br/>
go check it out!
<br/>
If the link above cannot be opened, please copy this link to your browser.
%(article_url)s
""") % {
'article_url': article_url, # 文章访问链接
'article_title': comment.article.title, # 文章标题
'comment_body': comment.parent_comment.body # 父评论的内容(供作者识别)
}
# 父评论作者的邮箱(收件人)
tomail = comment.parent_comment.author.email
# 调用发送邮件函数,向父评论作者发送回复通知邮件
send_email([tomail], subject, html_content)
# 捕获发送回复邮件过程中的异常(避免单个邮件发送失败影响整体流程)
except Exception as e:
# 记录异常日志(便于问题排查)
logger.error(e)

@ -0,0 +1,76 @@
# Create your views here.
from django.core.exceptions import ValidationError # 导入验证异常类,用于处理验证错误
from django.http import HttpResponseRedirect # 导入HTTP重定向类用于页面跳转
from django.shortcuts import get_object_or_404 # 导入获取对象或返回404的工具函数
from django.utils.decorators import method_decorator # 导入方法装饰器工具,用于为类视图方法添加装饰器
from django.views.decorators.csrf import csrf_protect # 导入CSRF保护装饰器防止跨站请求伪造
from django.views.generic.edit import FormView # 导入表单视图基类,用于处理表单提交逻辑
from accounts.models import BlogUser # 从accounts应用导入用户模型
from blog.models import Article # 从blog应用导入文章模型
from .forms import CommentForm # 从当前应用导入评论表单
from .models import Comment # 从当前应用导入评论模型
class CommentPostView(FormView):
"""评论提交视图类,处理评论发布功能"""
form_class = CommentForm # 指定使用的表单类为CommentForm
template_name = 'blog/article_detail.html' # 指定表单验证失败时渲染的模板
@method_decorator(csrf_protect) # 为dispatch方法添加CSRF保护
def dispatch(self, *args, **kwargs):
# 调用父类的dispatch方法处理请求分发
return super(CommentPostView, self).dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs):
"""处理GET请求重定向到文章详情页的评论区"""
article_id = self.kwargs['article_id'] # 从URL参数中获取文章ID
article = get_object_or_404(Article, pk=article_id) # 获取对应的文章对象不存在则返回404
url = article.get_absolute_url() # 获取文章的绝对URL
return HttpResponseRedirect(url + "#comments") # 重定向到文章详情页的评论区锚点
def form_invalid(self, form):
"""处理表单验证失败的情况"""
article_id = self.kwargs['article_id'] # 获取文章ID
article = get_object_or_404(Article, pk=article_id) # 获取文章对象
# 渲染文章详情页,传递错误的表单和文章对象(用于显示错误信息)
return self.render_to_response({
'form': form, # 验证失败的表单(包含错误信息)
'article': article # 文章对象
})
def form_valid(self, form):
"""处理表单验证通过后的逻辑:保存评论并跳转"""
user = self.request.user # 获取当前登录用户
author = BlogUser.objects.get(pk=user.pk) # 获取用户对应的BlogUser对象
article_id = self.kwargs['article_id'] # 获取文章ID
article = get_object_or_404(Article, pk=article_id) # 获取文章对象
# 检查文章是否允许评论(评论状态为关闭或文章状态为草稿则不允许评论)
if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.") # 抛出验证异常
comment = form.save(False) # 不立即保存表单数据,返回评论对象
comment.article = article # 设置评论关联的文章
# 获取博客设置,判断评论是否需要审核
from djangoblog.utils import get_blog_setting
settings = get_blog_setting()
if not settings.comment_need_review: # 如果不需要审核
comment.is_enable = True # 直接设置评论为启用状态
comment.author = author # 设置评论的作者
# 处理回复功能如果存在父评论ID则设置父评论
if form.cleaned_data['parent_comment_id']:
parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id']) # 获取父评论对象
comment.parent_comment = parent_comment # 设置当前评论的父评论
comment.save(True) # 保存评论到数据库
# 重定向到文章详情页的当前评论位置(带锚点)
return HttpResponseRedirect(
"%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk)) # 拼接URL包含评论ID锚点

@ -0,0 +1,52 @@
# Docker Compose配置文件版本为3指定兼容的Compose语法版本
version: '3'
# 定义所有服务(容器)
services:
# 1. Elasticsearch服务用于全文搜索功能集成IK中文分词器
es:
image: liangliangyy/elasticsearch-analysis-ik:8.6.1 # 使用带IK分词器的ES镜像版本8.6.1
container_name: es # 容器名称固定为"es",便于管理
restart: always # 容器退出后自动重启(确保服务持续运行)
environment: # 环境变量配置
- discovery.type=single-node # 单节点模式(无需集群,适合测试/小型部署)
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" # 设置JVM内存大小初始/最大均为512M避免内存溢出
ports: # 端口映射主机9200端口 → 容器9200端口ES默认API端口
- 9200:9200
volumes: # 数据卷挂载持久化ES数据
- ./bin/datas/es/:/usr/share/elasticsearch/data/ # 主机目录 → 容器内ES数据存储目录
# 2. Kibana服务ES的可视化管理工具用于操作/监控ES
kibana:
image: kibana:8.6.1 # Kibana镜像版本需与ES一致8.6.1
restart: always # 容器退出后自动重启
container_name: kibana # 容器名称固定为"kibana"
ports: # 端口映射主机5601端口 → 容器5601端口Kibana默认Web端口
- 5601:5601
environment: # 环境变量配置指定关联的ES地址
- ELASTICSEARCH_HOSTS=http://es:9200 # 指向同网络内的"es"服务(容器间通过服务名通信)
# 3. Django博客服务核心应用服务
djangoblog:
build: . # 基于当前目录的Dockerfile构建镜像不使用现成镜像需本地有Dockerfile
restart: always # 容器退出后自动重启
command: bash -c 'sh /code/djangoblog/bin/docker_start.sh' # 容器启动后执行的命令:运行启动脚本
ports: # 端口映射主机8000端口 → 容器8000端口Django默认开发服务器端口
- "8000:8000"
volumes: # 数据卷挂载:持久化应用数据/静态资源
- ./collectedstatic:/code/djangoblog/collectedstatic # 主机静态资源目录 → 容器内静态资源目录Nginx可直接访问
- ./uploads:/code/djangoblog/uploads # 主机上传文件目录 → 容器内上传文件目录(如博客图片)
environment: # 环境变量配置Django应用的关键参数数据库、缓存、ES等
- DJANGO_MYSQL_DATABASE=djangoblog # Django连接的MySQL数据库名
- DJANGO_MYSQL_USER=root # MySQL用户名
- DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E # MySQL密码
- DJANGO_MYSQL_HOST=db # MySQL服务地址指向同网络内的"db"服务需额外配置db服务
- DJANGO_MYSQL_PORT=3306 # MySQL端口
- DJANGO_MEMCACHED_LOCATION=memcached:11211 # Memcached缓存地址指向同网络内的"memcached"服务,需额外配置)
- DJANGO_ELASTICSEARCH_HOST=es:9200 # ES服务地址指向同网络内的"es"服务)
links: # 显式链接到其他服务已逐步被depends_on替代此处用于兼容
- db # 链接到MySQL服务
- memcached # 链接到Memcached服务
depends_on: # 服务依赖启动djangoblog前先启动db服务确保数据库就绪
- db
container_name: djangoblog # 容器名称固定为"djangoblog"

@ -0,0 +1,67 @@
# Docker Compose配置文件版本为3指定Compose语法版本
version: '3'
# 定义所有服务(容器)
services:
# 1. MySQL数据库服务存储应用数据
db:
image: mysql:latest # 使用最新版MySQL镜像
restart: always # 容器退出后自动重启(确保服务持续运行)
environment: # 环境变量配置(数据库初始化参数)
- MYSQL_DATABASE=djangoblog # 自动创建的数据库名称
- MYSQL_ROOT_PASSWORD=DjAnGoBlOg!2!Q@W#E # MySQL root用户密码
ports: # 端口映射主机3306端口 → 容器3306端口MySQL默认端口
- 3306:3306
volumes: # 数据卷挂载持久化MySQL数据
- ./bin/datas/mysql/:/var/lib/mysql # 主机目录 → 容器内MySQL数据存储目录
depends_on: # 服务依赖启动db前先启动redis可能用于数据库缓存等场景
- redis
container_name: db # 容器名称固定为"db"
# 2. Django博客应用服务核心应用
djangoblog:
build: # 构建配置
context: ../../ # 指定Dockerfile所在的上下文目录上级目录的上级目录
restart: always # 容器退出后自动重启
command: bash -c 'sh /code/djangoblog/bin/docker_start.sh' # 启动命令:执行应用启动脚本
ports: # 端口映射主机8000端口 → 容器8000端口Django应用端口
- "8000:8000"
volumes: # 数据卷挂载:持久化应用数据和配置
- ./collectedstatic:/code/djangoblog/collectedstatic # 静态资源目录供Nginx访问
- ./logs:/code/djangoblog/logs # 应用日志目录
- ./uploads:/code/djangoblog/uploads # 用户上传文件目录(如图片)
environment: # 环境变量配置(应用连接参数)
- DJANGO_MYSQL_DATABASE=djangoblog # 数据库名称与db服务对应
- DJANGO_MYSQL_USER=root # 数据库用户名
- DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E # 数据库密码与db服务对应
- DJANGO_MYSQL_HOST=db # 数据库服务地址(指向同网络内的"db"服务)
- DJANGO_MYSQL_PORT=3306 # 数据库端口
- DJANGO_REDIS_URL=redis:6379 # Redis服务地址指向同网络内的"redis"服务)
links: # 显式链接到其他服务(用于容器间通信)
- db # 链接到MySQL服务
- redis # 链接到Redis服务
depends_on: # 服务依赖启动djangoblog前先启动db服务确保数据库就绪
- db
container_name: djangoblog # 容器名称固定为"djangoblog"
# 3. Nginx服务反向代理和静态资源服务
nginx:
restart: always # 容器退出后自动重启
image: nginx:latest # 使用最新版Nginx镜像
ports: # 端口映射HTTP(80)和HTTPS(443)端口
- "80:80"
- "443:443"
volumes: # 数据卷挂载Nginx配置和静态资源
- ./bin/nginx.conf:/etc/nginx/nginx.conf # 主机Nginx配置文件 → 容器内Nginx配置文件
- ./collectedstatic:/code/djangoblog/collectedstatic # 静态资源目录与djangoblog服务共享
links: # 链接到djangoblog服务实现反向代理
- djangoblog:djangoblog # 将djangoblog服务映射为"djangoblog"主机名
container_name: nginx # 容器名称固定为"nginx"
# 4. Redis服务缓存服务用于提升应用性能
redis:
restart: always # 容器退出后自动重启
image: redis:latest # 使用最新版Redis镜像
container_name: redis # 容器名称固定为"redis"
ports: # 端口映射主机6379端口 → 容器6379端口Redis默认端口
- "6379:6379"

@ -0,0 +1,31 @@
#!/usr/bin/env bash
NAME="djangoblog"
DJANGODIR=/code/djangoblog
USER=root
GROUP=root
NUM_WORKERS=1
DJANGO_WSGI_MODULE=djangoblog.wsgi
echo "Starting $NAME as `whoami`"
cd $DJANGODIR
export PYTHONPATH=$DJANGODIR:$PYTHONPATH
python manage.py makemigrations && \
python manage.py migrate && \
python manage.py collectstatic --noinput && \
python manage.py compress --force && \
python manage.py build_index && \
python manage.py compilemessages || exit 1
exec gunicorn ${DJANGO_WSGI_MODULE}:application \
--name $NAME \
--workers $NUM_WORKERS \
--user=$USER --group=$GROUP \
--bind 0.0.0.0:8000 \
--log-level=debug \
--log-file=- \
--worker-class gevent \
--threads 4

@ -0,0 +1,124 @@
apiVersion: v1 # Kubernetes API版本v1为稳定版本
kind: ConfigMap # 资源类型为ConfigMap用于存储非敏感配置数据
metadata:
name: web-nginx-config # ConfigMap名称标识该Nginx配置资源
namespace: djangoblog # 所属命名空间用于资源隔离对应djangoblog应用
data: # 配置数据,键为文件名,值为文件内容
nginx.conf: | # Nginx主配置文件
user nginx; # Nginx进程运行的用户
worker_processes auto; # 工作进程数auto表示按CPU核心数自动分配
error_log /var/log/nginx/error.log notice; # 错误日志路径及级别notice级别
pid /var/run/nginx.pid; # Nginx进程PID文件路径
events { # 事件处理配置块
worker_connections 1024; # 每个工作进程最大连接数
multi_accept on; # 允许工作进程同时接受多个新连接
use epoll; # 使用epoll I/O模型Linux下高效事件驱动模型
}
http { # HTTP核心配置块
include /etc/nginx/mime.types; # 引入MIME类型映射文件识别文件类型
default_type application/octet-stream; # 默认MIME类型未知类型时使用
# 定义日志格式命名为main
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main; # 访问日志路径使用main格式
sendfile on; # 启用sendfile系统调用高效传输文件
keepalive_timeout 65; # 长连接超时时间65秒
gzip on; # 启用gzip压缩减少传输数据量
gzip_disable "msie6"; # 对IE6浏览器禁用gzip兼容性处理
# gzip压缩补充配置
gzip_vary on; # 启用Vary: Accept-Encoding响应头告知代理缓存压缩/非压缩版本)
gzip_proxied any; # 对所有代理请求启用压缩
gzip_comp_level 8; # 压缩级别1-98为较高压缩率
gzip_buffers 16 8k; # 压缩缓冲区大小16个8k缓冲区
gzip_http_version 1.1; # 仅对HTTP/1.1及以上版本启用压缩
# 需压缩的文件类型文本、JS、CSS、图片等
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
include /etc/nginx/conf.d/*.conf; # 引入其他服务器配置文件
}
djangoblog.conf: | # lylinux.net域名的Nginx站点配置
server { # 处理lylinux.net域名的服务配置
server_name lylinux.net; # 绑定的主域名
root /code/djangoblog/collectedstatic/; # 网站根目录(静态文件目录)
listen 80; # 监听80端口HTTP
keepalive_timeout 70; # 该站点长连接超时时间
location /static/ { # 处理静态文件请求
expires max; # 静态文件缓存有效期设为最大(长期缓存)
alias /code/djangoblog/collectedstatic/; # 静态文件实际路径
}
# 处理特定静态文件如robots.txt、网站验证文件等
location ~* (robots\.txt|ads\.txt|favicon\.ico|favion\.ico|crossdomain\.xml|google93fd32dbd906620a\.html|BingSiteAuth\.xml|baidu_verify_Ijeny6KrmS\.html)$ {
root /resource/djangopub; # 这些文件的根目录
expires 1d; # 缓存1天
access_log off; # 关闭访问日志
error_log off; # 关闭错误日志
}
location / { # 处理其他所有请求反向代理到Django
# 设置代理请求头(传递客户端信息给后端)
proxy_set_header X-Real-IP $remote_addr; # 客户端真实IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 代理链IP列表
proxy_set_header Host $http_host; # 原始请求Host
proxy_set_header X-NginX-Proxy true; # 标识经Nginx代理
proxy_redirect off; # 禁用代理重定向
# 若请求文件不存在反向代理到Django服务djangoblog为K8s内部服务名
if (!-f $request_filename) {
proxy_pass http://djangoblog:8000;
break;
}
}
}
server { # 处理www.lylinux.net域名重定向配置
server_name www.lylinux.net; # 绑定的www子域名
listen 80; # 监听80端口
return 301 https://lylinux.net$request_uri; # 永久重定向到主域名HTTPS地址
}
resource.lylinux.net.conf: | # resource.lylinux.net子域名的配置资源服务器
server {
index index.html index.htm; # 默认索引文件
server_name resource.lylinux.net; # 绑定的资源子域名
root /resource/; # 资源文件根目录
location /djangoblog/ { # 映射Django静态文件路径
alias /code/djangoblog/collectedstatic/; # 实际静态文件路径
}
access_log off; # 关闭访问日志
error_log off; # 关闭错误日志
include lylinux/resource.conf; # 引入通用资源配置
}
lylinux.resource.conf: | # 通用资源配置(被资源服务器引用)
expires max; # 资源缓存有效期设为最大
access_log off; # 关闭访问日志
log_not_found off; # 关闭文件未找到的错误日志
add_header Pragma public; # 缓存控制头(告知客户端可缓存)
add_header Cache-Control "public"; # 缓存控制头(公开可缓存)
add_header "Access-Control-Allow-Origin" "*"; # 允许跨域访问(所有域名)
---
apiVersion: v1 # Kubernetes API版本
kind: ConfigMap # 资源类型为ConfigMap存储环境变量
metadata:
name: djangoblog-env # ConfigMap名称标识Django环境变量配置
namespace: djangoblog # 所属命名空间(与应用一致)
data: # 环境变量键值对
DJANGO_MYSQL_DATABASE: djangoblog # Django连接的MySQL数据库名
DJANGO_MYSQL_USER: db_user # MySQL登录用户名
DJANGO_MYSQL_PASSWORD: db_password # MySQL登录密码
DJANGO_MYSQL_HOST: db_host # MySQL服务地址K8s内部服务名或IP
DJANGO_MYSQL_PORT: db_port # MySQL服务端口
DJANGO_REDIS_URL: "redis:6379" # Redis服务地址及端口
DJANGO_DEBUG: "False" # Django调试模式生产环境关闭
MYSQL_ROOT_PASSWORD: db_password # MySQL root用户密码用于初始化
MYSQL_DATABASE: djangoblog # 初始化的MySQL数据库名
MYSQL_PASSWORD: db_password # MySQL普通用户密码与Django配置一致
DJANGO_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Django加密密钥用于会话、CSRF等

@ -0,0 +1,326 @@
# 第一部分Django 博客应用部署配置
# apiVersion 指定 Kubernetes API 版本apps/v1 是 Deployment 资源的稳定版本
apiVersion: apps/v1
# kind 定义资源类型为 Deployment用于管理Pod的创建和扩展
kind: Deployment
metadata:
# Deployment 的名称
name: djangoblog
# 部署所在的命名空间(用于资源隔离)
namespace: djangoblog
# 为 Deployment 添加标签(用于筛选和关联资源)
labels:
app: djangoblog
spec:
# 副本数:指定运行的 Pod 数量为 3 个(实现高可用)
replicas: 3
# 选择器:用于匹配要管理的 Pod 标签(必须与下面 template.metadata.labels 一致)
selector:
matchLabels:
app: djangoblog
# Pod 模板:定义要创建的 Pod 的规格
template:
metadata:
# Pod 的标签(与上面的 selector.matchLabels 对应)
labels:
app: djangoblog
spec:
# 容器列表:一个 Pod 可以包含多个容器,这里定义应用容器
containers:
- name: djangoblog # 容器名称
# 容器使用的镜像Django 博客应用镜像)
image: liangliangyy/djangoblog:latest
# 镜像拉取策略Always 表示每次都从仓库拉取最新镜像
imagePullPolicy: Always
# 容器暴露的端口Django 应用默认运行在 8000 端口)
ports:
- containerPort: 8000
# 从配置映射ConfigMap中注入环境变量
envFrom:
- configMapRef:
name: djangoblog-env # 引用的 ConfigMap 名称
# 就绪探针:判断容器是否已准备好接收请求(服务发现会依赖此状态)
readinessProbe:
httpGet: # 通过 HTTP 请求检查就绪状态
path: / # 检查的路径(应用根目录)
port: 8000 # 检查的端口
initialDelaySeconds: 10 # 容器启动后延迟 10 秒开始首次检查
periodSeconds: 30 # 每隔 30 秒检查一次
# 存活探针:判断容器是否存活,若失败会重启容器
livenessProbe:
httpGet: # 通过 HTTP 请求检查存活状态
path: / # 检查的路径
port: 8000 # 检查的端口
initialDelaySeconds: 10 # 容器启动后延迟 10 秒开始首次检查
periodSeconds: 30 # 每隔 30 秒检查一次
# 资源限制:控制容器对 CPU 和内存的使用
resources:
requests: # 资源请求(调度时的最小需求)
cpu: 10m # 10 毫核 CPU1核=1000m
memory: 100Mi # 100 兆内存
limits: # 资源限制(容器最大可使用的资源)
cpu: "2" # 2 核 CPU
memory: 2Gi # 2 吉内存
# 卷挂载:将持久卷挂载到容器内的指定路径
volumeMounts:
- name: djangoblog # 引用下面 volumes 中定义的卷名称
mountPath: /code/djangoblog/collectedstatic # 容器内的挂载路径Django 静态文件目录)
- name: resource # 引用资源卷
mountPath: /resource # 容器内的资源文件目录
# 卷定义:声明需要挂载的持久卷
volumes:
- name: djangoblog # 卷名称(与上面 volumeMounts.name 对应)
persistentVolumeClaim: # 使用持久卷声明PVC
claimName: djangoblog-pvc # 引用的 PVC 名称(需提前创建)
- name: resource # 资源卷名称
persistentVolumeClaim:
claimName: resource-pvc # 资源对应的 PVC 名称
# 第二部分Redis 缓存服务部署配置
--- # 分隔符:用于在一个文件中定义多个 Kubernetes 资源
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis # Redis 部署名称
namespace: djangoblog # 同属 djangoblog 命名空间
labels:
app: redis # Redis 标签
spec:
replicas: 1 # Redis 单副本(简单部署,生产环境可能需要集群)
selector:
matchLabels:
app: redis # 匹配 Redis Pod 标签
template:
metadata:
labels:
app: redis # Pod 标签
spec:
containers:
- name: redis # 容器名称
image: redis:latest # Redis 官方最新镜像
# 镜像拉取策略IfNotPresent 表示本地有则使用本地镜像,否则拉取
imagePullPolicy: IfNotPresent
ports:
- containerPort: 6379 # Redis 默认端口
# 资源限制Redis 对资源需求较低)
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: 200m # 限制最大 200 毫核 CPU
memory: 2Gi
# 第三部分MySQL 数据库部署配置
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: db # 数据库部署名称
namespace: djangoblog
labels:
app: db # 数据库标签
spec:
replicas: 1 # 数据库单副本(生产环境需考虑主从或集群)
selector:
matchLabels:
app: db # 匹配数据库 Pod 标签
template:
metadata:
labels:
app: db # Pod 标签
spec:
containers:
- name: db # 容器名称
image: mysql:latest # MySQL 官方最新镜像
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3306 # MySQL 默认端口
# 从 ConfigMap 注入环境变量(如数据库密码、用户名等)
envFrom:
- configMapRef:
name: djangoblog-env # 复用 Django 应用的环境变量配置
# 就绪探针:通过执行 mysqladmin ping 检查数据库是否就绪
readinessProbe:
exec: # 执行命令检查
command:
- mysqladmin
- ping
- "-h"
- "127.0.0.1" # 数据库主机(容器内本地)
- "-u"
- "root" # 用户名
- "-p$MYSQL_ROOT_PASSWORD" # 密码(从环境变量获取)
initialDelaySeconds: 10 # 延迟 10 秒检查
periodSeconds: 10 # 每 10 秒检查一次
# 存活探针:同就绪探针,确保数据库存活
livenessProbe:
exec:
command:
- mysqladmin
- ping
- "-h"
- "127.0.0.1"
- "-u"
- "root"
- "-p$MYSQL_ROOT_PASSWORD"
initialDelaySeconds: 10
periodSeconds: 10
# 资源限制(数据库对资源需求较高)
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
# 挂载数据库数据目录(持久化存储,避免数据丢失)
volumeMounts:
- name: db-data # 引用数据卷
mountPath: /var/lib/mysql # MySQL 数据存储路径
volumes:
- name: db-data # 数据卷名称
persistentVolumeClaim:
claimName: db-pvc # 数据库对应的 PVC 名称
# 第四部分Nginx 反向代理部署配置
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx # Nginx 部署名称
namespace: djangoblog
labels:
app: nginx # Nginx 标签
spec:
replicas: 1 # Nginx 单副本
selector:
matchLabels:
app: nginx # 匹配 Nginx Pod 标签
template:
metadata:
labels:
app: nginx # Pod 标签
spec:
containers:
- name: nginx # 容器名称
image: nginx:latest # Nginx 官方最新镜像
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80 # Nginx 默认端口
# 资源限制
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
# 卷挂载:挂载配置文件和静态资源
volumeMounts:
# 挂载 Nginx 主配置文件subPath 表示只挂载单个文件,而非目录)
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
# 挂载默认站点配置
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: djangoblog.conf
# 挂载资源站点配置
- name: nginx-config
mountPath: /etc/nginx/conf.d/resource.lylinux.net.conf
subPath: resource.lylinux.net.conf
# 挂载额外的资源配置
- name: nginx-config
mountPath: /etc/nginx/lylinux/resource.conf
subPath: lylinux.resource.conf
# 挂载 Django 静态文件目录(与 Django 应用共享存储)
- name: djangoblog-pvc
mountPath: /code/djangoblog/collectedstatic
# 挂载资源文件目录
- name: resource-pvc
mountPath: /resource
volumes:
# Nginx 配置卷:通过 ConfigMap 挂载配置文件(避免在镜像中硬编码配置)
- name: nginx-config
configMap:
name: web-nginx-config # 引用的 ConfigMap 名称
# 挂载 Django 静态文件对应的 PVC
- name: djangoblog-pvc
persistentVolumeClaim:
claimName: djangoblog-pvc
# 挂载资源文件对应的 PVC
- name: resource-pvc
persistentVolumeClaim:
claimName: resource-pvc
# 第五部分Elasticsearch 搜索引擎部署配置
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch # ES 部署名称
namespace: djangoblog
labels:
app: elasticsearch # ES 标签
spec:
replicas: 1 # ES 单节点(生产环境需集群)
selector:
matchLabels:
app: elasticsearch # 匹配 ES Pod 标签
template:
metadata:
labels:
app: elasticsearch # Pod 标签
spec:
containers:
- name: elasticsearch # 容器名称
# 带 IK 分词器的 ES 镜像(适用于中文搜索)
image: liangliangyy/elasticsearch-analysis-ik:8.6.1
imagePullPolicy: IfNotPresent
# ES 环境变量配置
env:
- name: discovery.type # 单节点模式(无需集群发现)
value: single-node
- name: ES_JAVA_OPTS # JVM 内存配置(根据需求调整)
value: "-Xms256m -Xmx256m"
- name: xpack.security.enabled # 关闭安全验证(简化部署)
value: "false"
- name: xpack.monitoring.templates.enabled # 关闭监控模板
value: "false"
ports:
- containerPort: 9200 # ES HTTP 接口端口
# 资源限制ES 对内存需求较高)
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
# 就绪探针:检查 ES 是否就绪
readinessProbe:
httpGet:
path: / # ES 健康检查路径
port: 9200
initialDelaySeconds: 15 # 延迟 15 秒ES 启动较慢)
periodSeconds: 30
# 存活探针:检查 ES 是否存活
livenessProbe:
httpGet:
path: /
port: 9200
initialDelaySeconds: 15
periodSeconds: 30
# 挂载 ES 数据目录(持久化存储索引数据)
volumeMounts:
- name: elasticsearch-data
mountPath: /usr/share/elasticsearch/data/ # ES 数据存储路径
volumes:
- name: elasticsearch-data
persistentVolumeClaim:
claimName: elasticsearch-pvc # ES 对应的 PVC 名称

@ -0,0 +1,31 @@
# Ingress 资源配置(用于管理外部访问集群内服务的规则)
# apiVersion 指定 Kubernetes API 版本networking.k8s.io/v1 是 Ingress 的稳定版本
apiVersion: networking.k8s.io/v1
# kind 定义资源类型为 Ingress用于配置外部访问规则
kind: Ingress
metadata:
# Ingress 资源的名称
name: nginx
# 所属命名空间(与前面的部署资源保持一致,确保资源在同一命名空间内可访问)
namespace: djangoblog
spec:
# 指定 Ingress 控制器的类别(需提前部署对应类别的 Ingress Controller这里使用 nginx 类型)
ingressClassName: nginx
# 访问规则定义(外部请求如何路由到集群内的服务)
rules:
# 未指定 host 表示匹配所有未被其他规则匹配的主机(可理解为默认规则)
- http:
# HTTP 协议的路由规则
paths:
# 路径规则:匹配以 / 开头的所有请求(即所有路径)
- path: /
# 路径匹配类型Prefix 表示前缀匹配(/ 会匹配所有路径)
pathType: Prefix
# 后端服务配置:请求转发到哪个服务
backend:
service:
# 目标服务的名称(需提前创建名为 nginx 的 Service关联到 nginx 部署的 Pod
name: nginx
# 目标服务的端口号(对应 nginx 服务暴露的 80 端口)
port:
number: 80

@ -0,0 +1,101 @@
# 第一部分数据库MySQL专用持久卷配置
apiVersion: v1 # PV 资源使用的 Kubernetes API 版本
kind: PersistentVolume # 资源类型为持久卷PV用于提供集群级别的存储资源
metadata:
name: local-pv-db # PV 的名称需唯一这里明确关联数据库db
spec:
capacity: # 定义 PV 的存储容量
storage: 10Gi # 分配 10GiB 存储空间(数据库通常需要较大空间)
volumeMode: Filesystem # 卷模式Filesystem 表示以文件系统形式挂载(另一种是 Block 块设备)
accessModes: # 访问模式:定义 PV 可被如何访问
- ReadWriteOnce # 仅允许单个节点以读写方式挂载(适合数据库等需独占写入的场景)
persistentVolumeReclaimPolicy: Retain # 回收策略Retain 表示 PV 被释放后保留数据,需手动清理
storageClassName: local-storage # 存储类名称,用于与 PersistentVolumeClaimPVC匹配
local: # 声明为本地存储(使用节点上的本地磁盘,非分布式存储)
path: /mnt/local-storage-db # 本地存储的实际路径(需在对应节点上提前创建该目录)
nodeAffinity: # 节点亲和性:限制 PV 只能被特定节点使用(本地存储必须配置)
required: # 强制要求:必须满足以下条件才能使用该 PV
nodeSelectorTerms:
- matchExpressions: # 匹配规则
- key: kubernetes.io/hostname # 匹配节点的主机名标签
operator: In # 操作符In 表示值在指定列表中
values:
- master # 仅允许主机名为 "master" 的节点使用该 PV
# 第二部分Django 应用静态文件专用持久卷配置
--- # 分隔符:用于在一个文件中定义多个资源
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-djangoblog # PV 名称,关联 Django 应用
spec:
capacity:
storage: 5Gi # 分配 5GiB 存储空间(静态文件需求较小)
volumeMode: Filesystem
accessModes:
- ReadWriteOnce # 单节点读写(静态文件通常由单节点写入,多节点读取可考虑 ReadOnlyMany
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage # 与前面的 PV 共用同一存储类
local:
path: /mnt/local-storage-djangoblog # Django 静态文件的本地存储路径
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master # 同样限制在 "master" 节点
# 第三部分:资源文件专用持久卷配置
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-resource # PV 名称,关联通用资源文件
spec:
capacity:
storage: 5Gi # 分配 5GiB 存储空间
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/resource/ # 资源文件(如上传的图片、附件等)的本地存储路径
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master # 限制在 "master" 节点
# 第四部分Elasticsearch 搜索引擎专用持久卷配置
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-elasticsearch # PV 名称,关联 Elasticsearch
spec:
capacity:
storage: 5Gi # 分配 5GiB 存储空间(用于存储 ES 索引数据)
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/local-storage-elasticsearch # ES 数据的本地存储路径
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master # 限制在 "master" 节点

@ -0,0 +1,66 @@
# 第一部分数据库MySQL持久卷声明PVC
# PVC 用于向 Kubernetes 请求存储资源,需与 PV 匹配后才能供 Pod 使用
apiVersion: v1 # PVC 资源对应的 Kubernetes API 版本
kind: PersistentVolumeClaim # 资源类型为持久卷声明PVC
metadata:
name: db-pvc # PVC 名称,需与数据库 Deployment 中引用的 PVC 名称一致
namespace: djangoblog # 所属命名空间,与数据库 Deployment、对应 PV 保持一致(资源隔离)
spec:
storageClassName: local-storage # 存储类名称,必须与目标 PV 的 storageClassName 完全匹配(用于筛选 PV
volumeName: local-pv-db # 显式指定绑定的 PV 名称(强制绑定,非必填;不指定则按条件自动匹配)
accessModes: # 访问模式,需与目标 PV 的 accessModes 兼容(否则无法绑定)
- ReadWriteOnce # 单节点读写模式,与数据库 PV 的访问模式一致(满足数据库独占写入需求)
resources: # 存储资源请求,定义需要的存储容量
requests:
storage: 10Gi # 请求 10GiB 存储空间,需小于或等于目标 PV 的 capacity此处与 db PV 容量完全匹配)
# 第二部分Django 应用静态文件持久卷声明PVC
--- # 资源分隔符,用于在单个文件中定义多个 Kubernetes 资源
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: djangoblog-pvc # PVC 名称,需与 Django Deployment 中 volumeMounts 引用的 PVC 名称一致
namespace: djangoblog
spec:
volumeName: local-pv-djangoblog # 显式绑定 Django 应用专用 PV
storageClassName: local-storage # 与 Django 应用 PV 的存储类一致
accessModes:
- ReadWriteOnce # 单节点读写,与 Django 应用 PV 访问模式匹配
resources:
requests:
storage: 5Gi # 请求 5GiB 存储空间,与 Django 应用 PV 容量一致(用于存储静态文件)
# 第三部分资源文件如上传附件、图片持久卷声明PVC
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: resource-pvc # PVC 名称,需与 Django、Nginx Deployment 中引用的资源卷 PVC 名称一致
namespace: djangoblog
spec:
volumeName: local-pv-resource # 显式绑定资源文件专用 PV
storageClassName: local-storage # 与资源文件 PV 的存储类一致
accessModes:
- ReadWriteOnce # 单节点读写,与资源文件 PV 访问模式匹配
resources:
requests:
storage: 5Gi # 请求 5GiB 存储空间,与资源文件 PV 容量一致
# 第四部分Elasticsearch搜索引擎持久卷声明PVC
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: elasticsearch-pvc # PVC 名称,需与 Elasticsearch Deployment 中引用的 PVC 名称一致
namespace: djangoblog
spec:
volumeName: local-pv-elasticsearch # 显式绑定 Elasticsearch 专用 PV
storageClassName: local-storage # 与 Elasticsearch PV 的存储类一致
accessModes:
- ReadWriteOnce # 单节点读写,与 Elasticsearch PV 访问模式匹配
resources:
requests:
storage: 5Gi # 请求 5GiB 存储空间,与 Elasticsearch PV 容量一致(用于存储索引数据)

@ -0,0 +1,93 @@
# 第一部分Django 应用服务Service
# Service 用于为集群内的 Pod 提供稳定网络访问地址,实现 Pod 访问的负载均衡和服务发现
apiVersion: v1 # Service 资源对应的 Kubernetes API 版本
kind: Service # 资源类型为 Service
metadata:
name: djangoblog # Service 名称,需与其他组件(如 Nginx 配置)中引用的服务名一致
namespace: djangoblog # 所属命名空间,与 Django Deployment、其他组件保持一致资源隔离
labels:
app: djangoblog # 服务标签,用于筛选和管理服务资源
spec:
selector: # 标签选择器:通过标签匹配要管理的 Pod必须与 Django Pod 的标签一致)
app: djangoblog
ports: # 端口配置:定义服务暴露的端口与 Pod 端口的映射关系
- protocol: TCP # 网络协议,默认 TCP常用还有 UDP
port: 8000 # 服务暴露给集群内部的端口(其他组件通过此端口访问该服务)
targetPort: 8000 # 服务转发请求到 Pod 的目标端口(需与 Django 容器暴露的端口一致)
type: ClusterIP # 服务类型ClusterIP 表示仅在集群内部暴露服务,外部无法直接访问(适合内部组件通信)
# 第二部分Nginx 服务Service
--- # 资源分隔符,用于在单个文件中定义多个 Kubernetes 资源
apiVersion: v1
kind: Service
metadata:
name: nginx # Service 名称,需与 Ingress 配置中引用的服务名一致
namespace: djangoblog
labels:
app: nginx
spec:
selector:
app: nginx # 匹配 Nginx Pod 的标签
ports:
- protocol: TCP
port: 80 # 服务暴露的端口Ingress 转发请求到该端口)
targetPort: 80 # 转发到 Nginx 容器暴露的 80 端口
type: ClusterIP # 集群内部访问(外部通过 Ingress 间接访问 Nginx 服务)
# 第三部分Redis 缓存服务Service
---
apiVersion: v1
kind: Service
metadata:
name: redis # Service 名称,需与 Django 应用配置中访问 Redis 的服务名一致
namespace: djangoblog
labels:
app: redis
spec:
selector:
app: redis # 匹配 Redis Pod 的标签
ports:
- protocol: TCP
port: 6379 # 服务暴露的端口Redis 默认端口)
targetPort: 6379 # 转发到 Redis 容器暴露的 6379 端口
type: ClusterIP # 仅集群内部访问(缓存服务无需外部暴露)
# 第四部分MySQL 数据库服务Service
---
apiVersion: v1
kind: Service
metadata:
name: db # Service 名称,需与 Django 应用配置中访问数据库的服务名一致
namespace: djangoblog
labels:
app: db
spec:
selector:
app: db # 匹配 MySQL Pod 的标签
ports:
- protocol: TCP
port: 3306 # 服务暴露的端口MySQL 默认端口)
targetPort: 3306 # 转发到 MySQL 容器暴露的 3306 端口
type: ClusterIP # 仅集群内部访问(数据库服务禁止外部直接访问,保障安全)
# 第五部分Elasticsearch 搜索引擎服务Service
---
apiVersion: v1
kind: Service
metadata:
name: elasticsearch # Service 名称,需与 Django 应用配置中访问 ES 的服务名一致
namespace: djangoblog
labels:
app: elasticsearch
spec:
selector:
app: elasticsearch # 匹配 Elasticsearch Pod 的标签
ports:
- protocol: TCP
port: 9200 # 服务暴露的端口ES HTTP 接口默认端口)
targetPort: 9200 # 转发到 ES 容器暴露的 9200 端口
type: ClusterIP # 仅集群内部访问(搜索引擎无需外部直接暴露)

@ -0,0 +1,20 @@
# StorageClass 资源配置(用于定义存储资源的类型和动态供应策略)
# apiVersion 指定 Kubernetes API 版本storage.k8s.io/v1 是 StorageClass 的稳定版本
apiVersion: storage.k8s.io/v1
# kind 定义资源类型为 StorageClass用于统一管理存储资源的属性
kind: StorageClass
metadata:
# StorageClass 的名称,需与前面 PV 和 PVC 中指定的 storageClassName 一致
name: local-storage
# 注解:设置为默认存储类(当 PVC 未指定 storageClassName 时,自动使用此存储类)
annotations:
storageclass.kubernetes.io/is-default-class: "true"
spec:
# 存储供应器:指定用于动态创建 PV 的插件(此处使用 no-provisioner 表示不支持动态供应)
# 因为前面的 PV 是手动创建的本地存储,无需动态生成,所以使用此供应器
provisioner: kubernetes.io/no-provisioner
# 卷绑定模式Immediate 表示 PVC 创建后立即尝试绑定可用的 PV不等待 Pod 调度)
# 对于本地存储,若使用 WaitForFirstConsumer 模式会更合适(等待 Pod 调度后再绑定对应节点的 PV
# 此处配置为 Immediate需确保 PV 已提前创建且满足 PVC 条件
volumeBindingMode: Immediate

@ -0,0 +1,82 @@
# Nginx 核心配置文件,用于处理静态资源和反向代理请求到 Django 应用
# 全局配置段:设置 Nginx 整体运行参数
nginx; # 标识该文件为 Nginx 配置文件(固定起始标识)
# 工作进程数auto 表示自动根据服务器 CPU 核心数分配(优化并发性能)
worker_processes auto;
# 错误日志配置指定日志路径和日志级别notice 级别记录重要信息,不冗余)
error_log /var/log/nginx/error.log notice;
# PID 文件路径:存储 Nginx 主进程 ID用于管理 Nginx 进程(如重启、停止)
pid /var/run/nginx.pid;
# 事件模块配置:控制 Nginx 网络连接相关参数
events {
# 单个工作进程允许的最大并发连接数1024 为基础值,可根据服务器性能调整)
worker_connections 1024;
}
# HTTP 模块配置:处理 HTTP 请求的核心配置,包含全局规则和虚拟主机
http {
# 引入 MIME 类型映射文件:定义不同文件后缀对应的 Content-Type .html 对应 text/html
include /etc/nginx/mime.types;
# 默认 MIME 类型:当文件类型未匹配时,默认使用二进制流类型(避免浏览器直接解析未知文件)
default_type application/octet-stream;
# 日志格式定义:自定义访问日志的记录字段,命名为 main
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
# 访问日志配置:使用上面定义的 main 格式,指定日志存储路径
access_log /var/log/nginx/access.log main;
# 开启高效文件传输模式:使用内核零拷贝技术,提升静态文件传输效率
sendfile on;
# tcp_nopush on; # 可选配置:开启后会累积数据包再发送,适合大文件传输,默认关闭
# 长连接超时时间:客户端与 Nginx 保持连接的最长时间65 秒,超时后自动断开)
keepalive_timeout 65;
# gzip on; # 可选配置:开启 Gzip 压缩,减少传输带宽,默认关闭
# 虚拟主机配置:定义一个具体的站点规则(处理 80 端口的 HTTP 请求)
server {
# 站点根目录:默认请求的文件查找路径(此处指向 Django 静态文件目录)
root /code/djangoblog/collectedstatic/;
# 监听端口:该虚拟主机处理 80 端口的请求Nginx 默认 HTTP 端口)
listen 80;
# 长连接超时时间:覆盖 HTTP 模块的全局配置,仅作用于当前虚拟主机
keepalive_timeout 70;
# 静态资源路径规则:匹配以 /static/ 开头的请求(处理 Django 静态文件)
location /static/ {
# 缓存控制:设置静态文件缓存时间为最大(浏览器会长期缓存,减少重复请求)
expires max;
# 路径别名:将 /static/ 请求映射到实际的静态文件目录(与 root 配合确保路径正确)
alias /code/djangoblog/collectedstatic/;
}
# 默认路径规则:匹配所有未被上面规则匹配的请求(转发到 Django 应用)
location / {
# 转发请求头:传递客户端真实 IP Django否则 Django 会认为请求来自 Nginx
proxy_set_header X-Real-IP $remote_addr;
# 转发请求头:传递客户端 IP 列表(适用于多层代理场景)
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 转发请求头:传递原始请求的 Host 头(确保 Django 正确识别请求域名)
proxy_set_header Host $http_host;
# 转发标识:告诉 Django 请求来自 Nginx 代理
proxy_set_header X-NginX-Proxy true;
# 关闭重定向处理:禁止 Nginx 自动修改 Django 返回的重定向地址
proxy_redirect off;
# 条件判断:如果请求的文件在 Nginx 本地不存在(非静态文件)
if (!-f $request_filename) {
# 反向代理:将请求转发到 Django 服务(通过 Kubernetes Service 名称 djangoblog 8000 端口)
proxy_pass http://djangoblog:8000;
break; # 跳出条件判断,不再执行后续规则
}
}
}
}

@ -0,0 +1,3 @@
# Django 应用的默认配置指定
# 作用:告诉 Django 当该应用被加载时,应使用哪个配置类进行初始化
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'

@ -0,0 +1,90 @@
# 导入 Django 内置的 AdminSite 基础类(后台管理站点核心类)
from django.contrib.admin import AdminSite
# 导入日志记录模型(用于记录后台操作日志)
from django.contrib.admin.models import LogEntry
# 导入站点管理相关的默认 admin 类和模型Django 内置的站点管理功能)
from django.contrib.sites.admin import SiteAdmin
from django.contrib.sites.models import Site
# 导入各应用自定义的 admin 配置和模型(将各模块的后台管理逻辑聚合到此处)
from accounts.admin import * # 用户账户相关的 admin 配置
from blog.admin import * # 博客核心(文章、分类等)的 admin 配置
from blog.models import * # 博客核心模型
from comments.admin import * # 评论相关的 admin 配置
from comments.models import *# 评论模型
# 导入自定义的日志条目 admin 配置(扩展日志展示功能)
from djangoblog.logentryadmin import LogEntryAdmin
from oauth.admin import * # 第三方登录OAuth相关的 admin 配置
from oauth.models import * # OAuth 相关模型
from owntracks.admin import *# 位置追踪OwnTracks相关的 admin 配置
from owntracks.models import *# 位置追踪模型
from servermanager.admin import *# 服务器管理相关的 admin 配置
from servermanager.models import *# 服务器管理模型
# 自定义后台管理站点类(继承自 Django 内置的 AdminSite
class DjangoBlogAdminSite(AdminSite):
# 后台站点头部显示的标题(登录后顶部导航栏的文字)
site_header = 'djangoblog administration'
# 浏览器标签页显示的标题(页面标题)
site_title = 'djangoblog site admin'
# 初始化方法(调用父类构造方法,确保基础功能正常)
def __init__(self, name='admin'):
super().__init__(name)
# 权限控制方法:判断用户是否有权限访问后台
def has_permission(self, request):
# 仅允许超级用户is_superuser=True访问后台
return request.user.is_superuser
# 以下为注释掉的自定义 URL 示例(可扩展后台功能)
# def get_urls(self):
# # 先获取父类默认的 URL 配置
# urls = super().get_urls()
# from django.urls import path
# # 导入自定义视图(例如刷新缓存的视图)
# from blog.views import refresh_memcache
#
# # 定义自定义 URL 规则(如添加一个 /admin/refresh/ 路径)
# my_urls = [
# path('refresh/', self.admin_view(refresh_memcache), name="refresh"),
# ]
# # 合并默认 URL 和自定义 URL自定义 URL 优先级更高)
# return urls + my_urls
# 实例化自定义的后台管理站点(名称为 'admin',与默认后台路径保持一致)
admin_site = DjangoBlogAdminSite(name='admin')
# 注册模型与对应的 admin 配置到自定义后台站点(实现各模型在后台的管理界面)
# 博客核心模型注册
admin_site.register(Article, ArticlelAdmin) # 文章模型 + 其admin配置
admin_site.register(Category, CategoryAdmin) # 分类模型 + 其admin配置
admin_site.register(Tag, TagAdmin) # 标签模型 + 其admin配置
admin_site.register(Links, LinksAdmin) # 友情链接模型 + 其admin配置
admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型 + 其admin配置
admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型 + 其admin配置
# 服务器管理模型注册
admin_site.register(commands, CommandsAdmin) # 命令模型 + 其admin配置
admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志 + 其admin配置
# 用户模型注册
admin_site.register(BlogUser, BlogUserAdmin) # 自定义用户模型 + 其admin配置
# 评论模型注册
admin_site.register(Comment, CommentAdmin) # 评论模型 + 其admin配置
# OAuth 相关模型注册
admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型 + 其admin配置
admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置 + 其admin配置
# 位置追踪模型注册
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置日志 + 其admin配置
# Django 内置站点模型注册(使用默认的 SiteAdmin 配置)
admin_site.register(Site, SiteAdmin)
# 后台操作日志模型注册(使用自定义的 LogEntryAdmin 配置,增强日志展示)
admin_site.register(LogEntry, LogEntryAdmin)

@ -0,0 +1,20 @@
# 导入 Django 的应用配置基类(所有应用配置类需继承此类)
from django.apps import AppConfig
# 自定义应用配置类(用于 djangoblog 应用的初始化设置)
class DjangoblogAppConfig(AppConfig):
# 定义模型主键的默认类型:使用 BigAutoField自增 BigInteger 类型)
# 替代旧版的 AutoField自增 Integer支持更大范围的主键值
default_auto_field = 'django.db.models.BigAutoField'
# 应用的名称(必须与项目中 INSTALLED_APPS 配置的名称一致)
name = 'djangoblog'
# 应用就绪方法:当 Django 加载完所有应用后自动调用(用于初始化操作)
def ready(self):
# 调用父类的 ready 方法,确保基础初始化逻辑执行
super().ready()
# 导入并加载插件(应用启动时加载所有注册的插件)
# 注意:避免在模块顶部导入,防止 Django 初始化时循环导入问题
from .plugin_manage.loader import load_plugins
# 执行插件加载函数(例如注册钩子、初始化插件功能等)
load_plugins()

@ -0,0 +1,170 @@
# 导入线程模块:用于异步执行耗时操作(如发送邮件,避免阻塞主流程)
import _thread
# 导入日志模块:记录操作日志和错误信息
import logging
# 导入 Django 信号核心类:用于定义和处理自定义信号
import django.dispatch
# 导入 Django 配置、模型、工具类:支撑信号处理中的业务逻辑
from django.conf import settings
from django.contrib.admin.models import LogEntry
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.core.mail import EmailMultiAlternatives
from django.db.models.signals import post_save
from django.dispatch import receiver
# 导入项目自定义模型和工具函数:适配博客业务场景
from comments.models import Comment
from comments.utils import send_comment_email
from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
# 初始化日志对象:用于记录当前模块的日志(如邮件发送失败、爬虫通知错误)
logger = logging.getLogger(__name__)
# 定义自定义信号第三方登录OAuth成功后触发的信号携带用户ID参数
oauth_user_login_signal = django.dispatch.Signal(['id'])
# 定义自定义信号:发送邮件的信号,携带收件人、标题、内容参数
send_email_signal = django.dispatch.Signal(
['emailto', 'title', 'content'])
# 信号接收器:监听 send_email_signal 信号,触发邮件发送逻辑
@receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs):
# 从信号参数中提取邮件相关信息
emailto = kwargs['emailto'] # 收件人列表
title = kwargs['title'] # 邮件标题
content = kwargs['content'] # 邮件内容HTML格式
# 构建 HTML 格式邮件:支持富文本内容
msg = EmailMultiAlternatives(
title,
content,
from_email=settings.DEFAULT_FROM_EMAIL, # 发件人(从项目配置中获取)
to=emailto)
msg.content_subtype = "html" # 声明邮件内容为 HTML 类型
# 记录邮件发送日志到数据库
from servermanager.models import EmailSendLog
log = EmailSendLog()
log.title = title
log.content = content
log.emailto = ','.join(emailto) # 收件人列表转字符串存储
try:
# 发送邮件:返回成功发送的邮件数量
result = msg.send()
log.send_result = result > 0 # 发送成功标记(数量>0即为成功
except Exception as e:
# 捕获发送异常,记录错误日志
logger.error(f"失败邮箱号: {emailto}, {e}")
log.send_result = False # 标记发送失败
finally:
# 保存日志记录到数据库
log.save()
# 信号接收器:监听 oauth_user_login_signal 信号,处理 OAuth 登录后的逻辑
@receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs):
# 从信号参数中提取 OAuth 用户ID
id = kwargs['id']
# 获取对应的 OAuth 用户对象
oauthuser = OAuthUser.objects.get(id=id)
# 获取当前站点域名(用于判断头像是否为本站地址)
site = get_current_site().domain
# 若用户头像不是本站地址(如第三方平台的远程图片),则下载并保存到本地
if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
from djangoblog.utils import save_user_avatar
oauthuser.picture = save_user_avatar(oauthuser.picture) # 下载并更新头像路径
oauthuser.save() # 保存更新后的用户信息
# 删除侧边栏缓存:用户登录状态变化可能影响侧边栏内容(如显示登录用户信息)
delete_sidebar_cache()
# 信号接收器:监听所有模型的 post_save 信号(模型保存后触发)
@receiver(post_save)
def model_post_save_callback(
sender,
instance,
created,
raw,
using,
update_fields,
**kwargs):
# 标记是否需要清理缓存
clearcache = False
# 跳过 Admin 操作日志LogEntry的处理避免日志保存时触发不必要的逻辑
if isinstance(instance, LogEntry):
return
# 处理有 "get_full_url" 方法的模型(如 Article 文章模型)
if 'get_full_url' in dir(instance):
# 判断是否仅更新了 "views" 字段(文章阅读量)
is_update_views = update_fields == {'views'}
# 非测试环境且非阅读量更新:通知搜索引擎(如百度)收录新页面
if not settings.TESTING and not is_update_views:
try:
notify_url = instance.get_full_url() # 获取模型的完整访问链接
SpiderNotify.baidu_notify([notify_url]) # 调用百度爬虫通知接口
except Exception as ex:
# 捕获通知异常,记录错误日志
logger.error("notify sipder", ex)
# 非阅读量更新:标记需要清理缓存(如文章内容、标题修改)
if not is_update_views:
clearcache = True
# 处理 Comment 评论模型的保存逻辑
if isinstance(instance, Comment):
# 仅处理已启用的评论is_enable=True
if instance.is_enable:
# 获取评论所属文章的访问路径
path = instance.article.get_absolute_url()
# 获取当前站点域名(处理端口号,仅保留域名部分)
site = get_current_site().domain
if site.find(':') > 0:
site = site[0:site.find(':')]
# 清理文章详情页的视图缓存:避免显示旧评论
expire_view_cache(
path,
servername=site,
serverport=80,
key_prefix='blogdetail')
# 清理 SEO 处理器缓存:评论变化可能影响页面 SEO 信息
if cache.get('seo_processor'):
cache.delete('seo_processor')
# 清理该文章的评论列表缓存
comment_cache_key = 'article_comments_{id}'.format(
id=instance.article.id)
cache.delete(comment_cache_key)
# 清理侧边栏缓存:侧边栏可能显示最新评论
delete_sidebar_cache()
# 清理评论分页视图的缓存
delete_view_cache('article_comments', [str(instance.article.pk)])
# 异步发送评论通知邮件:用线程避免阻塞评论保存流程
_thread.start_new_thread(send_comment_email, (instance,))
# 若标记需要清理缓存,则清空全局缓存(确保最新数据生效)
if clearcache:
cache.clear()
# 信号接收器同时监听用户登录user_logged_in和登出user_logged_out信号
@receiver(user_logged_in)
@receiver(user_logged_out)
def user_auth_callback(sender, request, user, **kwargs):
# 若用户存在且用户名有效(排除异常情况)
if user and user.username:
# 记录用户登录/登出日志
logger.info(user)
# 清理侧边栏缓存:登录状态变化可能影响侧边栏(如显示/隐藏用户菜单)
delete_sidebar_cache()
# cache.clear() # 注释:若需全局清缓存可启用,当前仅清理侧边栏缓存

@ -0,0 +1,224 @@
# 导入 Django 字符串处理工具:确保字符串编码兼容
from django.utils.encoding import force_str
# 导入 Elasticsearch DSL 工具:构建 Elasticsearch 查询语句
from elasticsearch_dsl import Q
# 导入 Haystack 核心类:实现自定义搜索后端、查询和引擎
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query
from haystack.forms import ModelSearchForm # Haystack 基础搜索表单
from haystack.models import SearchResult # Haystack 搜索结果封装类
from haystack.utils import log as logging # Haystack 日志工具
# 导入项目自定义的 Elasticsearch 文档和管理器:关联博客文章模型
from blog.documents import ArticleDocument, ArticleDocumentManager
from blog.models import Article # 博客核心文章模型
# 初始化日志对象:记录搜索相关日志(如查询语句、错误信息)
logger = logging.getLogger(__name__)
# 自定义 Elasticsearch 搜索后端:实现 Haystack 与 Elasticsearch 的底层交互
class ElasticSearchBackend(BaseSearchBackend):
def __init__(self, connection_alias, **connection_options):
# 调用父类构造方法,初始化 Haystack 基础搜索后端
super(
ElasticSearchBackend,
self).__init__(
connection_alias,
**connection_options)
# 初始化文章文档管理器:负责 Elasticsearch 索引的创建、更新、删除
self.manager = ArticleDocumentManager()
# 启用拼写建议功能:用于返回搜索关键词的推荐词
self.include_spelling = True
# 辅助方法:将模型实例转换为 Elasticsearch 文档Document
def _get_models(self, iterable):
# 若传入空列表,默认获取所有文章;否则使用传入的模型实例
models = iterable if iterable and iterable[0] else Article.objects.all()
# 通过文档管理器将模型转换为 Elasticsearch 可识别的文档
docs = self.manager.convert_to_doc(models)
return docs
# 初始化索引:创建 Elasticsearch 索引并批量添加文档
def _create(self, models):
self.manager.create_index() # 创建 Elasticsearch 索引结构
docs = self._get_models(models) # 转换模型为文档
self.manager.rebuild(docs) # 批量写入文档到索引
# 删除索引中的文档:根据模型实例删除对应 Elasticsearch 记录
def _delete(self, models):
for m in models:
m.delete() # 调用文档的 delete 方法,删除 Elasticsearch 中的对应记录
return True
# 重建索引:全量更新 Elasticsearch 中的文档(覆盖旧数据)
def _rebuild(self, models):
# 若未指定模型,默认获取所有文章
models = models if models else Article.objects.all()
docs = self._get_models(models) # 转换模型为文档
self.manager.update_docs(docs) # 批量更新文档到索引
# Haystack 标准方法:增量更新索引(更新指定模型对应的文档)
def update(self, index, iterable, commit=True):
models = self._get_models(iterable) # 转换模型为文档
self.manager.update_docs(models) # 增量更新文档
# Haystack 标准方法:移除单个模型对应的索引记录
def remove(self, obj_or_string):
models = self._get_models([obj_or_string]) # 转换为文档
self._delete(models) # 删除文档
# Haystack 标准方法:清空索引(删除所有相关记录)
def clear(self, models=None, commit=True):
self.remove(None) # 调用 remove 方法清空索引
@staticmethod
def get_suggestion(query: str) -> str:
"""
生成搜索关键词的推荐词基于 Elasticsearch 拼写建议功能
若未找到推荐词返回原查询词
"""
# 构建 Elasticsearch 查询:匹配文章内容,并启用拼写建议
search = ArticleDocument.search() \
.query("match", body=query) \
.suggest('suggest_search', query, term={'field': 'body'}) \
.execute() # 执行查询
keywords = []
# 提取 Elasticsearch 返回的建议词
for suggest in search.suggest.suggest_search:
if suggest["options"]: # 若有推荐词,取第一个
keywords.append(suggest["options"][0]["text"])
else: # 若无推荐词,保留原查询词
keywords.append(suggest["text"])
return ' '.join(keywords) # 拼接推荐词为字符串返回
# Haystack 核心搜索方法:执行搜索并返回结果(带日志记录装饰器)
@log_query
def search(self, query_string, **kwargs):
logger.info('search query_string:' + query_string) # 记录查询关键词
# 获取分页参数:起始偏移量和结束偏移量(用于分页)
start_offset = kwargs.get('start_offset', 0)
end_offset = kwargs.get('end_offset') # 若为 NoneElasticsearch 会返回默认数量结果
# 生成推荐词:根据 is_suggest 标识判断是否需要拼写建议
if getattr(self, "is_suggest", None):
suggestion = self.get_suggestion(query_string)
else:
suggestion = query_string # 不需要建议则使用原查询词
# 构建 Elasticsearch 查询条件(布尔查询)
# 1. 匹配条件:标题或内容包含推荐词,匹配度最低 70%
q = Q('bool',
should=[Q('match', body=suggestion), Q('match', title=suggestion)],
minimum_should_match="70%")
# 构建完整搜索请求:
# - 过滤条件:使用上面的 q 匹配结果且文章状态为“已发布”status='p'、类型为“文章”type='a'
# - 不返回文档源数据source=False仅获取 ID 和得分,减少数据传输
# - 分页:按 start_offset 和 end_offset 截取结果
search = ArticleDocument.search() \
.query('bool', filter=[q]) \
.filter('term', status='p') \
.filter('term', type='a') \
.source(False)[start_offset: end_offset]
# 执行搜索,获取 Elasticsearch 返回结果
results = search.execute()
hits = results['hits'].total # 匹配到的总结果数
raw_results = [] # 存储 Haystack 标准格式的搜索结果
# 解析 Elasticsearch 原始结果,封装为 Haystack 的 SearchResult 格式
for raw_result in results['hits']['hits']:
app_label = 'blog' # 模型所属应用
model_name = 'Article' # 模型名称
additional_fields = {} # 额外字段(此处无额外信息,留空)
# 实例化 SearchResult封装应用名、模型名、文档ID、匹配得分等信息
result_class = SearchResult
result = result_class(
app_label,
model_name,
raw_result['_id'], # Elasticsearch 中文档的 ID
raw_result['_score'], # 匹配得分(用于排序)
**additional_fields)
raw_results.append(result)
# 搜索结果元数据:分面(无分面需求,留空)、拼写建议
facets = {}
# 若推荐词与原查询词不同,返回推荐词;否则为 None
spelling_suggestion = None if query_string == suggestion else suggestion
# 返回 Haystack 标准格式的搜索结果
return {
'results': raw_results, # 封装后的搜索结果列表
'hits': hits, # 总匹配数
'facets': facets, # 分面数据(空)
'spelling_suggestion': spelling_suggestion, # 拼写建议
}
# 自定义 Elasticsearch 查询类:处理查询参数解析、格式清洗等
class ElasticSearchQuery(BaseSearchQuery):
# 转换日期格式:适配 Elasticsearch 的日期查询需求
def _convert_datetime(self, date):
if hasattr(date, 'hour'): # 若为datetime含时分秒格式化为年月日时分秒
return force_str(date.strftime('%Y%m%d%H%M%S'))
else: # 若为date仅年月日补全时分秒为000000
return force_str(date.strftime('%Y%m%d000000'))
# 清洗查询词:处理 Haystack 保留词和特殊字符,避免查询语法错误
def clean(self, query_fragment):
words = query_fragment.split() # 拆分查询词为单词列表
cleaned_words = []
for word in words:
# 处理 Haystack 保留词(如 AND、OR转为小写避免语法冲突
if word in self.backend.RESERVED_WORDS:
word = word.replace(word, word.lower())
# 处理特殊字符(如 +、-、*):包含特殊字符的单词用引号包裹
for char in self.backend.RESERVED_CHARACTERS:
if char in word:
word = "'%s'" % word
break
cleaned_words.append(word)
return ' '.join(cleaned_words) # 拼接清洗后的查询词
# 构建查询片段:适配自定义查询逻辑(此处直接返回查询字符串)
def build_query_fragment(self, field, filter_type, value):
return value.query_string
# 获取搜索结果总数:通过 get_results 结果长度计算
def get_count(self):
results = self.get_results()
return len(results) if results else 0
# 获取拼写建议:返回后端生成的推荐词
def get_spelling_suggestion(self, preferred_query=None):
return self._spelling_suggestion
# 构建搜索参数:继承父类逻辑,可自定义扩展参数
def build_params(self, spelling_query=None):
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
# 自定义搜索表单:扩展 Haystack 基础表单,支持“是否启用拼写建议”的控制
class ElasticSearchModelSearchForm(ModelSearchForm):
def search(self):
# 根据请求参数is_suggest设置后端是否启用拼写建议
# 若 is_suggest = "no",则不启用;否则启用
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
# 调用父类 search 方法,执行搜索并返回结果
sqs = super().search()
return sqs
# 自定义 Elasticsearch 搜索引擎:关联后端和查询类,供 Haystack 调用
class ElasticSearchEngine(BaseEngine):
backend = ElasticSearchBackend # 绑定自定义搜索后端
query = ElasticSearchQuery # 绑定自定义查询类

@ -0,0 +1,61 @@
# 导入 Django 用户模型工具:获取当前项目的用户模型(支持自定义用户模型)
from django.contrib.auth import get_user_model
# 导入 Django 内置的 Feed 基类:用于快速实现 RSS/Atom 订阅功能
from django.contrib.syndication.views import Feed
# 导入 Django 时间工具:处理时区和当前时间
from django.utils import timezone
# 导入 RSS 2.0 格式生成器:指定 Feed 输出格式为 RSS 2.0 标准
from django.utils.feedgenerator import Rss201rev2Feed
# 导入博客核心模型和工具:关联文章数据及 Markdown 解析
from blog.models import Article
from djangoblog.utils import CommonMarkdown # 自定义 Markdown 解析工具(将文章内容转为 HTML
# 自定义 RSS Feed 类:继承 Django 内置 Feed 类,实现博客文章的订阅功能
class DjangoBlogFeed(Feed):
# 指定 Feed 生成器类型:使用 RSS 2.0 标准格式(最常用的 RSS 版本)
feed_type = Rss201rev2Feed
# Feed 描述信息:显示在订阅源的说明中
description = '大巧无工,重剑无锋.'
# Feed 标题:订阅源的名称(通常为博客名称)
title = "且听风吟 大巧无工,重剑无锋. "
# Feed 的链接:订阅源自身的 URL通常指向博客首页或 Feed 专属页面)
link = "/feed/"
# 订阅源作者名称:从系统第一个用户的昵称获取(适合个人博客)
def author_name(self):
return get_user_model().objects.first().nickname
# 订阅源作者链接:指向作者的个人页面(通过用户模型的 get_absolute_url 方法获取)
def author_link(self):
return get_user_model().objects.first().get_absolute_url()
# 订阅源包含的项目(文章):定义要展示在 Feed 中的内容
def items(self):
# 筛选条件类型为文章type='a'、状态为已发布status='p'
# 排序规则:按发布时间倒序(最新发布的文章在前)
# 数量限制:只显示最新的 5 篇文章
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
# 单个项目(文章)的标题:使用文章自身的标题
def item_title(self, item):
return item.title
# 单个项目(文章)的描述:将 Markdown 格式的文章内容转为 HTML 后展示
def item_description(self, item):
return CommonMarkdown.get_markdown(item.body) # 调用工具类解析 Markdown
# 订阅源的版权信息:动态生成包含当前年份的版权声明
def feed_copyright(self):
now = timezone.now() # 获取当前时间(带时区)
return "Copyright© {year} 且听风吟".format(year=now.year) # 格式化版权信息
# 单个项目(文章)的链接:指向文章详情页(通过文章模型的 get_absolute_url 方法获取)
def item_link(self, item):
return item.get_absolute_url()
# 单个项目文章的唯一标识GUID此处留空Django 会默认使用 item_link 作为 GUID
def item_guid(self, item):
return

@ -0,0 +1,120 @@
# 导入 Django Admin 核心模块:用于自定义后台管理界面
from django.contrib import admin
# 导入 Admin 日志相关常量和模型:处理日志操作类型(如删除)
from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType
# 导入 Django URL 和字符串处理工具:生成反向链接、处理编码和转义
from django.urls import reverse, NoReverseMatch
from django.utils.encoding import force_str
from django.utils.html import escape
from django.utils.safestring import mark_safe
# 导入国际化工具:支持后台文字的多语言翻译
from django.utils.translation import gettext_lazy as _
# 自定义 LogEntry Admin 类:用于在 Django 后台管理 Admin 操作日志(记录用户对模型的增删改操作)
class LogEntryAdmin(admin.ModelAdmin):
# 列表页筛选器:按“内容类型”(即操作的模型,如 Article、Comment筛选日志
list_filter = [
'content_type'
]
# 列表页搜索框:支持按“对象名称”(如文章标题)和“操作描述”(如“修改了标题”)搜索
search_fields = [
'object_repr',
'change_message'
]
# 列表页可点击的链接:点击“操作时间”或“操作描述”可进入日志详情页
list_display_links = [
'action_time',
'get_change_message',
]
# 列表页展示的字段:操作时间、操作用户(带链接)、操作模型、操作对象(带链接)、操作描述
list_display = [
'action_time',
'user_link',
'content_type',
'object_link',
'get_change_message',
]
# 权限控制:禁止添加日志(日志由系统自动生成,不允许手动添加)
def has_add_permission(self, request):
return False
# 权限控制:仅允许超级用户或拥有“修改日志”权限的用户查看/修改日志,且禁止 POST 请求(避免提交修改)
def has_change_permission(self, request, obj=None):
return (
request.user.is_superuser or
request.user.has_perm('admin.change_logentry')
) and request.method != 'POST'
# 权限控制:禁止删除日志(日志需留存,不允许手动删除)
def has_delete_permission(self, request, obj=None):
return False
# 自定义列表字段:操作对象(生成带链接的对象名称,点击可跳转到对象的编辑页)
def object_link(self, obj):
# 转义对象名称(避免 XSS 攻击)
object_link = escape(obj.object_repr)
# 获取操作对象的内容类型(即所属模型)
content_type = obj.content_type
# 若操作不是“删除”DELETION且内容类型存在排除异常情况
if obj.action_flag != DELETION and content_type is not None:
try:
# 生成对象编辑页的 URL格式admin/应用名/模型名/change/对象ID/
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.object_id]
)
# 将对象名称转为链接(点击跳转到编辑页)
object_link = '<a href="{}">{}</a>'.format(url, object_link)
except NoReverseMatch:
# 若无法生成链接(如模型未注册到 Admin则保留纯文本名称
pass
# 标记为安全 HTML告诉 Django 无需转义,避免链接被当作文本显示)
return mark_safe(object_link)
# 配置自定义字段的排序和显示名称
object_link.admin_order_field = 'object_repr' # 支持按“对象名称”排序
object_link.short_description = _('object') # 列表页字段显示名称(支持翻译)
# 自定义列表字段:操作用户(生成带链接的用户名,点击可跳转到用户的编辑页)
def user_link(self, obj):
# 获取用户模型的内容类型
content_type = ContentType.objects.get_for_model(type(obj.user))
# 转义用户名(避免 XSS 攻击)
user_link = escape(force_str(obj.user))
try:
# 生成用户编辑页的 URL
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.user.pk]
)
# 将用户名转为链接(点击跳转到用户编辑页)
user_link = '<a href="{}">{}</a>'.format(url, user_link)
except NoReverseMatch:
# 若无法生成链接(如用户模型未注册到 Admin则保留纯文本用户名
pass
return mark_safe(user_link)
# 配置自定义字段的排序和显示名称
user_link.admin_order_field = 'user' # 支持按“用户”排序
user_link.short_description = _('user') # 列表页字段显示名称(支持翻译)
# 优化查询性能:预加载“内容类型”关联数据(避免列表页加载时产生大量数据库查询)
def get_queryset(self, request):
queryset = super(LogEntryAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_type')
# 自定义批量操作:移除“批量删除”按钮(防止误删日志)
def get_actions(self, request):
actions = super(LogEntryAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected'] # 删除“批量删除”操作
return actions

@ -0,0 +1,41 @@
import logging
logger = logging.getLogger(__name__)
class BasePlugin:
# 插件元数据
PLUGIN_NAME = None
PLUGIN_DESCRIPTION = None
PLUGIN_VERSION = None
def __init__(self):
if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.")
self.init_plugin()
self.register_hooks()
def init_plugin(self):
"""
插件初始化逻辑
子类可以重写此方法来实现特定的初始化操作
"""
logger.info(f'{self.PLUGIN_NAME} initialized.')
def register_hooks(self):
"""
注册插件钩子
子类可以重写此方法来注册特定的钩子
"""
pass
def get_plugin_info(self):
"""
获取插件信息
:return: 包含插件元数据的字典
"""
return {
'name': self.PLUGIN_NAME,
'description': self.PLUGIN_DESCRIPTION,
'version': self.PLUGIN_VERSION
}

@ -0,0 +1,11 @@
# 文章相关操作的标识常量:用于统一管理操作类型,避免硬编码字符串导致的不一致问题
# 场景:可用于日志记录、统计分析、权限校验等,通过常量标识具体操作
ARTICLE_DETAIL_LOAD = 'article_detail_load' # 文章详情页加载操作标识(如用户访问某篇文章详情时使用)
ARTICLE_CREATE = 'article_create' # 文章创建操作标识(如用户发布新文章时使用)
ARTICLE_UPDATE = 'article_update' # 文章更新操作标识(如用户编辑已发布文章时使用)
ARTICLE_DELETE = 'article_delete' # 文章删除操作标识(如用户删除某篇文章时使用)
# 文章内容钩子Hook名称常量用于定义文章内容处理的钩子函数/扩展点名称
# 场景:在 Django 等框架中,可通过钩子机制对文章内容进行自定义处理(如过滤敏感词、添加水印、解析 markdown 等)
# 例如:注册名为 "the_content" 的钩子函数,在文章内容渲染前自动执行处理逻辑
ARTICLE_CONTENT_HOOK_NAME = "the_content"

@ -0,0 +1,44 @@
import logging
logger = logging.getLogger(__name__)
_hooks = {}
def register(hook_name: str, callback: callable):
"""
注册一个钩子回调
"""
if hook_name not in _hooks:
_hooks[hook_name] = []
_hooks[hook_name].append(callback)
logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'")
def run_action(hook_name: str, *args, **kwargs):
"""
执行一个 Action Hook
它会按顺序执行所有注册到该钩子上的回调函数
"""
if hook_name in _hooks:
logger.debug(f"Running action hook '{hook_name}'")
for callback in _hooks[hook_name]:
try:
callback(*args, **kwargs)
except Exception as e:
logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
def apply_filters(hook_name: str, value, *args, **kwargs):
"""
执行一个 Filter Hook
它会把 value 依次传递给所有注册的回调函数进行处理
"""
if hook_name in _hooks:
logger.debug(f"Applying filter hook '{hook_name}'")
for callback in _hooks[hook_name]:
try:
value = callback(value, *args, **kwargs)
except Exception as e:
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
return value

@ -0,0 +1,19 @@
import os
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
def load_plugins():
"""
Dynamically loads and initializes plugins from the 'plugins' directory.
This function is intended to be called when the Django app registry is ready.
"""
for plugin_name in settings.ACTIVE_PLUGINS:
plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
try:
__import__(f'plugins.{plugin_name}.plugin')
logger.info(f"Successfully loaded plugin: {plugin_name}")
except ImportError as e:
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)

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

Loading…
Cancel
Save