Merge pull request '更新后的Djangoblog项目的源代码' (#35) from yyd_branch into master

master
pnry42fjm 2 months ago
commit b171b8b729

@ -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,117 @@
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 _
from django.contrib import admin
from django.contrib.auth.models import Group
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect
# 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):
# 移除或修复 fieldsets 中不存在的字段
fieldsets = (
(None, {'fields': ('username', 'password')}),
('Personal info', {'fields': ('first_name', 'last_name', 'email')}),
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
# 确保没有引用 usable_password 字段
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('username', 'password1', 'password2'),
}),
)
def get_queryset(self, request):
"""限制普通用户只能看到自己的数据"""
qs = super().get_queryset(request)
if request.user.is_superuser:
return qs
return qs.filter(id=request.user.id)
def get_fieldsets(self, request, obj=None):
"""限制普通用户只能修改部分字段"""
if request.user.is_superuser:
return self.fieldsets
# 普通用户只能修改个人信息
return (
(None, {'fields': ('username',)}),
('Personal info', {'fields': ('first_name', 'last_name', 'email')}),
)
def get_readonly_fields(self, request, obj=None):
"""设置只读字段"""
if request.user.is_superuser:
return self.readonly_fields
# 普通用户不能修改权限相关字段
readonly_fields = list(self.readonly_fields)
readonly_fields.extend(['is_staff', 'is_superuser', 'groups', 'user_permissions', 'last_login', 'date_joined'])
return readonly_fields
def has_add_permission(self, request):
"""只有超级用户才能添加用户"""
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
"""只有超级用户才能删除用户"""
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
"""普通用户只能修改自己的信息"""
if request.user.is_superuser:
return True
if obj and obj.id == request.user.id:
return True
return False
# 注册自定义用户管理
admin.site.register(BlogUser, BlogUserAdmin)
# 确保普通用户可以访问admin后台is_staff=True
# 在创建用户时,需要设置 is_staff = True

@ -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'),
path("visitor-login/", views.visitor_login, name="visitor_login"),
]

@ -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,213 @@
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
from django.contrib.auth import authenticate, login, logout
from django.shortcuts import redirect
def visitor_login(request):
# 先登出已有用户
logout(request)
# 登录 visitor 访客账号
user = authenticate(username="visitor", password="visitor123")
if user:
login(request, user)
return redirect("/")
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):
# 不要重新实例化 AuthenticationForm
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)
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,226 @@
from django import forms
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
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, Tag, Category, Links, SideBar
# 获取User模型 - 这是Django推荐的方式
User = get_user_model()
class ArticleForm(forms.ModelForm):
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):
if obj.category:
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))
return "-"
link_to_category.short_description = _('category')
def get_form(self, request, obj=None, **kwargs):
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
if request.user.is_superuser:
form.base_fields['author'].queryset = get_user_model().objects.filter(is_staff=True)
else:
form.base_fields['author'].queryset = get_user_model().objects.filter(id=request.user.id)
if not obj:
form.base_fields['author'].initial = request.user
form.base_fields['author'].disabled = True
return form
def get_queryset(self, request):
qs = super(ArticlelAdmin, self).get_queryset(request)
if request.user.is_superuser:
return qs
return qs.filter(author=request.user)
def save_model(self, request, obj, form, change):
if not change and not request.user.is_superuser:
obj.author = request.user
super(ArticlelAdmin, self).save_model(request, obj, form, change)
def has_change_permission(self, request, obj=None):
if obj is not None and not request.user.is_superuser:
return obj.author == request.user
return super().has_change_permission(request, obj)
def has_delete_permission(self, request, obj=None):
if obj is not None and not request.user.is_superuser:
return obj.author == request.user
return super().has_delete_permission(request, obj)
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')
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs
def has_change_permission(self, request, obj=None):
return request.user.is_staff
def has_add_permission(self, request):
return request.user.is_staff
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_add_permission(self, request):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time')
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_add_permission(self, request):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_add_permission(self, request):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
class BlogSettingsAdmin(admin.ModelAdmin):
pass
class UserAdmin(BaseUserAdmin):
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.user.is_superuser:
return qs
return qs.filter(id=request.user.id)
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
if not request.user.is_superuser:
disabled_fields = {
'is_superuser', 'is_staff', 'is_active',
'user_permissions', 'groups', 'last_login', 'date_joined'
}
for field_name in disabled_fields:
if field_name in form.base_fields:
form.base_fields[field_name].disabled = True
if 'username' in form.base_fields:
form.base_fields['username'].disabled = True
return form
def has_add_permission(self, request):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
if obj is not None and not request.user.is_superuser:
return False
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
if obj is not None and not request.user.is_superuser:
return obj == request.user
return super().has_change_permission(request, obj)
# 注册博客模型
admin.site.register(Article, ArticlelAdmin)
admin.site.register(Tag, TagAdmin)
admin.site.register(Category, CategoryAdmin)
admin.site.register(Links, LinksAdmin)
admin.site.register(SideBar, SideBarAdmin)
# 安全地重新注册User模型
try:
admin.site.unregister(User)
except admin.sites.NotRegistered:
pass
admin.site.register(User, UserAdmin)

@ -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,64 @@
# 禁用Elasticsearch功能
ELASTICSEARCH_ENABLED = False
class ElapsedTimeDocumentManager:
"""
简单的文档管理器替代品
不依赖Elasticsearch提供空实现
"""
@staticmethod
def add(instance, *args, **kwargs):
"""添加文档 - 空实现"""
return None
@staticmethod
def update(instance, *args, **kwargs):
"""更新文档 - 空实现"""
return None
@staticmethod
def delete(instance, *args, **kwargs):
"""删除文档 - 空实现"""
return None
@staticmethod
def search(query, *args, **kwargs):
"""搜索文档 - 返回空结果"""
return []
@staticmethod
def bulk_update(instances, *args, **kwargs):
"""批量更新 - 空实现"""
return None
# 为可能被导入的Elasticsearch DSL类创建空定义
class Document:
pass
class InnerDoc:
pass
class Date:
pass
class Integer:
pass
class Long:
pass
class Text:
pass
class Object:
pass
class GeoPoint:
pass
class Keyword:
pass
class Boolean:
pass

@ -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,48 @@
import logging
import time
def get_client_ip(request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
from user_agents import parse
from blog.documents import ELASTICSEARCH_ENABLED, ElapsedTimeDocumentManager
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
ElapsedTimeDocumentManager.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,431 @@
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 django.utils.text 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)
#新增
likes = models.PositiveIntegerField(_('likes'), default=0) # 新增点赞计数字段
liked_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
through='LikeRecord',
related_name='liked_articles',
blank=True,
verbose_name=_('liked users')
)
def like_count(self):
return self.liked_users.count()
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( max_length=60, blank=True,unique=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(max_length=60, unique=True) # 移除default添加unique=True
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# 自动生成slug
if not self.slug or self.slug == 'no-slug':
self.slug = self.generate_unique_slug()
super().save(*args, **kwargs)
def generate_unique_slug(self):
"""生成唯一的slug"""
original_slug = slugify(self.name)
slug = original_slug
counter = 1
# 确保slug唯一
while Tag.objects.filter(slug=slug).exclude(id=self.id).exists():
slug = f"{original_slug}-{counter}"
counter += 1
return slug
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()
# 在文件末尾添加
class LikeRecord(models.Model):
"""点赞记录模型"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('user'),
on_delete=models.CASCADE
)
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE
)
created_time = models.DateTimeField(_('creation time'), auto_now_add=True)
class Meta:
verbose_name = _('like record')
verbose_name_plural = _('like records')
unique_together = ('user', 'article') # 防止重复点赞
def __str__(self):
return f"{self.user.username} liked {self.article.title}"

@ -0,0 +1,14 @@
from haystack import indexes
from blog.models import Article
from jieba.analyse import ChineseAnalyzer
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.all()

@ -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,75 @@
from django.urls import path
from django.views.decorators.cache import cache_page
from . import views
from blog.views import like_article
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'),
#搜索功能优化(模糊搜索 + 排序),注册搜索路由
path(
r'search/',
views.search,
name='search'),
#点赞功能路由
path(
'article/<int:article_id>/like/',
views.like_article,
name='like_article'),
]

@ -0,0 +1,517 @@
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 django.contrib.auth import authenticate, login, logout
from django.shortcuts import redirect
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
#搜索功能优化(模糊搜索 + 排序)
from django.db.models import Q
from .models import Article
#点赞
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
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)
context = super(ArticleListView, self).get_context_data(**kwargs)
user = self.request.user
# ⭐ 给模板传递当前用户点赞过的文章 ID 列表
if user.is_authenticated:
context["liked_ids"] = user.liked_articles.values_list('id', flat=True)
else:
context["liked_ids"] = []
return context
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):
return None
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
user = self.request.user
if user.is_authenticated:
kwargs['user_has_liked'] = self.object.liked_users.filter(id=user.id).exists()
else:
kwargs['user_has_liked'] = False
# ⭐ 修复:安全地获取点赞数
if hasattr(self.object, 'likes'):
kwargs['like_count'] = self.object.likes
else:
kwargs['like_count'] = self.object.liked_users.count()
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)
for c in article_comments:
c.is_liked = c.is_liked_by(self.request.user)
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 article_archive(request):
"""
文章归档页面视图 - 详细调试版
"""
try:
# 方法1最基础的查询确保没有额外过滤
articles = Article.objects.filter(status='p').order_by('-pub_time')
print("=== 详细调试信息 ===")
print(f"总文章数: {articles.count()}")
# 显示每篇文章的详细信息
for article in articles:
print(f"文章: {article.title}")
print(f" - 状态: {article.status}")
print(f" - 发布时间: {article.pub_time}")
print(f" - 分类: {article.category}") # 检查分类
print(f" - 分类ID: {getattr(article.category, 'id', '无分类')}")
print("---")
context = {
'articles': articles,
}
return render(request, 'blog/archive.html', context)
except Exception as e:
print(f"归档页面错误: {e}")
return render(request, 'blog/archive.html', {'articles': []})
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')
#搜索功能优化(模糊搜索 + 排序)
def search(request):
query = request.GET.get('q', '').strip()
sort = request.GET.get('sort', 'time')
results = []
if query:
results = Article.objects.filter(
Q(title__icontains=query) |
Q(body__icontains=query) |
Q(category__name__icontains=query) |
Q(tags__name__icontains=query)
).filter(status='p').distinct()
# 排序
if sort == 'views':
results = results.order_by('-views')
else:
results = results.order_by('-pub_time')
# 分页
paginator = Paginator(results, 10)
page = paginator.get_page(request.GET.get('page', 1))
context = {
'query': query,
'results': results,
'page': page,
}
return render(request, 'search/search.html', context)
# 点赞 - 完整修复版本
@csrf_exempt
@login_required
def like_article(request, article_id):
"""文章点赞/取消点赞 - 使用liked_users计数"""
if request.method == "POST":
try:
article = get_object_or_404(Article, id=article_id)
user = request.user
print(f"点赞操作: 用户 {user.username} 对文章 {article.title}")
# 检查用户是否已经点赞
if article.liked_users.filter(id=user.id).exists():
# 取消点赞
article.liked_users.remove(user)
liked = False
print("取消点赞")
else:
# 点赞
article.liked_users.add(user)
liked = True
print("点赞")
# 直接使用liked_users的计数
likes_count = article.liked_users.count()
print(f"当前点赞数: {likes_count}")
return JsonResponse({
'success': True,
'liked': liked,
'likes_count': likes_count
})
except Exception as e:
import traceback
print(f"点赞操作错误详情: {traceback.format_exc()}")
return JsonResponse({'success': False, 'error': str(e)})
return JsonResponse({'success': False, 'error': '只支持POST请求'})

@ -0,0 +1,47 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False)
def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
class CommentAdmin(admin.ModelAdmin):
list_per_page = 20
list_display = (
'id',
'body',
'link_to_userinfo',
'link_to_article',
'is_enable',
'creation_time')
list_display_links = ('id', 'body', 'is_enable')
list_filter = ('is_enable',)
exclude = ('creation_time', 'last_modify_time')
actions = [disable_commentstatus, enable_commentstatus]
def link_to_userinfo(self, obj):
info = (obj.author._meta.app_label, obj.author._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
def link_to_article(self, obj):
info = (obj.article._meta.app_label, obj.article._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
return format_html(
u'<a href="%s">%s</a>' % (link, obj.article.title))
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')

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

@ -0,0 +1,13 @@
from django import forms
from django.forms import ModelForm
from .models import Comment
class CommentForm(ModelForm):
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False)
class Meta:
model = Comment
fields = ['body']

@ -0,0 +1,56 @@
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
from blog.models import Article
# Create your models here.
class Comment(models.Model):
body = models.TextField('正文', max_length=300)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
parent_comment = models.ForeignKey(
'self',
verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
class Meta:
ordering = ['-id']
verbose_name = _('comment')
verbose_name_plural = verbose_name
get_latest_by = 'id'
# 新增:点赞用户
liked = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='liked_comments',
blank=True
)
def like_count(self):
return self.liked.count()
def is_liked_by(self, user):
if user.is_authenticated:
return self.liked.filter(id=user.id).exists()
return False
def __str__(self):
return self.body

@ -0,0 +1,30 @@
from django import template
register = template.Library()
@register.simple_tag
def parse_commenttree(commentlist, comment):
"""获得当前评论子评论的列表
用法: {% parse_commenttree article_comments comment as childcomments %}
"""
datas = []
def parse(c):
childs = commentlist.filter(parent_comment=c, is_enable=True)
for child in childs:
datas.append(child)
parse(child)
parse(comment)
return datas
@register.inclusion_tag('comments/tags/comment_item.html')
def show_comment_item(comment, ischild):
"""评论"""
depth = 1 if ischild else 2
return {
'comment_item': comment,
'depth': depth
}

@ -0,0 +1,109 @@
from django.test import Client, RequestFactory, TransactionTestCase
from django.urls import reverse
from accounts.models import BlogUser
from blog.models import Category, Article
from comments.models import Comment
from comments.templatetags.comments_tags import *
from djangoblog.utils import get_max_articleid_commentid
# Create your tests here.
class CommentsTest(TransactionTestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
from blog.models import BlogSettings
value = BlogSettings()
value.comment_need_review = True
value.save()
self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
def update_article_comment_status(self, article):
comments = article.comment_set.all()
for comment in comments:
comment.is_enable = True
comment.save()
def test_validate_comment(self):
self.client.login(username='liangliangyy1', password='liangliangyy1')
category = Category()
category.name = "categoryccc"
category.save()
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = self.user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
comment_url = reverse(
'comments:postcomment', kwargs={
'article_id': article.id})
response = self.client.post(comment_url,
{
'body': '123ffffffffff'
})
self.assertEqual(response.status_code, 302)
article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 0)
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 1)
response = self.client.post(comment_url,
{
'body': '123ffffffffff',
})
self.assertEqual(response.status_code, 302)
article = Article.objects.get(pk=article.pk)
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 2)
parent_comment_id = article.comment_list()[0].id
response = self.client.post(comment_url,
{
'body': '''
# Title1
```python
import os
```
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com)
''',
'parent_comment_id': parent_comment_id
})
self.assertEqual(response.status_code, 302)
self.update_article_comment_status(article)
article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 3)
comment = Comment.objects.get(id=parent_comment_id)
tree = parse_commenttree(article.comment_list(), comment)
self.assertEqual(len(tree), 1)
data = show_comment_item(comment, True)
self.assertIsNotNone(data)
s = get_max_articleid_commentid()
self.assertIsNotNone(s)
from comments.utils import send_comment_email
send_comment_email(comment)

@ -0,0 +1,15 @@
from django.urls import path
from . import views
app_name = "comments"
urlpatterns = [
path(
'article/<int:article_id>/postcomment',
views.CommentPostView.as_view(),
name='postcomment'),
path('comment/<int:comment_id>/like/',
views.like_comment,
name='like_comment'),
]

@ -0,0 +1,38 @@
import logging
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email
logger = logging.getLogger(__name__)
def send_comment_email(comment):
site = get_current_site().domain
subject = _('Thanks for your comment')
article_url = f"https://{site}{comment.article.get_absolute_url()}"
html_content = _("""<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_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,85 @@
# Create your views here.
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
from django.views.generic.edit import FormView
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from accounts.models import BlogUser
from blog.models import Article
from .forms import CommentForm
from .models import Comment
class CommentPostView(FormView):
form_class = CommentForm
template_name = 'blog/article_detail.html'
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
return super(CommentPostView, self).dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs):
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
url = article.get_absolute_url()
return HttpResponseRedirect(url + "#comments")
def form_invalid(self, form):
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
return self.render_to_response({
'form': form,
'article': article
})
def form_valid(self, form):
"""提交的数据验证合法后的逻辑"""
user = self.request.user
author = BlogUser.objects.get(pk=user.pk)
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.")
comment = form.save(False)
comment.article = article
from djangoblog.utils import get_blog_setting
settings = get_blog_setting()
if not settings.comment_need_review:
comment.is_enable = True
comment.author = author
if form.cleaned_data['parent_comment_id']:
parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id'])
comment.parent_comment = parent_comment
comment.save(True)
return HttpResponseRedirect(
"%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk))
@login_required
def like_comment(request, comment_id):
comment = Comment.objects.get(pk=comment_id)
user = request.user
if user in comment.liked.all():
# 取消点赞
comment.liked.remove(user)
liked = False
else:
# 添加点赞
comment.liked.add(user)
liked = True
return JsonResponse({
"status": "ok",
"liked": liked,
"like_count": comment.liked.count()
})

@ -0,0 +1,48 @@
version: '3'
services:
es:
image: liangliangyy/elasticsearch-analysis-ik:8.6.1
container_name: es
restart: always
environment:
- discovery.type=single-node
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ports:
- 9200:9200
volumes:
- ./bin/datas/es/:/usr/share/elasticsearch/data/
kibana:
image: kibana:8.6.1
restart: always
container_name: kibana
ports:
- 5601:5601
environment:
- ELASTICSEARCH_HOSTS=http://es:9200
djangoblog:
build: .
restart: always
command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
ports:
- "8000:8000"
volumes:
- ./collectedstatic:/code/djangoblog/collectedstatic
- ./uploads:/code/djangoblog/uploads
environment:
- DJANGO_MYSQL_DATABASE=djangoblog
- DJANGO_MYSQL_USER=root
- DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E
- DJANGO_MYSQL_HOST=db
- DJANGO_MYSQL_PORT=3306
- DJANGO_MEMCACHED_LOCATION=memcached:11211
- DJANGO_ELASTICSEARCH_HOST=es:9200
links:
- db
- memcached
depends_on:
- db
container_name: djangoblog

@ -0,0 +1,60 @@
version: '3'
services:
db:
image: mysql:latest
restart: always
environment:
- MYSQL_DATABASE=djangoblog
- MYSQL_ROOT_PASSWORD=DjAnGoBlOg!2!Q@W#E
ports:
- 3306:3306
volumes:
- ./bin/datas/mysql/:/var/lib/mysql
depends_on:
- redis
container_name: db
djangoblog:
build:
context: ../../
restart: always
command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
ports:
- "8000:8000"
volumes:
- ./collectedstatic:/code/djangoblog/collectedstatic
- ./logs:/code/djangoblog/logs
- ./uploads:/code/djangoblog/uploads
environment:
- DJANGO_MYSQL_DATABASE=djangoblog
- DJANGO_MYSQL_USER=root
- DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E
- DJANGO_MYSQL_HOST=db
- DJANGO_MYSQL_PORT=3306
- DJANGO_REDIS_URL=redis:6379
links:
- db
- redis
depends_on:
- db
container_name: djangoblog
nginx:
restart: always
image: nginx:latest
ports:
- "80:80"
- "443:443"
volumes:
- ./bin/nginx.conf:/etc/nginx/nginx.conf
- ./collectedstatic:/code/djangoblog/collectedstatic
links:
- djangoblog:djangoblog
container_name: nginx
redis:
restart: always
image: redis:latest
container_name: redis
ports:
- "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,119 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: web-nginx-config
namespace: djangoblog
data:
nginx.conf: |
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
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;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 8;
gzip_buffers 16 8k;
gzip_http_version 1.1;
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 server configurations
include /etc/nginx/conf.d/*.conf;
}
djangoblog.conf: |
server {
server_name lylinux.net;
root /code/djangoblog/collectedstatic/;
listen 80;
keepalive_timeout 70;
location /static/ {
expires max;
alias /code/djangoblog/collectedstatic/;
}
location ~* (robots\.txt|ads\.txt|favicon\.ico|favion\.ico|crossdomain\.xml|google93fd32dbd906620a\.html|BingSiteAuth\.xml|baidu_verify_Ijeny6KrmS\.html)$ {
root /resource/djangopub;
expires 1d;
access_log off;
error_log off;
}
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_redirect off;
if (!-f $request_filename) {
proxy_pass http://djangoblog:8000;
break;
}
}
}
server {
server_name www.lylinux.net;
listen 80;
return 301 https://lylinux.net$request_uri;
}
resource.lylinux.net.conf: |
server {
index index.html index.htm;
server_name resource.lylinux.net;
root /resource/;
location /djangoblog/ {
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
kind: ConfigMap
metadata:
name: djangoblog-env
namespace: djangoblog
data:
DJANGO_MYSQL_DATABASE: djangoblog
DJANGO_MYSQL_USER: db_user
DJANGO_MYSQL_PASSWORD: db_password
DJANGO_MYSQL_HOST: db_host
DJANGO_MYSQL_PORT: db_port
DJANGO_REDIS_URL: "redis:6379"
DJANGO_DEBUG: "False"
MYSQL_ROOT_PASSWORD: db_password
MYSQL_DATABASE: djangoblog
MYSQL_PASSWORD: db_password
DJANGO_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx

@ -0,0 +1,274 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: djangoblog
namespace: djangoblog
labels:
app: djangoblog
spec:
replicas: 3
selector:
matchLabels:
app: djangoblog
template:
metadata:
labels:
app: djangoblog
spec:
containers:
- name: djangoblog
image: liangliangyy/djangoblog:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: djangoblog-env
readinessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
livenessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
volumeMounts:
- name: djangoblog
mountPath: /code/djangoblog/collectedstatic
- name: resource
mountPath: /resource
volumes:
- name: djangoblog
persistentVolumeClaim:
claimName: djangoblog-pvc
- name: resource
persistentVolumeClaim:
claimName: resource-pvc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: djangoblog
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 6379
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: 200m
memory: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: db
namespace: djangoblog
labels:
app: db
spec:
replicas: 1
selector:
matchLabels:
app: db
template:
metadata:
labels:
app: db
spec:
containers:
- name: db
image: mysql:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3306
envFrom:
- configMapRef:
name: djangoblog-env
readinessProbe:
exec:
command:
- mysqladmin
- ping
- "-h"
- "127.0.0.1"
- "-u"
- "root"
- "-p$MYSQL_ROOT_PASSWORD"
initialDelaySeconds: 10
periodSeconds: 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
volumes:
- name: db-data
persistentVolumeClaim:
claimName: db-pvc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: djangoblog
labels:
app: nginx
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
volumeMounts:
- 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
- name: djangoblog-pvc
mountPath: /code/djangoblog/collectedstatic
- name: resource-pvc
mountPath: /resource
volumes:
- name: nginx-config
configMap:
name: web-nginx-config
- name: djangoblog-pvc
persistentVolumeClaim:
claimName: djangoblog-pvc
- name: resource-pvc
persistentVolumeClaim:
claimName: resource-pvc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch
namespace: djangoblog
labels:
app: elasticsearch
spec:
replicas: 1
selector:
matchLabels:
app: elasticsearch
template:
metadata:
labels:
app: elasticsearch
spec:
containers:
- name: elasticsearch
image: liangliangyy/elasticsearch-analysis-ik:8.6.1
imagePullPolicy: IfNotPresent
env:
- name: discovery.type
value: single-node
- name: ES_JAVA_OPTS
value: "-Xms256m -Xmx256m"
- name: xpack.security.enabled
value: "false"
- name: xpack.monitoring.templates.enabled
value: "false"
ports:
- containerPort: 9200
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
readinessProbe:
httpGet:
path: /
port: 9200
initialDelaySeconds: 15
periodSeconds: 30
livenessProbe:
httpGet:
path: /
port: 9200
initialDelaySeconds: 15
periodSeconds: 30
volumeMounts:
- name: elasticsearch-data
mountPath: /usr/share/elasticsearch/data/
volumes:
- name: elasticsearch-data
persistentVolumeClaim:
claimName: elasticsearch-pvc

@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nginx
namespace: djangoblog
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nginx
port:
number: 80

@ -0,0 +1,94 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-db
spec:
capacity:
storage: 10Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/local-storage-db
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-djangoblog
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/local-storage-djangoblog
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-resource
spec:
capacity:
storage: 5Gi
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
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-elasticsearch
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/local-storage-elasticsearch
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master

@ -0,0 +1,60 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-pvc
namespace: djangoblog
spec:
storageClassName: local-storage
volumeName: local-pv-db
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: djangoblog-pvc
namespace: djangoblog
spec:
volumeName: local-pv-djangoblog
storageClassName: local-storage
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: resource-pvc
namespace: djangoblog
spec:
volumeName: local-pv-resource
storageClassName: local-storage
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: elasticsearch-pvc
namespace: djangoblog
spec:
volumeName: local-pv-elasticsearch
storageClassName: local-storage
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi

@ -0,0 +1,80 @@
apiVersion: v1
kind: Service
metadata:
name: djangoblog
namespace: djangoblog
labels:
app: djangoblog
spec:
selector:
app: djangoblog
ports:
- protocol: TCP
port: 8000
targetPort: 8000
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: nginx
namespace: djangoblog
labels:
app: nginx
spec:
selector:
app: nginx
ports:
- protocol: TCP
port: 80
targetPort: 80
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: djangoblog
labels:
app: redis
spec:
selector:
app: redis
ports:
- protocol: TCP
port: 6379
targetPort: 6379
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: db
namespace: djangoblog
labels:
app: db
spec:
selector:
app: db
ports:
- protocol: TCP
port: 3306
targetPort: 3306
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: elasticsearch
namespace: djangoblog
labels:
app: elasticsearch
spec:
selector:
app: elasticsearch
ports:
- protocol: TCP
port: 9200
targetPort: 9200
type: ClusterIP

@ -0,0 +1,10 @@
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-storage
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: Immediate

@ -0,0 +1,50 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
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;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
server {
root /code/djangoblog/collectedstatic/;
listen 80;
keepalive_timeout 70;
location /static/ {
expires max;
alias /code/djangoblog/collectedstatic/;
}
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_redirect off;
if (!-f $request_filename) {
proxy_pass http://djangoblog:8000;
break;
}
}
}
}

@ -0,0 +1,19 @@
# 当该包通常是Django应用被导入时打印初始化信息
# 主要用于开发调试,确认包的加载时机和状态
print("__init__.py is running")
# 定义Django应用的默认配置类
# Django在启动时会根据此配置加载应用包括应用名称、信号注册、初始化逻辑等
# 'djangoblog.apps.DjangoblogAppConfig'表示配置类的完整路径:
# - djangoblog应用所在的包名
# - apps存放配置类的模块名
# - DjangoblogAppConfig具体的配置类继承自django.apps.AppConfig
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'
# 导入pymysql库Python连接MySQL的第三方库
import pymysql
# 将pymysql伪装成MySQLdb库
# 背景早期Django默认使用MySQLdb库连接MySQL但MySQLdb不支持Python3
# 作用让Django在使用`import MySQLdb`时实际导入pymysql实现Python3环境下的MySQL连接兼容
pymysql.install_as_MySQLdb()

@ -0,0 +1,103 @@
# 导入Django Admin相关核心组件
from django.contrib.admin import AdminSite # Django Admin站点基类
from django.contrib.admin.models import LogEntry # 管理员操作日志模型
from django.contrib.sites.admin import SiteAdmin # 站点管理的默认Admin配置
from django.contrib.sites.models import Site # Django内置的站点模型用于多站点管理
# 导入各应用的Admin配置和数据模型
from accounts.admin import * # 账户相关的Admin配置
from blog.admin import * # 博客核心功能文章、分类等的Admin配置
from blog.models import * # 博客核心数据模型
from comments.admin import * # 评论功能的Admin配置
from comments.models import * # 评论相关数据模型
from djangoblog.logentryadmin import LogEntryAdmin # 自定义的操作日志Admin配置
from oauth.admin import * # 第三方登录OAuth的Admin配置
from oauth.models import * # OAuth相关数据模型
from owntracks.admin import * # 位置追踪OwnTracks的Admin配置
from owntracks.models import *# OwnTracks相关数据模型
from servermanager.admin import * # 服务器管理的Admin配置
from servermanager.models import * # 服务器管理相关数据模型
class DjangoBlogAdminSite(AdminSite):
"""
自定义的Django Admin站点类继承自Django内置的AdminSite
作用通过重写基类属性和方法定制Admin后台的外观和权限控制
"""
# 定制Admin站点的页面头部标题显示在登录页和后台顶部导航栏
site_header = 'djangoblog administration'
# 定制Admin站点的页面标题显示在浏览器标签页
site_title = 'djangoblog site admin'
def __init__(self, name='admin'):
"""
初始化自定义Admin站点
:param name: 站点名称默认'admin'与Django默认Admin站点名称保持一致避免路由冲突
"""
super().__init__(name) # 调用父类构造方法初始化
def has_permission(self, request):
"""
重写权限检查方法控制谁能访问Admin后台
:param request: HTTP请求对象包含当前用户信息
:return: 布尔值True表示允许访问False表示拒绝访问
此处限制仅超级用户is_superuser可访问比默认的is_staff更严格
"""
return request.user.is_superuser
# 以下为注释掉的自定义URL示例可根据需求启用
# def get_urls(self):
# """
# 扩展Admin站点的URL路由添加自定义功能入口
# """
# # 先获取父类默认的URL配置
# urls = super().get_urls()
# # 导入URL路径处理和自定义视图
# from django.urls import path
# from blog.views import refresh_memcache # 示例:缓存刷新视图
#
# # 定义自定义URL规则使用admin_view()包装确保权限检查
# my_urls = [
# path('refresh/', self.admin_view(refresh_memcache), name="refresh"),
# ]
# # 合并默认URL和自定义URL自定义URL优先
# return urls + my_urls
# 实例化自定义的Admin站点名称为'admin'与Django默认Admin站点名称一致接管后台
admin_site = DjangoBlogAdminSite(name='admin')
# 注册数据模型与对应的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内置
admin_site.register(Site, SiteAdmin) # 站点模型 + Django默认的SiteAdmin配置
# 操作日志管理
admin_site.register(LogEntry, LogEntryAdmin) # 管理员操作日志模型 + 自定义Admin配置

@ -0,0 +1,43 @@
# 导入Django的AppConfig类用于配置Django应用的生命周期和元数据
from django.apps import AppConfig
class DjangoblogAppConfig(AppConfig):
"""
Django博客应用djangoblog的配置类用于定义应用的核心配置和生命周期钩子
作用
1. 配置应用的数据库主键生成规则
2. 标识应用的唯一名称
3. 定义应用就绪后的初始化逻辑如加载插件
"""
# 配置Django模型默认的自增主键字段类型
# BigAutoField是64位整数型自增字段支持更大的主键范围适用于数据量较大的博客
# 替代默认的AutoField32位整数避免数据量增长后主键溢出问题
default_auto_field = 'django.db.models.BigAutoField'
# 应用的唯一名称,必须与项目中应用的目录名一致(此处为'djangoblog'
# Django通过该名称识别应用用于注册路由、加载模型等核心操作
name = 'djangoblog'
def ready(self):
"""
Django应用就绪后的钩子方法在应用完全加载并初始化后自动调用
执行时机
- 项目启动时如runservercelery启动
- 应用注册表app registry完成所有应用加载后
注意该方法可能会被多次调用如开发环境自动重载时需确保逻辑可重入
核心功能
调用插件加载函数在应用就绪后初始化所有已激活的插件
"""
# 调用父类的ready()方法确保Django默认的应用就绪逻辑正常执行
super().ready()
# 导入并执行插件加载函数:
# 1. 从当前应用djangoblog的plugin_manage.loader模块中导入load_plugins函数
# 2. 调用load_plugins()触发插件动态加载(如导入插件模块、初始化插件实例)
# 此处是插件系统与Django应用生命周期的绑定点确保插件在应用就绪后启动
from .plugin_manage.loader import load_plugins
load_plugins()

@ -0,0 +1,207 @@
# 导入必要模块
import _thread # 用于创建多线程,处理异步任务(如发送邮件)
import logging # 日志记录模块,记录信号处理过程中的关键信息和错误
import django.dispatch # Django信号系统用于定义和发送自定义信号
from django.conf import settings # 导入Django项目配置
from django.contrib.admin.models import LogEntry # 管理员操作日志模型
from django.contrib.auth.signals import user_logged_in, user_logged_out # Django内置的用户登录/登出信号
from django.core.mail import EmailMultiAlternatives # 用于发送HTML格式的邮件
from django.db.models.signals import post_save # Django模型保存后的信号
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 # OAuth用户模型
# 创建当前模块的日志记录器,用于记录信号处理相关日志
logger = logging.getLogger(__name__)
# 定义自定义信号OAuth用户登录信号
# 触发时机当用户通过OAuth第三方登录成功登录时
# 参数:['id'] 表示信号会携带OAuthUser的id
oauth_user_login_signal = django.dispatch.Signal(['id'])
# 定义自定义信号:发送邮件信号
# 触发时机:需要发送邮件时(解耦邮件发送逻辑,便于多处调用)
# 参数:['emailto', 'title', 'content'] 分别表示收件人、邮件标题、邮件内容
send_email_signal = django.dispatch.Signal(['emailto', 'title', 'content'])
@receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs):
"""
发送邮件信号的接收器处理实际的邮件发送逻辑
:param sender: 信号发送者通常无需关注
:param kwargs: 信号携带的参数emailto, title, content
"""
# 从信号参数中提取邮件信息
emailto = kwargs['emailto'] # 收件人列表
title = kwargs['title'] # 邮件标题
content = kwargs['content'] # 邮件内容HTML格式
# 创建HTML格式邮件对象
# from_email发件人从项目配置中获取
# to收件人列表
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 # 标记为发送失败
log.save() # 保存日志记录到数据库
@receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs):
"""
OAuth用户登录信号的接收器处理第三方登录后的后续操作
:param sender: 信号发送者
:param kwargs: 信号携带的参数idOAuthUser的id
"""
id = kwargs['id'] # 获取OAuth用户ID
oauthuser = OAuthUser.objects.get(id=id) # 查询对应的OAuth用户对象
# 获取当前站点域名(用于判断头像是否为本站资源)
site = get_current_site().domain
# 若用户头像存在且不是本站资源如第三方平台的头像URL则下载并保存到本地
if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
from djangoblog.utils import save_user_avatar # 导入保存用户头像的工具函数
oauthuser.picture = save_user_avatar(oauthuser.picture) # 下载并替换头像URL为本地路径
oauthuser.save() # 保存更新后的用户信息
# 清除侧边栏缓存(用户登录状态可能影响侧边栏展示内容)
delete_sidebar_cache()
@receiver(post_save)
def model_post_save_callback(
sender,
instance,
created,
raw,
using,
update_fields,
**kwargs):
"""
Django模型保存后post_save信号的接收器处理模型保存后的联动操作
:param sender: 发送信号的模型类
:param instance: 被保存的模型实例
:param created: 是否为新创建的记录True表示新建False表示更新
:param raw: 是否为原始保存如通过loaddata导入数据时为True
:param using: 使用的数据库别名
:param update_fields: 被更新的字段列表None表示全量更新
:param kwargs: 其他参数
"""
clearcache = False # 标记是否需要清除缓存
# 若保存的是管理员操作日志LogEntry直接返回无需处理
if isinstance(instance, LogEntry):
return
# 若模型实例有get_full_url方法通常表示是可被搜索引擎收录的内容如文章
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() # 获取内容的完整URL
SpiderNotify.baidu_notify([notify_url]) # 向百度搜索引擎推送更新
except Exception as ex:
logger.error("notify spider", ex) # 推送失败时记录错误日志
# 若不是仅更新浏览量,标记需要清除缓存
if not is_update_views:
clearcache = True
# 若保存的是评论Comment实例
if isinstance(instance, Comment):
# 仅处理已启用的评论is_enable=True
if instance.is_enable:
# 获取评论所属文章的URL路径
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()
@receiver(user_logged_in) # 注册为用户登录信号的接收器
@receiver(user_logged_out) # 同时注册为用户登出信号的接收器
def user_auth_callback(sender, request, user, **kwargs):
"""
用户登录/登出信号的接收器处理认证状态变化后的操作
:param sender: 信号发送者
:param request: HTTP请求对象
:param user: 当前用户对象
:param kwargs: 其他参数
"""
# 若用户存在且用户名有效
if user and user.username:
logger.info(user) # 记录用户登录/登出日志
delete_sidebar_cache() # 清除侧边栏缓存(登录状态可能影响侧边栏内容,如显示用户名)
# cache.clear() # 注释:可根据需求开启全量缓存清除(通常侧边栏缓存足够)

@ -0,0 +1,316 @@
# 导入必要模块
from django.utils.encoding import force_str # 用于将数据转换为字符串兼容Python 2/3
"""from elasticsearch_dsl import Q """ # Elasticsearch DSL的查询构建工具
from haystack.backends import ( # Haystack搜索框架的基础类
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的日志工具
# 导入项目内部模块
from blog.documents import ArticleDocument, ArticleDocumentManager # 文章的Elasticsearch文档定义及管理器
from blog.models import Article # 博客文章模型
# 创建当前模块的日志记录器,用于记录搜索相关日志
logger = logging.getLogger(__name__)
class ElasticSearchBackend(BaseSearchBackend):
"""
基于Elasticsearch的搜索后端实现继承自Haystack的BaseSearchBackend
作用实现与Elasticsearch的交互逻辑包括索引的创建更新删除
以及搜索查询的执行拼写建议等功能
"""
def __init__(self, connection_alias, **connection_options):
"""
初始化搜索后端
:param connection_alias: 数据库连接别名用于多后端配置
:param connection_options: 连接参数如主机端口等
"""
super(ElasticSearchBackend, self).__init__(connection_alias,** connection_options)
self.manager = ArticleDocumentManager() # 初始化文章文档管理器(处理索引操作)
self.include_spelling = True # 启用拼写建议功能
def _get_models(self, iterable):
"""
将模型实例列表转换为Elasticsearch文档对象
:param iterable: 模型实例列表如Article对象列表
:return: 转换后的Elasticsearch文档列表
"""
# 若输入为空,默认使用所有已发布的文章
models = iterable if iterable and iterable[0] else Article.objects.all()
# 通过管理器将模型转换为文档
docs = self.manager.convert_to_doc(models)
return docs
def _create(self, models):
"""
创建索引并初始化文档全量重建索引时使用
:param models: 模型实例列表
"""
self.manager.create_index() # 创建Elasticsearch索引若不存在
docs = self._get_models(models) # 转换模型为文档
self.manager.rebuild(docs) # 全量重建索引(清空旧数据后插入新数据)
def _delete(self, models):
"""
从索引中删除指定模型对应的文档
:param models: 要删除的模型实例列表
:return: 操作是否成功始终返回True
"""
for m in models:
m.delete() # 调用文档的删除方法
return True
def _rebuild(self, models):
"""
增量更新索引适用于部分数据更新
:param models: 需要更新的模型实例列表若为空则更新所有文章
"""
models = models if models else Article.objects.all() # 处理空输入
docs = self._get_models(models) # 转换模型为文档
self.manager.update_docs(docs) # 增量更新文档
def update(self, index, iterable, commit=True):
"""
Haystack标准接口更新索引用于实时同步模型变更
:param index: 索引名称当前实现未使用由管理器处理
:param iterable: 模型实例列表
:param commit: 是否立即提交当前实现未使用
"""
models = self._get_models(iterable) # 转换模型为文档
self.manager.update_docs(models) # 执行更新
def remove(self, obj_or_string):
"""
Haystack标准接口从索引中移除指定对象
:param obj_or_string: 模型实例或ID字符串
"""
models = self._get_models([obj_or_string]) # 转换为文档
self._delete(models) # 执行删除
def clear(self, models=None, commit=True):
"""
Haystack标准接口清空索引或指定模型的索引
:param models: 可选指定要清空的模型类当前实现忽略清空所有
:param commit: 是否立即提交当前实现未使用
"""
self.remove(None) # 调用删除方法清空所有
@staticmethod
def get_suggestion(query: str) -> str:
"""
获取搜索建议词基于Elasticsearch的拼写纠错功能
:param query: 用户输入的搜索词
:return: 建议的修正词多个词用空格拼接
"""
# 构建搜索查询:匹配文章内容,并启用拼写建议
search = ArticleDocument.search() \
.query("match", body=query) \
.suggest('suggest_search', query, term={'field': 'body'}) \
.execute()
keywords = []
# 提取建议结果
for suggest in search.suggest.suggest_search:
if suggest["options"]: # 若有建议词,取第一个
keywords.append(suggest["options"][0]["text"])
else: # 若无建议,保留原词
keywords.append(suggest["text"])
return ' '.join(keywords) # 拼接建议词为字符串
@log_query # Haystack装饰器记录查询日志
def search(self, query_string, **kwargs):
"""
执行搜索查询的核心方法
:param query_string: 用户输入的搜索字符串
:param kwargs: 额外参数如分页偏移量start_offset/end_offset
:return: 搜索结果字典包含结果列表命中数拼写建议等
"""
logger.info('search query_string:' + query_string) # 记录搜索词
# 获取分页参数(用于限制返回结果范围)
start_offset = kwargs.get('start_offset', 0)
end_offset = kwargs.get('end_offset', 10) # 默认返回前10条
# 判断是否需要启用拼写建议通过is_suggest参数控制
if getattr(self, "is_suggest", None):
suggestion = self.get_suggestion(query_string) # 获取建议词
else:
suggestion = query_string # 不启用建议,使用原搜索词
# 构建Elasticsearch查询条件
# 1. 布尔查询should匹配标题或内容至少满足70%的条件
q = Q('bool',
should=[Q('match', body=suggestion), Q('match', title=suggestion)],
minimum_should_match="70%")
# 构建完整搜索:
# - 应用上述查询条件
# - 过滤仅包含已发布status='p'的文章type='a'
# - 不返回原始文档内容source=False
# - 应用分页偏移
search = ArticleDocument.search() \
.query('bool', filter=[q]) \
.filter('term', status='p') \
.filter('term', type='a') \
.source(False)[start_offset: end_offset]
# 执行搜索并处理结果
results = search.execute()
hits = results['hits'].total # 总命中数
raw_results = []
# 遍历搜索结果转换为Haystack的SearchResult格式
for raw_result in results['hits']['hits']:
app_label = 'blog' # 应用标签(固定为博客应用)
model_name = 'Article' # 模型名称(固定为文章模型)
additional_fields = {} # 额外字段(当前未使用)
# 创建SearchResult实例适配Haystack的结果格式
result = SearchResult(
app_label,
model_name,
raw_result['_id'], # 文档ID对应文章ID
raw_result['_score'], # 匹配得分
**additional_fields
)
raw_results.append(result)
# 构建返回结果字典
facets = {} # 分面搜索结果(当前未实现)
# 若建议词与原词不同则返回建议词否则为None
spelling_suggestion = None if query_string == suggestion else suggestion
return {
'results': raw_results, # 搜索结果列表SearchResult实例
'hits': hits, # 总命中数
'facets': facets, # 分面数据
'spelling_suggestion': spelling_suggestion, # 拼写建议
}
class ElasticSearchQuery(BaseSearchQuery):
"""
自定义搜索查询类继承自Haystack的BaseSearchQuery
作用处理搜索查询的构建逻辑包括查询字符串清洗参数转换等
"""
def _convert_datetime(self, date):
"""
转换日期时间为Elasticsearch兼容的字符串格式
:param date: 日期时间对象
:return: 格式化的字符串'20231018123000'
"""
if hasattr(date, 'hour'): # 若包含时间信息datetime对象
return force_str(date.strftime('%Y%m%d%H%M%S'))
else: # 仅日期date对象时间部分设为00:00:00
return force_str(date.strftime('%Y%m%d000000'))
def clean(self, query_fragment):
"""
清洗用户输入的查询片段处理保留字和特殊字符
:param query_fragment: 用户输入的查询字符串片段
:return: 清洗后的查询字符串
"""
words = query_fragment.split() # 按空格拆分词语
cleaned_words = []
for word in words:
# 处理Elasticsearch保留字转为小写
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):
"""
构建查询片段适配Elasticsearch的查询语法
:param field: 搜索字段
:param filter_type: 过滤类型
:param value: 查询值
:return: 构建的查询字符串
"""
return value.query_string # 直接使用查询字符串由value提供
def get_count(self):
"""
获取搜索结果总数
:return: 结果数量
"""
results = self.get_results()
return len(results) if results else 0
def get_spelling_suggestion(self, preferred_query=None):
"""
获取拼写建议适配Haystack接口
:param preferred_query: 优先使用的查询未使用
:return: 拼写建议词
"""
return self._spelling_suggestion
def build_params(self, spelling_query=None):
"""
构建查询参数适配Haystack接口
:param spelling_query: 拼写建议查询未使用
:return: 构建的参数字典
"""
kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
return kwargs
class ElasticSearchModelSearchForm(ModelSearchForm):
"""
自定义搜索表单继承自Haystack的ModelSearchForm
作用扩展默认搜索表单支持控制是否启用拼写建议
"""
def search(self):
"""
执行搜索根据表单参数控制拼写建议
:return: 搜索结果集SearchQuerySet
"""
# 通过表单数据中的"is_suggest"参数控制是否启用拼写建议
# 若"is_suggest"为"no",则禁用建议
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
# 调用父类方法执行搜索
sqs = super().search()
return sqs
class ElasticSearchEngine(BaseEngine):
"""
Elasticsearch搜索引擎入口类继承自Haystack的BaseEngine
作用绑定后端实现和查询类作为Haystack的引擎配置入口
"""
backend = ElasticSearchBackend # 指定使用的搜索后端
query = ElasticSearchQuery # 指定使用的查询类

@ -0,0 +1,40 @@
from django.contrib.auth import get_user_model
from django.contrib.syndication.views import Feed
from django.utils import timezone
from django.utils.feedgenerator import Rss201rev2Feed
from blog.models import Article
from djangoblog.utils import CommonMarkdown
class DjangoBlogFeed(Feed):
feed_type = Rss201rev2Feed
description = '大巧无工,重剑无锋.'
title = "且听风吟 大巧无工,重剑无锋. "
link = "/feed/"
def author_name(self):
return get_user_model().objects.first().nickname
def author_link(self):
return get_user_model().objects.first().get_absolute_url()
def items(self):
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
def item_title(self, item):
return item.title
def item_description(self, item):
return CommonMarkdown.get_markdown(item.body)
def feed_copyright(self):
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
def item_link(self, item):
return item.get_absolute_url()
def item_guid(self, item):
return

@ -0,0 +1,91 @@
from django.contrib import admin
from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse, NoReverseMatch
from django.utils.encoding import force_str
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
class LogEntryAdmin(admin.ModelAdmin):
list_filter = [
'content_type'
]
search_fields = [
'object_repr',
'change_message'
]
list_display_links = [
'action_time',
'get_change_message',
]
list_display = [
'action_time',
'user_link',
'content_type',
'object_link',
'get_change_message',
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return (
request.user.is_superuser or
request.user.has_perm('admin.change_logentry')
) and request.method != 'POST'
def has_delete_permission(self, request, obj=None):
return False
def object_link(self, obj):
object_link = escape(obj.object_repr)
content_type = obj.content_type
if obj.action_flag != DELETION and content_type is not None:
# try returning an actual link instead of object repr string
try:
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.object_id]
)
object_link = '<a href="{}">{}</a>'.format(url, object_link)
except NoReverseMatch:
pass
return mark_safe(object_link)
object_link.admin_order_field = 'object_repr'
object_link.short_description = _('object')
def user_link(self, obj):
content_type = ContentType.objects.get_for_model(type(obj.user))
user_link = escape(force_str(obj.user))
try:
# try returning an actual link instead of object repr string
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.user.pk]
)
user_link = '<a href="{}">{}</a>'.format(url, user_link)
except NoReverseMatch:
pass
return mark_safe(user_link)
user_link.admin_order_field = 'user'
user_link.short_description = _('user')
def get_queryset(self, request):
queryset = super(LogEntryAdmin, self).get_queryset(request)
return queryset.prefetch_related('content_type')
def get_actions(self, request):
actions = super(LogEntryAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions

@ -0,0 +1,95 @@
# 导入Python标准库中的logging模块用于实现插件运行过程中的日志记录功能
# 日志可以帮助开发者追踪插件的运行状态、排查错误等
import logging
# 创建一个日志记录器实例,其名称与当前模块(__name__)绑定
# 这样可以确保日志信息能够准确关联到插件模块,便于日志的分类和筛选
logger = logging.getLogger(__name__)
class BasePlugin:
"""
插件系统的基类抽象基类角色所有自定义插件都必须继承此类
该类的核心作用是
1. 定义插件必须包含的元数据规范名称描述版本
2. 提供插件初始化和钩子注册的统一流程
3. 封装获取插件信息的通用方法
子类通过继承此类并实现特定方法即可快速接入插件系统
"""
# 插件元数据字段(子类必须显式赋值,否则初始化会失败)
PLUGIN_NAME = None # 插件的唯一标识名称,用于在系统中区分不同插件,例如"DataCleanPlugin"
PLUGIN_DESCRIPTION = None # 插件功能的详细描述,说明插件的作用和使用场景,例如"用于清洗CSV格式的原始数据"
PLUGIN_VERSION = None # 插件的版本号,遵循语义化版本规范(如"1.0.0"),用于版本管理和兼容性判断
def __init__(self):
"""
插件实例的构造方法负责插件的初始化流程控制
执行逻辑
1. 首先验证子类是否完整实现了元数据名称描述版本
2. 若元数据不完整抛出ValueError异常阻止实例化
3. 元数据验证通过后依次调用初始化方法和钩子注册方法
确保插件在使用前完成必要的准备工作
"""
# 使用all()函数检查三个元数据字段是否都有值非None
# 若存在任何一个未定义的字段,触发异常
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):
"""
插件初始化的具体实现方法用于执行插件启动前的准备工作
基类默认实现
- 输出一条INFO级别的日志提示插件已完成初始化
子类可重写此方法实现特定逻辑例如
- 加载配置文件
- 建立数据库连接
- 初始化缓存数据结构等
注意重写时若需要保留默认日志可通过super().init_plugin()调用父类方法
"""
# 记录插件初始化成功的日志,包含插件名称便于追踪
logger.info(f'{self.PLUGIN_NAME} initialized.')
def register_hooks(self):
"""
插件钩子注册方法用于将插件功能与系统事件关联
钩子Hook机制说明
系统在运行过程中会触发一系列事件"数据处理前""任务完成后"
插件通过注册钩子可以在特定事件发生时自动执行对应逻辑
基类默认实现空方法pass
子类需根据自身功能重写此方法例如
- 调用系统提供的register_hook()方法注册事件回调
- 定义需要监听的事件类型和对应的处理函数
"""
pass
def get_plugin_info(self):
"""
获取插件元数据的统一接口用于系统展示或管理插件信息
返回值说明
- 字典类型包含三个键值对
- 'name'对应PLUGIN_NAME
- 'description'对应PLUGIN_DESCRIPTION
- 'version'对应PLUGIN_VERSION
应用场景
- 插件管理界面展示插件列表
- 系统启动时收集所有插件信息进行校验
- 插件间依赖关系判断时获取版本信息
"""
return {
'name': self.PLUGIN_NAME,
'description': self.PLUGIN_DESCRIPTION,
'version': self.PLUGIN_VERSION
}

@ -0,0 +1,30 @@
# 文章相关系统事件常量定义
# 用途:统一管理插件系统中与文章操作相关的事件名称,避免硬编码导致的不一致问题
# 所有事件名称均采用大写蛇形命名法UPPER_SNAKE_CASE符合Python常量命名规范
# 事件:文章详情页加载完成
# 触发时机:当用户访问某篇文章的详情页,页面内容加载完成后触发
# 应用场景:插件可监听此事件,执行详情页相关的自定义逻辑(如添加页面统计代码、注入额外内容等)
ARTICLE_DETAIL_LOAD = 'article_detail_load'
# 事件:文章创建完成
# 触发时机:当一篇新文章在系统中创建成功(如数据库写入完成、状态设为"已发布"或"草稿")后触发
# 应用场景:插件可监听此事件,执行创建后的后续操作(如自动生成文章摘要、同步到外部平台、发送通知等)
ARTICLE_CREATE = 'article_create'
# 事件:文章更新完成
# 触发时机:当已存在的文章内容、属性(如标题、分类、状态)修改并保存成功后触发
# 应用场景:插件可监听此事件,执行更新后的联动操作(如更新文章索引、记录修改日志、重新生成相关统计数据等)
ARTICLE_UPDATE = 'article_update'
# 事件:文章删除完成
# 触发时机:当一篇文章从系统中删除(物理删除或逻辑删除,如标记为"已删除"状态)后触发
# 应用场景:插件可监听此事件,执行删除后的清理操作(如删除关联的评论、移除相关缓存、同步删除外部存储的附件等)
ARTICLE_DELETE = 'article_delete'
# 文章内容钩子名称常量
# 用途:定义专门用于拦截、修改文章内容的钩子标识,与上述"操作事件"区分(事件侧重流程节点,钩子侧重内容处理)
# 命名格式与事件常量保持一致,确保插件系统中钩子名称的唯一性和可识别性
# 应用场景插件可注册此钩子在文章内容渲染前如详情页展示、导出为PDF对内容进行自定义处理如过滤敏感词、替换关键词、添加水印等
ARTICLE_CONTENT_HOOK_NAME = "the_content"

@ -0,0 +1,92 @@
# 导入logging模块用于记录钩子系统运行过程中的日志如注册信息、错误信息等
import logging
# 创建当前模块的日志记录器,日志名称与模块绑定,便于区分不同组件的日志输出
logger = logging.getLogger(__name__)
# 全局钩子存储字典,用于保存所有注册的钩子及其对应的回调函数
# 键:钩子名称(字符串,如"article_create"
# 值:回调函数列表(所有注册到该钩子的可调用对象将按注册顺序存储)
_hooks = {}
def register(hook_name: str, callback: callable):
"""
注册一个钩子回调函数将其添加到指定钩子名称对应的回调列表中
核心作用建立"钩子名称""处理逻辑(回调函数)"的映射关系
使后续触发钩子时能自动执行所有注册的回调
:param hook_name: 钩子名称字符串需与触发时使用的名称一致如ARTICLE_CREATE
:param callback: 可调用对象函数方法等当钩子被触发时会执行此对象
回调函数的参数需与钩子触发时传递的参数匹配
"""
# 若钩子名称尚未在全局字典中,初始化一个空列表用于存储回调
if hook_name not in _hooks:
_hooks[hook_name] = []
# 将回调函数添加到对应钩子的列表中(按注册顺序存储,触发时也按此顺序执行)
_hooks[hook_name].append(callback)
# 记录DEBUG级日志说明钩子注册成功包含钩子名称和回调函数名便于调试
logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'")
def run_action(hook_name: str, *args, **kwargs):
"""
执行指定名称的"动作钩子Action Hook"按注册顺序调用所有关联的回调函数
动作钩子特性用于触发一系列操作不关注返回值仅执行回调逻辑
典型场景文章创建后发送通知记录日志等执行动作但无需修改数据
:param hook_name: 要触发的钩子名称需已被注册过
:param *args: 传递给回调函数的位置参数可变参数根据钩子场景定义
:param **kwargs: 传递给回调函数的关键字参数可变参数根据钩子场景定义
"""
# 检查该钩子是否有已注册的回调函数
if hook_name in _hooks:
# 记录DEBUG级日志说明开始执行该动作钩子
logger.debug(f"Running action hook '{hook_name}'")
# 按注册顺序遍历所有回调函数并执行
for callback in _hooks[hook_name]:
try:
# 传递位置参数和关键字参数给回调函数
callback(*args, **kwargs)
except Exception as e:
# 若回调执行出错记录ERROR级日志包含详细异常信息
# exc_info=True 会在日志中附带堆栈跟踪,便于排查错误
logger.error(
f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}",
exc_info=True
)
def apply_filters(hook_name: str, value, *args, **kwargs):
"""
执行指定名称的"过滤钩子Filter Hook"通过回调函数链式处理初始值并返回最终结果
过滤钩子特性用于对数据进行加工处理每个回调函数接收上一个函数的输出作为输入
最终返回经过所有回调处理后的值
典型场景文章内容过滤敏感词格式化文本等修改数据并返回新值
:param hook_name: 要触发的钩子名称需已被注册过
:param value: 初始值需要被过滤/处理的数据如文章内容字符串
:param *args: 传递给回调函数的额外位置参数
:param **kwargs: 传递给回调函数的额外关键字参数
:return: 经过所有回调函数处理后的最终值
"""
# 检查该钩子是否有已注册的回调函数
if hook_name in _hooks:
# 记录DEBUG级日志说明开始执行该过滤钩子
logger.debug(f"Applying filter hook '{hook_name}'")
# 按注册顺序遍历所有回调函数,链式处理初始值
for callback in _hooks[hook_name]:
try:
# 调用回调函数,将当前值和额外参数传入,更新值为回调返回的结果
value = callback(value, *args, **kwargs)
except Exception as e:
# 若回调执行出错记录ERROR级日志包含详细异常信息
logger.error(
f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}",
exc_info=True
)
# 返回经过所有过滤处理后的最终值
return value

@ -0,0 +1,51 @@
# 导入必要的模块
# os: 用于处理文件路径和目录操作
# logging: 用于记录插件加载过程中的日志信息(成功/失败状态)
# django.conf.settings: 用于获取Django项目的配置信息如插件目录、激活的插件列表
import os
import logging
from django.conf import settings
# 创建当前模块的日志记录器,日志名称与模块绑定,便于追踪插件加载相关的日志
logger = logging.getLogger(__name__)
def load_plugins():
"""
动态加载并初始化位于'plugins'目录中的插件
功能说明
- 从Django配置中读取激活的插件列表settings.ACTIVE_PLUGINS
- 检查每个插件的目录结构是否合法是否存在plugin.py文件
- 动态导入插件的核心模块plugin.py触发插件的初始化流程
- 通过日志记录每个插件的加载结果成功/失败及原因
调用时机
该函数应在Django应用注册表app registry准备就绪后调用
通常在项目启动时如通过AppConfig.ready()方法触发确保Django环境已初始化完成
"""
# 遍历配置中激活的所有插件名称settings.ACTIVE_PLUGINS是一个插件名称列表
for plugin_name in settings.ACTIVE_PLUGINS:
# 构建插件的完整目录路径:
# settings.PLUGINS_DIR是项目中存放所有插件的根目录如"project_root/plugins"
# 拼接根目录与当前插件名称,得到具体插件的目录路径(如"project_root/plugins/my_plugin"
plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
# 验证插件目录的合法性:
# 1. 必须是一个存在的目录os.path.isdir(plugin_path)
# 2. 目录中必须包含核心文件plugin.py插件的入口模块
if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
try:
# 动态导入插件的plugin.py模块
# 导入路径格式为"plugins.{插件名称}.plugin"基于Python包结构
# 导入后会自动执行plugin.py中的顶级代码如插件类的定义和注册逻辑
__import__(f'plugins.{plugin_name}.plugin')
# 记录INFO级日志提示插件加载成功
logger.info(f"Successfully loaded plugin: {plugin_name}")
# 捕获导入过程中的异常(如模块不存在、语法错误、依赖缺失等)
except ImportError as e:
# 记录ERROR级日志提示插件导入失败并附带异常信息
# exc_info=e 会将异常堆栈信息写入日志,便于排查问题
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)

@ -0,0 +1,347 @@
"""
Django settings for djangoblog project.
Generated by 'django-admin startproject' using Django 1.10.2.
For more information on this file, see
https://docs.djangoproject.com/en/1.10/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.10/ref/settings/
"""
import os
import sys
from pathlib import Path
from django.utils.translation import gettext_lazy as _
def env_to_bool(env, default):
str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True'
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_to_bool('DJANGO_DEBUG', True)
# DEBUG = False
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
# django 4.0新增配置
CSRF_TRUSTED_ORIGINS = ['http://example.com']
# Application definition
INSTALLED_APPS = [
# 'django.contrib.admin',
'django.contrib.admin.apps.SimpleAdminConfig',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'django.contrib.sitemaps',
'mdeditor',
'haystack',
'blog',
'accounts',
'comments',
'oauth',
'servermanager',
'owntracks',
'compressor',
'djangoblog'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.gzip.GZipMiddleware',
# 'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware',
# 'django.middleware.cache.FetchFromCacheMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.http.ConditionalGetMiddleware',
'blog.middleware.OnlineMiddleware'
]
ROOT_URLCONF = 'djangoblog.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'blog.context_processors.seo_processor'
],
},
},
]
WSGI_APPLICATION = 'djangoblog.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog',
'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root',
'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or '050322yyd*',
'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1',
'PORT': int(
os.environ.get('DJANGO_MYSQL_PORT') or 3306),
'OPTIONS': {
'charset': 'utf8mb4'},
}}
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
LANGUAGES = (
('en', _('English')),
('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')),
)
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
)
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_L10N = True
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine',
'PATH': os.path.join(BASE_DIR, 'whoosh_index'),
'ANALYZER': 'utils.jieba_analyzer.jieba_analyzer',
},
}
# Automatically update searching index
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# Allow user login with username and password
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend']
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
STATIC_URL = '/static/'
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'static'),
]
AUTH_USER_MODEL = 'accounts.BlogUser'
LOGIN_URL = '/login/'
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
DATE_TIME_FORMAT = '%Y-%m-%d'
# bootstrap color styles
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
# paginate
PAGINATE_BY = 10
# http cache timeout
CACHE_CONTROL_MAX_AGE = 2592000
# cache setting
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 10800,
'LOCATION': 'unique-snowflake',
}
}
# 使用redis作为缓存
if os.environ.get("DJANGO_REDIS_URL"):
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
}
}
SITE_ID = 1
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
# Email:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False)
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True)
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com'
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465)
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = EMAIL_HOST_USER
# Setting debug=false did NOT handle except email notifications
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
# WX ADMIN password(Two times md5)
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
LOG_PATH = os.path.join(BASE_DIR, 'logs')
if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH, exist_ok=True)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'root': {
'level': 'INFO',
'handlers': ['console', 'log_file'],
},
'formatters': {
'verbose': {
'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
}
},
'filters': {
'require_debug_false': {
'()': 'django.utils.log.RequireDebugFalse',
},
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': {
'log_file': {
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
'when': 'D',
'formatter': 'verbose',
'interval': 1,
'delay': True,
'backupCount': 5,
'encoding': 'utf-8'
},
'console': {
'level': 'DEBUG',
'filters': ['require_debug_true'],
'class': 'logging.StreamHandler',
'formatter': 'verbose'
},
'null': {
'class': 'logging.NullHandler',
},
'mail_admins': {
'level': 'ERROR',
'filters': ['require_debug_false'],
'class': 'django.utils.log.AdminEmailHandler'
}
},
'loggers': {
'djangoblog': {
'handlers': ['log_file', 'console'],
'level': 'INFO',
'propagate': True,
},
'django.request': {
'handlers': ['mail_admins'],
'level': 'ERROR',
'propagate': False,
}
}
}
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# other
'compressor.finders.CompressorFinder',
)
COMPRESS_ENABLED = True
# COMPRESS_OFFLINE = True
COMPRESS_CSS_FILTERS = [
# creates absolute urls from relative ones
'compressor.filters.css_default.CssAbsoluteFilter',
# css minimizer
'compressor.filters.cssmin.CSSMinFilter'
]
COMPRESS_JS_FILTERS = [
'compressor.filters.jsmin.JSMinFilter'
]
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
MEDIA_URL = '/media/'
X_FRAME_OPTIONS = 'SAMEORIGIN'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
ELASTICSEARCH_DSL = {
'default': {
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
},
}
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
# Plugin System
PLUGINS_DIR = BASE_DIR / 'plugins'
ACTIVE_PLUGINS = [
'article_copyright',
'reading_time',
'external_links',
'view_count',
'seo_optimizer'
]

@ -0,0 +1,59 @@
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
from blog.models import Article, Category, Tag
class StaticViewSitemap(Sitemap):
priority = 0.5
changefreq = 'daily'
def items(self):
return ['blog:index', ]
def location(self, item):
return reverse(item)
class ArticleSiteMap(Sitemap):
changefreq = "monthly"
priority = "0.6"
def items(self):
return Article.objects.filter(status='p')
def lastmod(self, obj):
return obj.last_modify_time
class CategorySiteMap(Sitemap):
changefreq = "Weekly"
priority = "0.6"
def items(self):
return Category.objects.all()
def lastmod(self, obj):
return obj.last_modify_time
class TagSiteMap(Sitemap):
changefreq = "Weekly"
priority = "0.3"
def items(self):
return Tag.objects.all()
def lastmod(self, obj):
return obj.last_modify_time
class UserSiteMap(Sitemap):
changefreq = "Weekly"
priority = "0.3"
def items(self):
return list(set(map(lambda x: x.author, Article.objects.all())))
def lastmod(self, obj):
return obj.date_joined

@ -0,0 +1,21 @@
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
class SpiderNotify():
@staticmethod
def baidu_notify(urls):
try:
data = '\n'.join(urls)
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
logger.info(result.text)
except Exception as e:
logger.error(e)
@staticmethod
def notify(url):
SpiderNotify.baidu_notify(url)

@ -0,0 +1,32 @@
from django.test import TestCase
from djangoblog.utils import *
class DjangoBlogTest(TestCase):
def setUp(self):
pass
def test_utils(self):
md5 = get_sha256('test')
self.assertIsNotNone(md5)
c = CommonMarkdown.get_markdown('''
# Title1
```python
import os
```
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com)
''')
self.assertIsNotNone(c)
d = {
'd': 'key1',
'd2': 'key2'
}
data = parse_dict_to_url(d)
self.assertIsNotNone(data)

@ -0,0 +1,64 @@
"""djangoblog URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/1.10/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
"""
from django.conf import settings
from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static
from django.contrib.sitemaps.views import sitemap
from django.urls import path, include
from django.urls import re_path
from haystack.views import search_view_factory
from djangoblog.admin_site import admin_site
#from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm
from djangoblog.feeds import DjangoBlogFeed
from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
sitemaps = {
'blog': ArticleSiteMap,
'Category': CategorySiteMap,
'Tag': TagSiteMap,
'User': UserSiteMap,
'static': StaticViewSitemap
}
handler404 = 'blog.views.page_not_found_view'
handler500 = 'blog.views.server_error_view'
handle403 = 'blog.views.permission_denied_view'
urlpatterns = [
path('i18n/', include('django.conf.urls.i18n')),
#path('search/', include('haystack.urls')),
]
urlpatterns += i18n_patterns(
re_path(r'^admin/', admin_site.urls),
re_path(r'', include('blog.urls', namespace='blog')),
re_path(r'mdeditor/', include('mdeditor.urls')),
re_path(r'', include('comments.urls', namespace='comment')),
re_path(r'', include('accounts.urls', namespace='account')),
re_path(r'', include('oauth.urls', namespace='oauth')),
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
name='django.contrib.sitemaps.views.sitemap'),
re_path(r'^feed/$', DjangoBlogFeed()),
re_path(r'^rss/$', DjangoBlogFeed()),
#re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
# name='search'),
re_path(r'', include('servermanager.urls', namespace='servermanager')),
re_path(r'', include('owntracks.urls', namespace='owntracks'))
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)

@ -0,0 +1,232 @@
#!/usr/bin/env python
# encoding: utf-8
import logging
import os
import random
import string
import uuid
from hashlib import sha256
import bleach
import markdown
import requests
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.cache import cache
from django.templatetags.static import static
logger = logging.getLogger(__name__)
def get_max_articleid_commentid():
from blog.models import Article
from comments.models import Comment
return (Article.objects.latest().pk, Comment.objects.latest().pk)
def get_sha256(str):
m = sha256(str.encode('utf-8'))
return m.hexdigest()
def cache_decorator(expiration=3 * 60):
def wrapper(func):
def news(*args, **kwargs):
try:
view = args[0]
key = view.get_cache_key()
except:
key = None
if not key:
unique_str = repr((func, args, kwargs))
m = sha256(unique_str.encode('utf-8'))
key = m.hexdigest()
value = cache.get(key)
if value is not None:
# logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
if str(value) == '__default_cache_value__':
return None
else:
return value
else:
logger.debug(
'cache_decorator set cache:%s key:%s' %
(func.__name__, key))
value = func(*args, **kwargs)
if value is None:
cache.set(key, '__default_cache_value__', expiration)
else:
cache.set(key, value, expiration)
return value
return news
return wrapper
def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
刷新视图缓存
:param path:url路径
:param servername:host
:param serverport:端口
:param key_prefix:前缀
:return:是否成功
'''
from django.http import HttpRequest
from django.utils.cache import get_cache_key
request = HttpRequest()
request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport}
request.path = path
key = get_cache_key(request, key_prefix=key_prefix, cache=cache)
if key:
logger.info('expire_view_cache:get key:{path}'.format(path=path))
if cache.get(key):
cache.delete(key)
return True
return False
@cache_decorator()
def get_current_site():
site = Site.objects.get_current()
return site
class CommonMarkdown:
@staticmethod
def _convert_markdown(value):
md = markdown.Markdown(
extensions=[
'extra',
'codehilite',
'toc',
'tables',
]
)
body = md.convert(value)
toc = md.toc
return body, toc
@staticmethod
def get_markdown_with_toc(value):
body, toc = CommonMarkdown._convert_markdown(value)
return body, toc
@staticmethod
def get_markdown(value):
body, toc = CommonMarkdown._convert_markdown(value)
return body
def send_email(emailto, title, content):
from djangoblog.blog_signals import send_email_signal
send_email_signal.send(
send_email.__class__,
emailto=emailto,
title=title,
content=content)
def generate_code() -> str:
"""生成随机数验证码"""
return ''.join(random.sample(string.digits, 6))
def parse_dict_to_url(dict):
from urllib.parse import quote
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
for k, v in dict.items()])
return url
def get_blog_setting():
value = cache.get('get_blog_setting')
if value:
return value
else:
from blog.models import BlogSettings
if not BlogSettings.objects.count():
setting = BlogSettings()
setting.site_name = 'djangoblog'
setting.site_description = '基于Django的博客系统'
setting.site_seo_description = '基于Django的博客系统'
setting.site_keywords = 'Django,Python'
setting.article_sub_length = 300
setting.sidebar_article_count = 10
setting.sidebar_comment_count = 5
setting.show_google_adsense = False
setting.open_site_comment = True
setting.analytics_code = ''
setting.beian_code = ''
setting.show_gongan_code = False
setting.comment_need_review = False
setting.save()
value = BlogSettings.objects.first()
logger.info('set cache get_blog_setting')
cache.set('get_blog_setting', value)
return value
def save_user_avatar(url):
'''
保存用户头像
:param url:头像url
:return: 本地路径
'''
logger.info(url)
try:
basedir = os.path.join(settings.STATICFILES, 'avatar')
rsp = requests.get(url, timeout=2)
if rsp.status_code == 200:
if not os.path.exists(basedir):
os.makedirs(basedir)
image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
ext = os.path.splitext(url)[1] if isimage else '.jpg'
save_filename = str(uuid.uuid4().hex) + ext
logger.info('保存用户头像:' + basedir + save_filename)
with open(os.path.join(basedir, save_filename), 'wb+') as file:
file.write(rsp.content)
return static('avatar/' + save_filename)
except Exception as e:
logger.error(e)
return static('blog/img/avatar.png')
def delete_sidebar_cache():
from blog.models import LinkShowType
keys = ["sidebar" + x for x in LinkShowType.values]
for k in keys:
logger.info('delete sidebar key:' + k)
cache.delete(k)
def delete_view_cache(prefix, keys):
from django.core.cache.utils import make_template_fragment_key
key = make_template_fragment_key(prefix, keys)
cache.delete(key)
def get_resource_url():
if settings.STATIC_URL:
return settings.STATIC_URL
else:
site = get_current_site()
return 'http://' + site.domain + '/static/'
ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
'h2', 'p']
ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']}
def sanitize_html(html):
return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)

File diff suppressed because it is too large Load Diff

@ -0,0 +1,16 @@
"""
WSGI config for djangoblog project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
application = get_wsgi_application()

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

@ -0,0 +1,64 @@
# Introduction to main features settings
## Cache:
Cache using `memcache` for default. If you don't have `memcache` environment, you can remove the `default` setting in `CACHES` and change `locmemcache` to `default`.
```python
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
'LOCATION': '127.0.0.1:11211',
'KEY_PREFIX': 'django_test' if TESTING else 'djangoblog',
'TIMEOUT': 60 * 60 * 10
},
'locmemcache': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 10800,
'LOCATION': 'unique-snowflake',
}
}
```
## OAuth Login:
QQ, Weibo, Google, GitHub and Facebook are now supported for OAuth login. Fetch OAuth login permissions from the corresponding open platform, and save them with `appkey`, `appsecret` and callback address in **Backend->OAuth** configuration.
### Callback address examples:
QQ: http://your-domain-name/oauth/authorize?type=qq
Weibo: http://your-domain-name/oauth/authorize?type=weibo
type is in the type field of `oauthmanager`.
## owntracks:
owntracks is a location tracking application. It will send your locaiton to the server by timing.Simple support owntracks features. Just install owntracks app and set api address as `your-domain-name/owntracks/logtracks`. Visit `your-domain-name/owntracks/show_dates` and you will see the date with latitude and langitude, click it and see the motion track. The map is drawn by AMap.
## Email feature:
Same as before, Configure your own error msg recvie email information with`ADMINS = [('liangliang', 'liangliangyy@gmail.com')]` in `settings.py`. And modify:
```python
EMAIL_HOST = 'smtp.zoho.com'
EMAIL_PORT = 587
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER')
```
with your email account information.
## WeChat Official Account
Simple wechat official account features integrated. Set token as `your-domain-name/robot` in wechat backend. Default token is `lylinux`, you can change it to your own in `servermanager/robot.py`. Add a new command in `Backend->Servermanager->command`, in this way, you can manage the system through wechat official account.
## Introduction to website configuration
You can add website configuration in **Backend->BLOG->WebSiteConfiguration**. Such as: keywords, description, Google Ad, website stats code, case number, etc.
OAuth user avatar path is saved in *StaticFileSavedAddress*. Please input absolute path, code directory for default.
## Source code highlighting
If the code block in your article didn't show hightlight, please write the code blocks as following:
![](https://resource.lylinux.net/image/codelang.png)
That is, you should add the corresponding language name before the code block.
## Update
If you get errors as following while executing database migrations:
```python
django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1"))
```
This problem may cause by the mysql version under 5.6, a new version( >= 5.6 ) mysql is needed.

@ -0,0 +1,58 @@
# 主要功能配置介绍:
## 缓存:
缓存默认使用`localmem`缓存,如果你有`redis`环境,可以设置`DJANGO_REDIS_URL`环境变量则会自动使用该redis来作为缓存或者你也可以直接修改如下代码来使用。
https://github.com/liangliangyy/DjangoBlog/blob/ffcb2c3711de805f2067dd3c1c57449cd24d84ee/djangoblog/settings.py#L185-L199
## oauth登录:
现在已经支持QQ微博GoogleGitHubFacebook登录需要在其对应的开放平台申请oauth登录权限然后在
**后台->Oauth** 配置中新增配置,填写对应的`appkey`和`appsecret`以及回调地址。
### 回调地址示例:
qqhttp://你的域名/oauth/authorize?type=qq
微博http://你的域名/oauth/authorize?type=weibo
type对应在`oauthmanager`中的type字段。
## owntracks
owntracks是一个位置追踪软件可以定时的将你的坐标提交到你的服务器上现在简单的支持owntracks功能需要安装owntracks的app然后将api地址设置为:
`你的域名/owntracks/logtracks`就可以了。然后访问`你的域名/owntracks/show_dates`就可以看到有经纬度记录的日期,点击之后就可以看到运动轨迹了。地图是使用高德地图绘制。
## 邮件功能:
同样,将`settings.py`中的`ADMINS = [('liangliang', 'liangliangyy@gmail.com')]`配置为你自己的错误接收邮箱,另外修改:
```python
EMAIL_HOST = 'smtp.zoho.com'
EMAIL_PORT = 587
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER')
```
为你自己的邮箱配置。
## 微信公众号
集成了简单的微信公众号功能在微信后台将token地址设置为:`你的域名/robot` 即可默认token为`lylinux`,当然你可以修改为你自己的,在`servermanager/robot.py`中。
然后在**后台->Servermanager->命令**中新增命令,这样就可以使用微信公众号来管理了。
## 网站配置介绍
在**后台->BLOG->网站配置**中,可以新增网站配置,比如关键字,描述等,以及谷歌广告,网站统计代码及备案号等等。
其中的*静态文件保存地址*是保存oauth用户登录的头像路径填写绝对路径默认是代码目录。
## 代码高亮
如果你发现你文章的代码没有高亮,请这样书写代码块:
![](https://resource.lylinux.net/image/codelang.png)
也就是说,需要在代码块开始位置加入这段代码对应的语言。
## update
如果你发现执行数据库迁移的时候出现如下报错:
```python
django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1"))
```
可能是因为你的mysql版本低于5.6需要升级mysql版本>=5.6即可。
django 4.0登录可能会报错CSRF需要配置下`settings.py`中的`CSRF_TRUSTED_ORIGINS`
https://github.com/liangliangyy/DjangoBlog/blob/master/djangoblog/settings.py#L39

@ -0,0 +1,114 @@
# Deploying DjangoBlog with Docker
![Docker Pulls](https://img.shields.io/docker/pulls/liangliangyy/djangoblog)
![Docker Image Version (latest by date)](https://img.shields.io/docker/v/liangliangyy/djangoblog?sort=date)
![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/liangliangyy/djangoblog)
This project fully supports containerized deployment using Docker, providing you with a fast, consistent, and isolated runtime environment. We recommend using `docker-compose` to launch the entire blog service stack with a single command.
## 1. Prerequisites
Before you begin, please ensure you have the following software installed on your system:
- [Docker Engine](https://docs.docker.com/engine/install/)
- [Docker Compose](https://docs.docker.com/compose/install/) (Included with Docker Desktop for Mac and Windows)
## 2. Recommended Method: Using `docker-compose` (One-Click Deployment)
This is the simplest and most recommended way to deploy. It automatically creates and manages the Django application, a MySQL database, and an optional Elasticsearch service for you.
### Step 1: Start the Basic Services
From the project's root directory, run the following command:
```bash
# Build and start the containers in detached mode (includes Django app and MySQL)
docker-compose up -d --build
```
`docker-compose` will read the `docker-compose.yml` file, pull the necessary images, build the project image, and start all services.
- **Access Your Blog**: Once the services are up, you can access the blog by navigating to `http://127.0.0.1` in your browser.
- **Data Persistence**: MySQL data files will be stored in the `data/mysql` directory within the project root, ensuring that your data persists across container restarts.
### Step 2: (Optional) Enable Elasticsearch for Full-Text Search
If you want to use Elasticsearch for more powerful full-text search capabilities, you can include the `docker-compose.es.yml` configuration file:
```bash
# Build and start all services in detached mode (Django, MySQL, Elasticsearch)
docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build
```
- **Data Persistence**: Elasticsearch data will be stored in the `data/elasticsearch` directory.
### Step 3: First-Time Initialization
After the containers start for the first time, you'll need to execute some initialization commands inside the application container.
```bash
# Get a shell inside the djangoblog application container (named 'web')
docker-compose exec web bash
# Inside the container, run the following commands:
# Create a superuser account (follow the prompts to set username, email, and password)
python manage.py createsuperuser
# (Optional) Create some test data
python manage.py create_testdata
# (Optional, if ES is enabled) Create the search index
python manage.py rebuild_index
# Exit the container
exit
```
## 3. Alternative Method: Using the Standalone Docker Image
If you already have an external MySQL database running, you can run the DjangoBlog application image by itself.
```bash
# Pull the latest image from Docker Hub
docker pull liangliangyy/djangoblog:latest
# Run the container and connect it to your external database
docker run -d \
-p 8000:8000 \
-e DJANGO_SECRET_KEY='your-strong-secret-key' \
-e DJANGO_MYSQL_HOST='your-mysql-host' \
-e DJANGO_MYSQL_USER='your-mysql-user' \
-e DJANGO_MYSQL_PASSWORD='your-mysql-password' \
-e DJANGO_MYSQL_DATABASE='djangoblog' \
--name djangoblog \
liangliangyy/djangoblog:latest
```
- **Access Your Blog**: After startup, visit `http://127.0.0.1:8000`.
- **Create Superuser**: `docker exec -it djangoblog python manage.py createsuperuser`
## 4. Configuration (Environment Variables)
Most of the project's configuration is managed through environment variables. You can modify them in the `docker-compose.yml` file or pass them using the `-e` flag with the `docker run` command.
| Environment Variable | Default/Example Value | Notes |
|---------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------|
| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **Must be changed to a random, complex string!** |
| `DJANGO_DEBUG` | `False` | Toggles Django's debug mode. |
| `DJANGO_MYSQL_HOST` | `mysql` | Database hostname. |
| `DJANGO_MYSQL_PORT` | `3306` | Database port. |
| `DJANGO_MYSQL_DATABASE` | `djangoblog` | Database name. |
| `DJANGO_MYSQL_USER` | `root` | Database username. |
| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | Database password. |
| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis connection URL (for caching). |
| `DJANGO_ELASTICSEARCH_HOST`| `elasticsearch:9200` | Elasticsearch host address. |
| `DJANGO_EMAIL_HOST` | `smtp.example.org` | Email server address. |
| `DJANGO_EMAIL_PORT` | `465` | Email server port. |
| `DJANGO_EMAIL_USER` | `user@example.org` | Email account username. |
| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | Email account password. |
| `DJANGO_EMAIL_USE_SSL` | `True` | Whether to use SSL. |
| `DJANGO_EMAIL_USE_TLS` | `False` | Whether to use TLS. |
| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | Admin email for receiving error reports. |
| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | Push API from [Baidu Webmaster Tools](https://ziyuan.baidu.com/linksubmit/index). |
---
After deployment, please review and adjust these environment variables according to your needs, especially `DJANGO_SECRET_KEY` and the database and email settings.

@ -0,0 +1,114 @@
# 使用 Docker 部署 DjangoBlog
![Docker Pulls](https://img.shields.io/docker/pulls/liangliangyy/djangoblog)
![Docker Image Version (latest by date)](https://img.shields.io/docker/v/liangliangyy/djangoblog?sort=date)
![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/liangliangyy/djangoblog)
本项目全面支持使用 Docker 进行容器化部署,为您提供了快速、一致且隔离的运行环境。我们推荐使用 `docker-compose` 来一键启动整个博客服务栈。
## 1. 环境准备
在开始之前,请确保您的系统中已经安装了以下软件:
- [Docker Engine](https://docs.docker.com/engine/install/)
- [Docker Compose](https://docs.docker.com/compose/install/) (对于 Docker Desktop 用户,它已内置)
## 2. 推荐方式:使用 `docker-compose` (一键部署)
这是最简单、最推荐的部署方式。它会自动为您创建并管理 Django 应用、MySQL 数据库,以及可选的 Elasticsearch 服务。
### 步骤 1: 启动基础服务
在项目根目录下,执行以下命令:
```bash
# 构建并以后台模式启动容器 (包含 Django 应用和 MySQL)
docker-compose up -d --build
```
`docker-compose` 会读取 `docker-compose.yml` 文件,自动拉取所需镜像、构建项目镜像,并启动所有服务。
- **访问您的博客**: 服务启动后,在浏览器中访问 `http://127.0.0.1` 即可看到博客首页。
- **数据持久化**: MySQL 的数据文件将存储在项目根目录下的 `data/mysql` 文件夹中,确保数据在容器重启后不丢失。
### 步骤 2: (可选) 启用 Elasticsearch 全文搜索
如果您希望使用 Elasticsearch 提供更强大的全文搜索功能,可以额外加载 `docker-compose.es.yml` 配置文件:
```bash
# 构建并以后台模式启动所有服务 (Django, MySQL, Elasticsearch)
docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build
```
- **数据持久化**: Elasticsearch 的数据将存储在 `data/elasticsearch` 文件夹中。
### 步骤 3: 首次运行的初始化操作
当容器首次启动后,您需要进入容器来执行一些初始化命令。
```bash
# 进入 djangoblog 应用容器
docker-compose exec web bash
# 在容器内执行以下命令:
# 创建超级管理员账户 (请按照提示设置用户名、邮箱和密码)
python manage.py createsuperuser
# (可选) 创建一些测试数据
python manage.py create_testdata
# (可选,如果启用了 ES) 创建索引
python manage.py rebuild_index
# 退出容器
exit
```
## 3. 备选方式:使用独立的 Docker 镜像
如果您已经拥有一个正在运行的外部 MySQL 数据库,您也可以只运行 DjangoBlog 的应用镜像。
```bash
# 从 Docker Hub 拉取最新镜像
docker pull liangliangyy/djangoblog:latest
# 运行容器,并链接到您的外部数据库
docker run -d \
-p 8000:8000 \
-e DJANGO_SECRET_KEY='your-strong-secret-key' \
-e DJANGO_MYSQL_HOST='your-mysql-host' \
-e DJANGO_MYSQL_USER='your-mysql-user' \
-e DJANGO_MYSQL_PASSWORD='your-mysql-password' \
-e DJANGO_MYSQL_DATABASE='djangoblog' \
--name djangoblog \
liangliangyy/djangoblog:latest
```
- **访问您的博客**: 启动完成后,访问 `http://127.0.0.1:8000`
- **创建管理员**: `docker exec -it djangoblog python manage.py createsuperuser`
## 4. 配置说明 (环境变量)
本项目的大部分配置都通过环境变量来管理。您可以在 `docker-compose.yml` 文件中修改它们,或者在使用 `docker run` 命令时通过 `-e` 参数传入。
| 环境变量名称 | 默认值/示例 | 备注 |
|-------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------|
| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **请务必修改为一个随机且复杂的字符串!** |
| `DJANGO_DEBUG` | `False` | 是否开启 Django 的调试模式 |
| `DJANGO_MYSQL_HOST` | `mysql` | 数据库主机名 |
| `DJANGO_MYSQL_PORT` | `3306` | 数据库端口 |
| `DJANGO_MYSQL_DATABASE` | `djangoblog` | 数据库名称 |
| `DJANGO_MYSQL_USER` | `root` | 数据库用户名 |
| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | 数据库密码 |
| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis 连接地址 (用于缓存) |
| `DJANGO_ELASTICSEARCH_HOST` | `elasticsearch:9200` | Elasticsearch 主机地址 |
| `DJANGO_EMAIL_HOST` | `smtp.example.org` | 邮件服务器地址 |
| `DJANGO_EMAIL_PORT` | `465` | 邮件服务器端口 |
| `DJANGO_EMAIL_USER` | `user@example.org` | 邮件账户 |
| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | 邮件密码 |
| `DJANGO_EMAIL_USE_SSL` | `True` | 是否使用 SSL |
| `DJANGO_EMAIL_USE_TLS` | `False` | 是否使用 TLS |
| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | 接收异常报告的管理员邮箱 |
| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | [百度站长平台](https://ziyuan.baidu.com/linksubmit/index) 的推送接口 |
---
部署完成后,请务必检查并根据您的实际需求调整这些环境变量,特别是 `DJANGO_SECRET_KEY` 和数据库、邮件相关的配置。

@ -0,0 +1,28 @@
# 集成Elasticsearch
如果你已经有了`Elasticsearch`环境,那么可以将搜索从`Whoosh`换成`Elasticsearch`,集成方式也很简单,
首先需要注意如下几点:
1. 你的`Elasticsearch`支持`ik`中文分词
2. 你的`Elasticsearch`版本>=7.3.0
接下来在`settings.py`做如下改动即可:
- 增加es链接如下所示
```python
ELASTICSEARCH_DSL = {
'default': {
'hosts': '127.0.0.1:9200'
},
}
```
- 修改`HAYSTACK`配置:
```python
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
```
然后终端执行:
```shell script
./manage.py build_index
```
这将会在你的es中创建两个索引分别是`blog`和`performance`,其中`blog`索引就是搜索所使用的,而`performance`会记录每个请求的响应时间,以供将来优化使用。

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

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

Loading…
Cancel
Save