Compare commits

...

4 Commits

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

@ -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,49 @@
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@v3
with:
languages: python
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

@ -0,0 +1,176 @@
name: 自动部署到生产环境
on:
workflow_run:
workflows: ["Django CI"]
types:
- completed
branches:
- master
workflow_dispatch:
inputs:
environment:
description: '部署环境'
required: true
default: 'production'
type: choice
options:
- production
- staging
image_tag:
description: '镜像标签 (默认: latest)'
required: false
default: 'latest'
type: string
skip_tests:
description: '跳过测试直接部署'
required: false
default: false
type: boolean
env:
REGISTRY: registry.cn-shenzhen.aliyuncs.com
IMAGE_NAME: liangliangyy/djangoblog
NAMESPACE: djangoblog
jobs:
deploy:
name: 构建镜像并部署到生产环境
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 设置部署参数
id: deploy-params
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "trigger_type=手动触发" >> $GITHUB_OUTPUT
echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT
echo "image_tag=${{ github.event.inputs.image_tag }}" >> $GITHUB_OUTPUT
echo "skip_tests=${{ github.event.inputs.skip_tests }}" >> $GITHUB_OUTPUT
else
echo "trigger_type=CI自动触发" >> $GITHUB_OUTPUT
echo "environment=production" >> $GITHUB_OUTPUT
echo "image_tag=latest" >> $GITHUB_OUTPUT
echo "skip_tests=false" >> $GITHUB_OUTPUT
fi
- name: 显示部署信息
run: |
echo "🚀 部署信息:"
echo " 触发方式: ${{ steps.deploy-params.outputs.trigger_type }}"
echo " 部署环境: ${{ steps.deploy-params.outputs.environment }}"
echo " 镜像标签: ${{ steps.deploy-params.outputs.image_tag }}"
echo " 跳过测试: ${{ steps.deploy-params.outputs.skip_tests }}"
- name: 设置Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 登录私有镜像仓库
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: 提取镜像元数据
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=${{ steps.deploy-params.outputs.image_tag }}
- name: 构建并推送Docker镜像
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
- name: 部署到生产服务器
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }}
key: ${{ secrets.PRODUCTION_SSH_KEY }}
port: ${{ secrets.PRODUCTION_PORT || 22 }}
script: |
echo "🚀 开始部署 DjangoBlog..."
# 检查kubectl是否可用
if ! command -v kubectl &> /dev/null; then
echo "❌ 错误: kubectl 未安装或不在PATH中"
exit 1
fi
# 检查命名空间是否存在
if ! kubectl get namespace ${{ env.NAMESPACE }} &> /dev/null; then
echo "❌ 错误: 命名空间 ${{ env.NAMESPACE }} 不存在"
exit 1
fi
# 更新deployment镜像
echo "📦 更新deployment镜像为: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.deploy-params.outputs.image_tag }}"
kubectl set image deployment/djangoblog \
djangoblog=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.deploy-params.outputs.image_tag }} \
-n ${{ env.NAMESPACE }}
# 重启deployment
echo "🔄 重启deployment..."
kubectl -n ${{ env.NAMESPACE }} rollout restart deployment djangoblog
# 等待deployment完成
echo "⏳ 等待deployment完成..."
kubectl rollout status deployment/djangoblog -n ${{ env.NAMESPACE }} --timeout=300s
# 检查deployment状态
echo "✅ 检查deployment状态..."
kubectl get deployment djangoblog -n ${{ env.NAMESPACE }}
kubectl get pods -l app=djangoblog -n ${{ env.NAMESPACE }}
echo "🎉 部署完成!"
- name: 发送部署通知
if: always()
run: |
# 设置通知内容
if [ "${{ job.status }}" = "success" ]; then
TITLE="✅ DjangoBlog部署成功"
STATUS="成功"
else
TITLE="❌ DjangoBlog部署失败"
STATUS="失败"
fi
MESSAGE="部署状态: ${STATUS}
触发方式: ${{ steps.deploy-params.outputs.trigger_type }}
部署环境: ${{ steps.deploy-params.outputs.environment }}
镜像标签: ${{ steps.deploy-params.outputs.image_tag }}
提交者: ${{ github.actor }}
时间: $(date '+%Y-%m-%d %H:%M:%S')
查看详情: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# 发送Server酱通知
if [ -n "${{ secrets.SERVERCHAN_KEY }}" ]; then
echo "{\"title\": \"${TITLE}\", \"desp\": \"${MESSAGE}\"}" > /tmp/serverchan.json
curl --location "https://sctapi.ftqq.com/${{ secrets.SERVERCHAN_KEY }}.send" \
--header "Content-Type: application/json" \
--data @/tmp/serverchan.json \
--silent > /dev/null
rm -f /tmp/serverchan.json
echo "📱 部署通知已发送"
fi

@ -0,0 +1,371 @@
name: Django CI
on:
push:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
pull_request:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# 标准测试 - Python 3.10
- python-version: "3.10"
test-type: "standard"
database: "mysql"
elasticsearch: false
coverage: false
# 标准测试 - Python 3.11
- python-version: "3.11"
test-type: "standard"
database: "mysql"
elasticsearch: false
coverage: false
# 完整测试 - 包含ES和覆盖率
- python-version: "3.11"
test-type: "full"
database: "mysql"
elasticsearch: true
coverage: true
# Docker构建测试
- python-version: "3.11"
test-type: "docker"
database: "none"
elasticsearch: false
coverage: false
name: Test (${{ matrix.test-type }}, Python ${{ matrix.python-version }})
steps:
- name: Checkout代码
uses: actions/checkout@v4
- name: 设置测试信息
id: test-info
run: |
echo "test_name=${{ matrix.test-type }}-py${{ matrix.python-version }}" >> $GITHUB_OUTPUT
if [ "${{ matrix.test-type }}" = "docker" ]; then
echo "skip_python_setup=true" >> $GITHUB_OUTPUT
else
echo "skip_python_setup=false" >> $GITHUB_OUTPUT
fi
# MySQL数据库设置 (只有需要数据库的测试才执行)
- name: 启动MySQL数据库
if: matrix.database == '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
# Elasticsearch设置 (只有完整测试才执行)
- name: 配置系统参数 (ES)
if: matrix.elasticsearch == true
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
- name: 启动Elasticsearch
if: matrix.elasticsearch == true
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'
# Python环境设置 (Docker测试跳过)
- name: 设置Python ${{ matrix.python-version }}
if: steps.test-info.outputs.skip_python_setup == 'false'
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: 'requirements.txt'
# 多层缓存策略优化
- name: 缓存Python依赖
if: steps.test-info.outputs.skip_python_setup == 'false'
uses: actions/cache@v4
with:
path: |
~/.cache/pip
.pytest_cache
key: ${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}-
${{ runner.os }}-python-${{ matrix.python-version }}-
${{ runner.os }}-python-
# Django缓存优化 (测试数据库等)
- name: 缓存Django资源
if: matrix.test-type != 'docker'
uses: actions/cache@v4
with:
path: |
.coverage*
htmlcov/
.django_cache/
key: ${{ runner.os }}-django-${{ matrix.test-type }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-django-${{ matrix.test-type }}-
${{ runner.os }}-django-
- name: 安装Python依赖
if: steps.test-info.outputs.skip_python_setup == 'false'
run: |
echo "📦 安装Python依赖 (Python ${{ matrix.python-version }})"
python -m pip install --upgrade pip setuptools wheel
# 安装基础依赖
pip install -r requirements.txt
# 根据测试类型安装额外依赖
if [ "${{ matrix.coverage }}" = "true" ]; then
echo "📊 安装覆盖率工具"
pip install coverage[toml]
fi
# 验证关键依赖
echo "🔍 验证关键依赖安装"
python -c "import django; print(f'Django version: {django.get_version()}')"
python -c "import MySQLdb; print('MySQL client: OK')" || python -c "import pymysql; print('PyMySQL client: OK')"
if [ "${{ matrix.elasticsearch }}" = "true" ]; then
python -c "import elasticsearch; print('Elasticsearch client: OK')"
fi
# Django环境准备
- name: 准备Django环境
if: matrix.test-type != 'docker'
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }}
run: |
echo "🔧 准备Django测试环境"
# 等待数据库就绪
echo "⏳ 等待MySQL数据库启动..."
for i in {1..30}; do
if python -c "import MySQLdb; MySQLdb.connect(host='127.0.0.1', user='root', passwd='root', db='djangoblog')" 2>/dev/null; then
echo "✅ MySQL数据库连接成功"
break
fi
echo "🔄 等待数据库启动... ($i/30)"
sleep 2
done
# 等待Elasticsearch就绪 (如果启用)
if [ "${{ matrix.elasticsearch }}" = "true" ]; then
echo "⏳ 等待Elasticsearch启动..."
for i in {1..30}; do
if curl -s http://127.0.0.1:9200/_cluster/health | grep -q '"status":"green"\|"status":"yellow"'; then
echo "✅ Elasticsearch连接成功"
break
fi
echo "🔄 等待Elasticsearch启动... ($i/30)"
sleep 2
done
fi
# Django测试执行
- name: 执行数据库迁移
if: matrix.test-type != 'docker'
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }}
run: |
echo "🗄️ 执行数据库迁移"
# 检查迁移文件
echo "📋 检查待应用的迁移..."
python manage.py showmigrations
# 检查是否有未创建的迁移
python manage.py makemigrations --check --verbosity 2
# 执行迁移
python manage.py migrate --verbosity 2
echo "✅ 数据库迁移完成"
- name: 运行Django测试
if: matrix.test-type != 'docker'
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }}
run: |
echo "🧪 开始执行 ${{ matrix.test-type }} 测试 (Python ${{ matrix.python-version }})"
# 显示Django配置信息
python manage.py diffsettings | head -20
# 运行测试
if [ "${{ matrix.coverage }}" = "true" ]; then
echo "📊 运行测试并生成覆盖率报告"
coverage run --source='.' --omit='*/venv/*,*/migrations/*,*/tests/*,manage.py' manage.py test --verbosity=2
echo "📈 生成覆盖率报告"
coverage xml
coverage report --show-missing
coverage html
echo "📋 覆盖率统计:"
coverage report | tail -1
else
echo "🧪 运行标准测试"
python manage.py test --verbosity=2 --failfast
fi
echo "✅ 测试执行完成"
# 覆盖率报告上传 (只有完整测试才执行)
- name: 上传覆盖率到Codecov
if: matrix.coverage == true && success()
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
flags: unittests
name: codecov-${{ steps.test-info.outputs.test_name }}
fail_ci_if_error: false
verbose: true
- name: 上传覆盖率到Codecov (备用)
if: matrix.coverage == true && failure()
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
flags: unittests
name: codecov-${{ steps.test-info.outputs.test_name }}-fallback
fail_ci_if_error: false
verbose: true
# Docker构建测试
- name: 设置QEMU
if: matrix.test-type == 'docker'
uses: docker/setup-qemu-action@v3
- name: 设置Docker Buildx
if: matrix.test-type == 'docker'
uses: docker/setup-buildx-action@v3
- name: Docker构建测试
if: matrix.test-type == 'docker'
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: djangoblog/djangoblog:test-${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 收集测试工件 (失败时收集调试信息)
- name: 收集测试工件
if: failure() && matrix.test-type != 'docker'
run: |
echo "🔍 收集测试失败的调试信息"
# 收集Django日志
if [ -d "logs" ]; then
echo "📄 Django日志文件:"
ls -la logs/
if [ -f "logs/djangoblog.log" ]; then
echo "🔍 最新日志内容:"
tail -100 logs/djangoblog.log
fi
fi
# 显示数据库状态
echo "🗄️ 数据库连接状态:"
python -c "
try:
from django.db import connection
cursor = connection.cursor()
cursor.execute('SELECT VERSION()')
print(f'MySQL版本: {cursor.fetchone()[0]}')
cursor.execute('SHOW TABLES')
tables = cursor.fetchall()
print(f'数据库表数量: {len(tables)}')
except Exception as e:
print(f'数据库连接错误: {e}')
" || true
# Elasticsearch状态 (如果启用)
if [ "${{ matrix.elasticsearch }}" = "true" ]; then
echo "🔍 Elasticsearch状态:"
curl -s http://127.0.0.1:9200/_cluster/health?pretty || true
fi
# 上传测试工件
- name: 上传覆盖率HTML报告
if: matrix.coverage == true && always()
uses: actions/upload-artifact@v4
with:
name: coverage-report-${{ steps.test-info.outputs.test_name }}
path: htmlcov/
retention-days: 30
# 性能统计
- name: 测试性能统计
if: always() && matrix.test-type != 'docker'
run: |
echo "⚡ 测试性能统计:"
echo " 开始时间: $(date -d '@${{ job.started_at }}' '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo '未知')"
echo " 当前时间: $(date '+%Y-%m-%d %H:%M:%S')"
# 系统资源使用情况
echo "💻 系统资源:"
echo " CPU使用: $(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)%"
echo " 内存使用: $(free -h | awk '/^Mem:/ {printf "%.1f%%", $3/$2 * 100}')"
echo " 磁盘使用: $(df -h / | awk 'NR==2{printf "%s", $5}')"
# 测试结果汇总
- name: 测试完成总结
if: always()
run: |
echo "📋 ============ 测试执行总结 ============"
echo " 🏷️ 测试类型: ${{ matrix.test-type }}"
echo " 🐍 Python版本: ${{ matrix.python-version }}"
echo " 🗄️ 数据库: ${{ matrix.database }}"
echo " 🔍 Elasticsearch: ${{ matrix.elasticsearch }}"
echo " 📊 覆盖率: ${{ matrix.coverage }}"
echo " ⚡ 状态: ${{ job.status }}"
echo " 📅 完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "============================================"
# 根据测试结果显示不同消息
if [ "${{ job.status }}" = "success" ]; then
echo "🎉 测试执行成功!"
else
echo "❌ 测试执行失败,请检查上面的日志"
fi

@ -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@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
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,79 @@
# 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
# 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,96 @@
# 导入Django表单模块
from django import forms
# 导入Django默认的用户管理类
from django.contrib.auth.admin import UserAdmin
# 导入Django默认的用户修改表单
from django.contrib.auth.forms import UserChangeForm
# 导入用户名字段
from django.contrib.auth.forms import UsernameField
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# Register your models here.
# 导入自定义的用户模型
from .models import BlogUser
# 自定义用户创建表单继承自ModelForm
class BlogUserCreationForm(forms.ModelForm):
# 密码字段1使用密码输入控件
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):
# 从清理后的数据中获取两个密码字段的值
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方法但不立即提交到数据库
user = super().save(commit=False)
# 设置用户的密码(会自动进行哈希处理)
user.set_password(self.cleaned_data["password1"])
# 如果设置为立即提交
if commit:
# 设置用户来源为管理员站点
user.source = 'adminsite'
# 保存用户到数据库
user.save()
# 返回用户实例
return user
# 自定义用户修改表单继承自Django的UserChangeForm
class BlogUserChangeForm(UserChangeForm):
# 定义表单的元数据
class Meta:
# 指定表单对应的模型
model = BlogUser
# 包含所有字段
fields = '__all__'
# 指定字段类型映射
field_classes = {'username': UsernameField}
# 初始化方法
def __init__(self, *args, **kwargs):
# 调用父类的初始化方法
super().__init__(*args, **kwargs)
# 自定义用户管理类继承自Django的UserAdmin
class BlogUserAdmin(UserAdmin):
# 指定修改用户时使用的表单
form = BlogUserChangeForm
# 指定创建用户时使用的表单
add_form = BlogUserCreationForm
# 定义在管理列表页面显示的字段
list_display = (
'id',
'nickname',
'username',
'email',
'last_login',
'date_joined',
'source')
# 定义可以作为链接点击进入编辑页面的字段
list_display_links = ('id', 'username')
# 定义默认排序字段按id倒序
ordering = ('-id',)
# 定义可搜索的字段
search_fields = ('username', 'nickname', 'email')

@ -0,0 +1,8 @@
# 导入Django的应用配置基类
from django.apps import AppConfig
# 定义accounts应用的配置类
class AccountsConfig(AppConfig):
# 指定应用的Python路径Django内部使用的标识
name = 'accounts'

@ -0,0 +1,166 @@
# 导入Django表单模块
from django import forms
# 导入Django认证相关函数和表单
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
# 登录表单继承自Django的AuthenticationForm
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"})
# 注册表单继承自Django的UserCreationForm
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:
# 指定表单对应的模型使用get_user_model获取当前用户模型
model = get_user_model()
# 指定表单包含的字段
fields = ("username", "email")
# 忘记密码表单继承自forms.Form
class ForgetPasswordForm(forms.Form):
# 新密码字段
new_password1 = forms.CharField(
label=_("New password"), # 字段标签
widget=forms.PasswordInput( # 使用密码输入控件
attrs={
"class": "form-control", # CSS类名
'placeholder': _("New password") # 占位符文本
}
),
)
# 确认新密码字段
new_password2 = forms.CharField(
label="确认密码", # 字段标签(中文)
widget=forms.PasswordInput( # 使用密码输入控件
attrs={
"class": "form-control", # CSS类名
'placeholder': _("Confirm password") # 占位符文本
}
),
)
# 邮箱字段
email = forms.EmailField(
label='邮箱', # 字段标签(中文)
widget=forms.TextInput( # 使用文本输入控件
attrs={
'class': 'form-control', # CSS类名
'placeholder': _("Email") # 占位符文本
}
),
)
# 验证码字段
code = forms.CharField(
label=_('Code'), # 字段标签
widget=forms.TextInput( # 使用文本输入控件
attrs={
'class': 'form-control', # CSS类名
'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"))
# 使用Django的密码验证器验证密码强度
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():
# 如果邮箱不存在,抛出验证错误
# 注释说明:这里的报错提示可能会暴露邮箱是否注册,可根据安全需求修改
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
# 忘记密码验证码请求表单继承自forms.Form
class ForgetPasswordCodeForm(forms.Form):
# 邮箱字段
email = forms.EmailField(
label=_('Email'), # 字段标签
)

@ -0,0 +1,73 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 导入Django内置模块
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自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 密码字段使用Django的密码哈希存储
('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')),
# 管理员标志位决定能否登录admin后台
('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'], # 默认按ID倒序排列
'get_latest_by': 'id', # 指定最新记录的依据字段
},
# 指定模型管理器
managers=[
# 使用Django默认的用户管理器
('objects', django.contrib.auth.models.UserManager()),
],
),
]

@ -0,0 +1,60 @@
# 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'), # 依赖于accounts应用中的初始迁移
]
# 定义本迁移要执行的操作序列
operations = [
# 修改模型的元选项配置
migrations.AlterModelOptions(
name='bloguser', # 指定要修改的模型名称
options={
'get_latest_by': 'id', # 指定获取最新记录的依据字段为id
'ordering': ['-id'], # 设置默认排序为按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,56 @@
# 导入Django认证系统的抽象用户基类
from django.contrib.auth.models import AbstractUser
# 导入Django数据库模型
from django.db import models
# 导入URL反向解析函数
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.
# 自定义博客用户模型继承自Django的AbstractUser
class BlogUser(AbstractUser):
# 昵称字段最大长度100字符允许为空
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)
# 用户创建来源字段最大长度100字符允许为空
source = models.CharField(_('create source'), max_length=100, blank=True)
# 获取用户详情页的绝对URL不含域名
def get_absolute_url(self):
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
# 定义对象的字符串表示形式
def __str__(self):
return self.email
# 获取用户详情页的完整URL包含域名
def get_full_url(self):
# 获取当前站点的域名
site = get_current_site().domain
# 构建完整的URL
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
# 定义模型的元数据
class Meta:
# 默认按id倒序排列
ordering = ['-id']
# 单数形式的显示名称
verbose_name = _('user')
# 复数形式的显示名称(与单数相同)
verbose_name_plural = verbose_name
# 指定获取最新记录的依据字段
get_latest_by = 'id'

@ -0,0 +1,264 @@
# 导入Django测试相关模块
from django.test import Client, RequestFactory, TestCase
# 导入URL反向解析
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.
# 账户测试类继承自TestCase
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
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,52 @@
# 导入Django URL路由相关模块
from django.urls import path
from django.urls import re_path
# 导入当前应用的视图模块
from . import views
# 导入自定义登录表单
from .forms import LoginForm
# 定义应用的命名空间
app_name = "accounts"
# 定义URL模式列表
urlpatterns = [
# 登录URL使用正则表达式匹配
re_path(r'^login/$',
# 使用类视图,设置登录成功后跳转到首页
views.LoginView.as_view(success_url='/'),
name='login', # URL名称
# 传递额外参数指定认证表单为自定义的LoginForm
kwargs={'authentication_form': LoginForm}),
# 注册URL使用正则表达式匹配
re_path(r'^register/$',
# 使用类视图,设置注册成功后跳转到首页
views.RegisterView.as_view(success_url="/"),
name='register'), # URL名称
# 登出URL使用正则表达式匹配
re_path(r'^logout/$',
# 使用类视图
views.LogoutView.as_view(),
name='logout'), # URL名称
# 账户结果页面URL使用path匹配精确路径
path(r'account/result.html',
# 使用函数视图
views.account_result,
name='result'), # URL名称
# 忘记密码URL使用正则表达式匹配
re_path(r'^forget_password/$',
# 使用类视图
views.ForgetPasswordView.as_view(),
name='forget_password'), # URL名称
# 忘记密码验证码请求URL使用正则表达式匹配
re_path(r'^forget_password_code/$',
# 使用类视图
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'), # URL名称
]

@ -0,0 +1,42 @@
# 导入获取用户模型的函数
from django.contrib.auth import get_user_model
# 导入Django认证后端基类
from django.contrib.auth.backends import ModelBackend
# 自定义认证后端,允许使用邮箱或用户名登录
class EmailOrUsernameModelBackend(ModelBackend):
"""
允许使用用户名或邮箱登录
"""
# 用户认证方法
def authenticate(self, request, username=None, password=None, **kwargs):
# 判断输入的用户名是否包含@符号(即是否为邮箱格式)
if '@' in username:
# 如果是邮箱格式使用email字段进行查询
kwargs = {'email': username}
else:
# 如果不是邮箱格式使用username字段进行查询
kwargs = {'username': username}
try:
# 根据用户名或邮箱获取用户对象
user = get_user_model().objects.get(**kwargs)
# 验证密码是否正确
if user.check_password(password):
# 密码正确,返回用户对象
return user
# 捕获用户不存在的异常
except get_user_model().DoesNotExist:
# 用户不存在返回None
return None
# 根据用户ID获取用户对象的方法
def get_user(self, username):
try:
# 根据主键用户ID获取用户对象
return get_user_model().objects.get(pk=username)
# 捕获用户不存在的异常
except get_user_model().DoesNotExist:
# 用户不存在返回None
return None

@ -0,0 +1,66 @@
# 导入类型提示模块
import typing
# 导入时间间隔类
from datetime import timedelta
# 导入Django缓存模块
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
# 定义验证码的生存时间5分钟
_code_ttl = timedelta(minutes=5)
# 发送验证邮件的函数
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""发送重设密码验证码
Args:
to_mail: 接受邮箱
subject: 邮件主题
code: 验证码
"""
# 构建邮件HTML内容包含验证码信息
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"""
# 将验证码存入缓存设置过期时间为_code_ttl定义的秒数
cache.set(email, code, _code_ttl.seconds)
# 从缓存获取验证码的函数
def get_code(email: str) -> typing.Optional[str]:
"""获取code"""
# 从缓存中获取指定邮箱的验证码
return cache.get(email)

@ -0,0 +1,304 @@
# 导入日志模块
import logging
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# 导入Django配置
from django.conf import settings
# 导入Django认证相关模块
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
# 导入HTTP响应类
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
# 导入URL反向解析
from django.urls import reverse
# 导入方法装饰器
from django.utils.decorators import method_decorator
# 导入URL安全验证工具
from django.utils.http import url_has_allowed_host_and_scheme
# 导入基于类的视图
from django.views import View
# 导入视图装饰器
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, RedirectView
# 导入自定义工具函数
from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
# 导入当前应用的工具模块
from . import utils
# 导入自定义表单
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
# 导入用户模型
from .models import BlogUser
# 获取日志器
logger = logging.getLogger(__name__)
# Create your views here.
# 注册视图继承自FormView
class RegisterView(FormView):
# 指定使用的表单类
form_class = RegisterForm
# 指定模板文件
template_name = 'account/registration_form.html'
# 使用CSRF保护装饰器
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
# 调用父类的dispatch方法
return super(RegisterView, self).dispatch(*args, **kwargs)
# 表单验证通过后的处理
def form_valid(self, form):
# 检查表单是否有效
if form.is_valid():
# 保存用户但不提交到数据库commit=False
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'
# 获取结果页面的URL路径
path = reverse('account:result')
# 构建完整的验证URL
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
url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id)
# 重定向到结果页面
return HttpResponseRedirect(url)
else:
# 表单无效,重新渲染表单页面
return self.render_to_response({
'form': form
})
# 登出视图继承自RedirectView
class LogoutView(RedirectView):
# 设置登出后重定向的URL
url = '/login/'
# 使用永不缓存装饰器
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
return super(LogoutView, self).dispatch(request, *args, **kwargs)
# 处理GET请求
def get(self, request, *args, **kwargs):
# 执行登出操作
logout(request)
# 删除侧边栏缓存
delete_sidebar_cache()
# 调用父类的GET方法进行重定向
return super(LogoutView, self).get(request, *args, **kwargs)
# 登录视图继承自FormView
class LoginView(FormView):
# 指定使用的表单类
form_class = LoginForm
# 指定模板文件
template_name = 'account/login.html'
# 设置登录成功后的默认重定向URL
success_url = '/'
# 重定向字段名
redirect_field_name = REDIRECT_FIELD_NAME
# 登录会话有效期(一个月)
login_ttl = 2626560
# 使用多个装饰器保护视图
@method_decorator(sensitive_post_parameters('password')) # 敏感参数保护
@method_decorator(csrf_protect) # CSRF保护
@method_decorator(never_cache) # 永不缓存
def dispatch(self, request, *args, **kwargs):
return super(LoginView, self).dispatch(request, *args, **kwargs)
# 获取上下文数据
def get_context_data(self, **kwargs):
# 获取重定向URL
redirect_to = self.request.GET.get(self.redirect_field_name)
# 如果没有重定向URL使用默认首页
if redirect_to is None:
redirect_to = '/'
# 将重定向URL添加到上下文
kwargs['redirect_to'] = redirect_to
return super(LoginView, self).get_context_data(**kwargs)
# 表单验证通过后的处理
def form_valid(self, form):
# 创建认证表单实例
form = AuthenticationForm(data=self.request.POST, request=self.request)
# 检查表单是否有效
if form.is_valid():
# 删除侧边栏缓存
delete_sidebar_cache()
# 记录日志
logger.info(self.redirect_field_name)
# 执行登录操作
auth.login(self.request, form.get_user())
# 如果用户选择"记住我",设置会话过期时间
if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl)
# 调用父类的form_valid方法进行重定向
return super(LoginView, self).form_valid(form)
else:
# 表单无效,重新渲染表单页面
return self.render_to_response({
'form': form
})
# 获取成功登录后的重定向URL
def get_success_url(self):
# 从POST数据中获取重定向URL
redirect_to = self.request.POST.get(self.redirect_field_name)
# 验证重定向URL是否安全
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
# 如果不安全使用默认成功URL
redirect_to = self.success_url
return redirect_to
# 账户结果页面视图函数
def account_result(request):
# 获取结果类型
type = request.GET.get('type')
# 获取用户ID
id = request.GET.get('id')
# 获取用户对象如果不存在返回404
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('/')
# 忘记密码视图继承自FormView
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})
# 忘记密码验证码发送视图继承自View
class ForgetPasswordEmailCode(View):
# 处理POST请求
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,377 @@
# analyze_models_final.py
import os
import sys
import django
import json
def setup_django():
"""配置Django环境"""
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, current_dir)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings')
django.setup()
print("✅ Django环境配置成功")
def serialize_field_default(default_value):
"""序列化字段的默认值"""
if callable(default_value):
return f"<function {default_value.__name__}>"
elif hasattr(default_value, '__class__'):
return str(default_value)
else:
return default_value
def comprehensive_model_analysis():
"""全面分析Django模型"""
from django.apps import apps
from django.db import models
all_models = apps.get_models()
print("🔍 Django Blog 数据模型全面分析")
print("=" * 80)
# 按应用分组
apps_data = {}
for model in all_models:
app_label = model._meta.app_label
if app_label not in apps_data:
apps_data[app_label] = []
model_info = {
'name': model.__name__,
'db_table': model._meta.db_table,
'fields': [],
'relationships': [],
'meta': {
'managed': model._meta.managed,
'abstract': model._meta.abstract,
}
}
# 分析字段
for field in model._meta.fields:
try:
field_info = {
'name': field.name,
'type': field.get_internal_type(),
'primary_key': getattr(field, 'primary_key', False),
'unique': getattr(field, 'unique', False),
'null': getattr(field, 'null', False),
'blank': getattr(field, 'blank', False),
'default': serialize_field_default(field.default) if hasattr(field,
'default') and field.default != models.NOT_PROVIDED else None,
'max_length': getattr(field, 'max_length', None),
'related_model': field.related_model.__name__ if field.related_model else None,
'relationship': None
}
# 确定关系类型
if field.is_relation:
if field.many_to_one:
field_info['relationship'] = 'ForeignKey'
elif field.one_to_one:
field_info['relationship'] = 'OneToOneField'
model_info['fields'].append(field_info)
# 添加到关系列表
if field.related_model:
model_info['relationships'].append({
'type': field_info['relationship'],
'target': field.related_model.__name__,
'field': field.name
})
except Exception as e:
print(f"⚠️ 分析字段 {field.name} 时出错: {e}")
continue
# 分析多对多字段
for field in model._meta.many_to_many:
try:
field_info = {
'name': field.name,
'type': 'ManyToManyField',
'primary_key': False,
'unique': False,
'null': getattr(field, 'null', False),
'blank': getattr(field, 'blank', False),
'related_model': field.related_model.__name__ if field.related_model else None,
}
model_info['fields'].append(field_info)
if field.related_model:
model_info['relationships'].append({
'type': 'ManyToManyField',
'target': field.related_model.__name__,
'field': field.name
})
except Exception as e:
print(f"⚠️ 分析多对多字段 {field.name} 时出错: {e}")
continue
apps_data[app_label].append(model_info)
return apps_data
def generate_markdown_documentation(apps_data):
"""生成Markdown格式的详细文档"""
content = [
"# Django Blog 数据模型设计文档",
"",
"## 1. 系统概述",
"",
"本文档详细描述了Django Blog项目的数据库模型设计。",
"",
"## 2. 模型统计",
"",
f"系统共包含 **{sum(len(models) for models in apps_data.values())}** 个数据模型,分布在 **{len(apps_data)}** 个应用中。",
"",
"### 应用列表",
""
]
# 应用统计
for app_label, models in apps_data.items():
content.append(f"- **{app_label}**: {len(models)} 个模型")
content.extend([
"",
"## 3. 核心实体关系图",
"",
"```mermaid",
"erDiagram",
""
])
# 生成Mermaid ER图
for app_label, models in apps_data.items():
for model_info in models:
content.append(f" {model_info['name']} {{")
# 显示关键字段
for field in model_info['fields'][:5]: # 只显示前5个字段避免过于复杂
if field.get('primary_key'):
content.append(f" {field['type']} {field['name']} PK")
elif field['type'] in ['CharField', 'DateTimeField', 'BooleanField', 'TextField']:
content.append(f" {field['type']} {field['name']}")
content.append(" }")
content.append("")
# 添加主要关系
relationships_added = set()
for app_label, models in apps_data.items():
for model_info in models:
for rel in model_info['relationships']:
if rel.get('type') == 'ForeignKey':
rel_str = f" {model_info['name']} ||--o{{ {rel['target']} : \"{rel['field']}\""
if rel_str not in relationships_added:
relationships_added.add(rel_str)
content.append(rel_str)
content.extend([
"```",
"",
"## 4. 详细数据字典",
""
])
# 每个模型的详细说明
for app_label, models in apps_data.items():
content.append(f"### {app_label} 应用")
content.append("")
for model_info in models:
content.append(f"#### {model_info['name']}")
content.append("")
content.append(f"- **数据库表**: `{model_info['db_table']}`")
content.append(f"- **管理方式**: {'Django管理' if model_info['meta']['managed'] else '非Django管理'}")
content.append("")
content.append("| 字段名 | 数据类型 | 约束 | 关联模型 | 说明 |")
content.append("|--------|----------|------|----------|------|")
for field in model_info['fields']:
try:
constraints = []
if field.get('primary_key'):
constraints.append("PK")
if field.get('unique'):
constraints.append("UNIQUE")
if field.get('null'):
constraints.append("NULL")
if field.get('blank'):
constraints.append("BLANK")
constraint_str = ", ".join(constraints) if constraints else ""
related_model = field.get('related_model') or ""
field_type = field.get('relationship') or field.get('type', 'Unknown')
content.append(f"| {field['name']} | {field_type} | {constraint_str} | {related_model} | |")
except Exception as e:
content.append(f"| {field.get('name', 'Unknown')} | Error | | | 字段分析错误 |")
content.append("")
# 模型关系分析
content.extend([
"## 5. 模型关系分析",
"",
"### 5.1 一对一关系 (OneToOne)",
""
])
one_to_one_rels = []
for app_label, models in apps_data.items():
for model_info in models:
for rel in model_info['relationships']:
if rel.get('type') == 'OneToOneField':
one_to_one_rels.append(f"- `{model_info['name']}.{rel['field']}` → `{rel['target']}`")
if one_to_one_rels:
content.extend(one_to_one_rels)
else:
content.append("无一对一关系")
content.extend([
"",
"### 5.2 一对多关系 (ForeignKey)",
""
])
foreign_key_rels = []
for app_label, models in apps_data.items():
for model_info in models:
for rel in model_info['relationships']:
if rel.get('type') == 'ForeignKey':
foreign_key_rels.append(f"- `{model_info['name']}.{rel['field']}` → `{rel['target']}`")
if foreign_key_rels:
content.extend(foreign_key_rels)
else:
content.append("无一对多关系")
content.extend([
"",
"### 5.3 多对多关系 (ManyToMany)",
""
])
many_to_many_rels = []
for app_label, models in apps_data.items():
for model_info in models:
for rel in model_info['relationships']:
if rel.get('type') == 'ManyToManyField':
many_to_many_rels.append(f"- `{model_info['name']}.{rel['field']}` ↔ `{rel['target']}`")
if many_to_many_rels:
content.extend(many_to_many_rels)
else:
content.append("无多对多关系")
content.extend([
"",
"## 6. 索引设计",
"",
"### 6.1 主键索引",
"- 所有模型默认使用自增ID作为主键",
"",
"### 6.2 外键索引",
"- Django自动为所有ForeignKey和OneToOneField创建索引",
"",
"### 6.3 业务索引",
"- 需要根据具体查询需求添加数据库索引",
"",
"## 7. 总结",
"",
"本文档详细描述了Django Blog项目的数据库模型设计包括",
"- 完整的模型字段定义",
"- 模型间的关联关系",
"- 数据表结构设计",
"- 索引设计建议",
"",
"该设计支持博客系统的核心功能,包括用户管理、文章发布、评论系统等。"
])
return "\n".join(content)
def main():
"""主函数"""
print("🚀 开始Django Blog数据模型分析...")
try:
# 设置Django环境
setup_django()
# 执行分析
print("📊 分析数据模型中...")
apps_data = comprehensive_model_analysis()
# 生成Markdown文档
print("📝 生成文档中...")
markdown_content = generate_markdown_documentation(apps_data)
# 保存文档
with open('数据模型设计文档.md', 'w', encoding='utf-8') as f:
f.write(markdown_content)
print("✅ 数据模型设计文档已生成: 数据模型设计文档.md")
# 同时保存JSON数据用于其他用途修复序列化问题
try:
# 创建一个可序列化的版本
serializable_data = {}
for app_label, models in apps_data.items():
serializable_data[app_label] = []
for model in models:
serializable_model = model.copy()
serializable_model['fields'] = []
for field in model['fields']:
serializable_field = field.copy()
# 确保所有值都是可序列化的
for key, value in serializable_field.items():
if callable(value):
serializable_field[key] = f"<function {value.__name__}>"
serializable_model['fields'].append(serializable_field)
serializable_data[app_label].append(serializable_model)
with open('models_analysis.json', 'w', encoding='utf-8') as f:
json.dump(serializable_data, f, indent=2, ensure_ascii=False)
print("✅ 模型分析数据已保存: models_analysis.json")
except Exception as json_error:
print(f"⚠️ JSON保存失败但文档已生成: {json_error}")
# 打印简要统计
total_models = sum(len(models) for models in apps_data.values())
total_fields = sum(sum(len(model['fields']) for model in models) for models in apps_data.values())
total_relationships = sum(sum(len(model['relationships']) for model in models) for models in apps_data.values())
print(f"\n📊 分析统计:")
print(f" 应用数量: {len(apps_data)}")
print(f" 模型数量: {total_models}")
print(f" 字段数量: {total_fields}")
print(f" 关系数量: {total_relationships}")
# 显示发现的模型
print(f"\n📁 发现的模型:")
for app_label, models in apps_data.items():
print(f" {app_label}:")
for model in models:
print(f" - {model['name']} ({len(model['fields'])} 字段)")
print(f"\n🎉 分析完成!")
print("📄 生成的文档: 数据模型设计文档.md")
except Exception as e:
print(f"❌ 分析过程中出现错误: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

@ -0,0 +1,165 @@
# 导入Django表单模块
from django import forms
# 导入Django管理员模块
from django.contrib import admin
# 导入获取用户模型的函数
from django.contrib.auth import get_user_model
# 导入URL反向解析
from django.urls import reverse
# 导入HTML格式化工具
from django.utils.html import format_html
# 导入国际化翻译函数
from django.utils.translation import gettext_lazy as _
# Register your models here.
# 导入博客模型
from .models import Article, Category, Tag, Links, SideBar, BlogSettings
# 自定义文章表单
class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
# 定义表单的元数据
class Meta:
# 指定表单对应的模型
model = Article
# 包含所有字段
fields = '__all__'
# 发布文章的管理动作函数
def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p')
# 将文章设为草稿的管理动作函数
def draft_article(modeladmin, request, queryset):
queryset.update(status='d')
# 关闭文章评论的管理动作函数
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c')
# 打开文章评论的管理动作函数
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
# 设置管理动作的显示名称
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
open_article_commentstatus.short_description = _('Open article comments')
# 文章模型的管理类
class ArticlelAdmin(admin.ModelAdmin):
# 每页显示20条记录
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')
# 日期层次导航
date_hierarchy = 'creation_time'
# 水平选择器字段
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]
# 使用原始ID字段显示搜索框而不是下拉选择
raw_id_fields = ('author', 'category',)
# 自定义方法:显示分类链接
def link_to_category(self, obj):
# 获取分类模型的app和model信息
info = (obj.category._meta.app_label, obj.category._meta.model_name)
# 生成分类编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
# 返回HTML链接
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
# 设置自定义方法的显示名称
link_to_category.short_description = _('category')
# 重写获取表单的方法
def get_form(self, request, obj=None, **kwargs):
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
# 限制作者字段只能选择超级用户
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
return form
# 重写保存模型的方法
def save_model(self, request, obj, form, change):
super(ArticlelAdmin, self).save_model(request, obj, form, change)
# 重写获取站点查看URL的方法
def get_view_on_site_url(self, obj=None):
if obj:
# 返回文章的完整URL
url = obj.get_full_url()
return url
else:
# 如果没有指定对象,返回站点域名
from djangoblog.utils import get_current_site
site = get_current_site().domain
return site
# 标签模型的管理类
class TagAdmin(admin.ModelAdmin):
# 排除的字段
exclude = ('slug', 'last_modify_time', 'creation_time')
# 分类模型的管理类
class CategoryAdmin(admin.ModelAdmin):
# 列表页显示的字段
list_display = ('name', 'parent_category', 'index')
# 排除的字段
exclude = ('slug', 'last_modify_time', 'creation_time')
# 链接模型的管理类
class LinksAdmin(admin.ModelAdmin):
# 排除的字段
exclude = ('last_modify_time', 'creation_time')
# 侧边栏模型的管理类
class SideBarAdmin(admin.ModelAdmin):
# 列表页显示的字段
list_display = ('name', 'content', 'is_enable', 'sequence')
# 排除的字段
exclude = ('last_modify_time', 'creation_time')
# 博客设置模型的管理类
class BlogSettingsAdmin(admin.ModelAdmin):
pass

@ -0,0 +1,8 @@
# 导入Django应用配置基类
from django.apps import AppConfig
# 定义blog应用的配置类
class BlogConfig(AppConfig):
# 指定应用的Python路径Django内部使用的标识
name = 'blog'

@ -0,0 +1,57 @@
# 导入日志模块
import logging
# 导入Django时区工具
from django.utils import timezone
# 导入自定义工具函数
from djangoblog.utils import cache, get_blog_setting
# 导入模型类
from .models import Category, Article
# 获取日志记录器
logger = logging.getLogger(__name__)
# SEO上下文处理器函数
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广告
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, # Google广告代码
'SITE_SEO_DESCRIPTION': setting.site_seo_description, # 网站SEO描述
'SITE_DESCRIPTION': setting.site_description, # 网站描述
'SITE_KEYWORDS': setting.site_keywords, # 网站关键词
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', # 网站基础URL
'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, # 评论是否需要审核
}
# 将数据存入缓存有效期10小时
cache.set(key, value, 60 * 60 * 10)
# 返回上下文数据
return value

@ -0,0 +1,245 @@
# 导入时间模块
import time
# 导入Elasticsearch客户端
import elasticsearch.client
# 导入Django配置
from django.conf import settings
# 导入Elasticsearch DSL相关类
from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean
from elasticsearch_dsl.connections import connections
# 导入博客文章模型
from blog.models import Article
# 检查是否启用了Elasticsearch
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
if ELASTICSEARCH_ENABLED:
# 创建Elasticsearch连接
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
# 创建Elasticsearch客户端实例
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
from elasticsearch.client import IngestClient
# 创建Ingest客户端用于管道处理
c = IngestClient(es)
try:
# 检查geoip管道是否存在
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
# 如果不存在则创建geoip管道
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
{
"geoip" : {
"field" : "ip"
}
}
]
}''')
# 定义GeoIP内部文档类
class GeoIp(InnerDoc):
continent_name = Keyword() # 大洲名称
country_iso_code = Keyword() # 国家ISO代码
country_name = Keyword() # 国家名称
location = GeoPoint() # 地理位置坐标
# 定义用户代理浏览器内部文档类
class UserAgentBrowser(InnerDoc):
Family = Keyword() # 浏览器家族
Version = Keyword() # 浏览器版本
# 定义用户代理操作系统内部文档类继承自UserAgentBrowser
class UserAgentOS(UserAgentBrowser):
pass
# 定义用户代理设备内部文档类
class UserAgentDevice(InnerDoc):
Family = Keyword() # 设备家族
Brand = Keyword() # 设备品牌
Model = Keyword() # 设备型号
# 定义用户代理内部文档类
class UserAgent(InnerDoc):
browser = Object(UserAgentBrowser, required=False) # 浏览器信息
os = Object(UserAgentOS, required=False) # 操作系统信息
device = Object(UserAgentDevice, required=False) # 设备信息
string = Text() # 原始用户代理字符串
is_bot = Boolean() # 是否为机器人
# 定义耗时记录文档类
class ElapsedTimeDocument(Document):
url = Keyword() # 请求URL
time_taken = Long() # 耗时(毫秒)
log_datetime = Date() # 日志时间
ip = Keyword() # IP地址
geoip = Object(GeoIp, required=False) # GeoIP信息
useragent = Object(UserAgent, required=False) # 用户代理信息
class Index:
name = 'performance' # 索引名称
settings = {
"number_of_shards": 1, # 分片数量
"number_of_replicas": 0 # 副本数量
}
class Meta:
doc_type = 'ElapsedTime' # 文档类型
# 耗时记录文档管理器类
class ElaspedTimeDocumentManager:
@staticmethod
def build_index():
from elasticsearch import Elasticsearch
client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 检查索引是否存在
res = client.indices.exists(index="performance")
if not res:
# 如果不存在则初始化索引
ElapsedTimeDocument.init()
@staticmethod
def delete_index():
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 删除索引忽略400和404错误
es.indices.delete(index='performance', ignore=[400, 404])
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
# 构建索引
ElaspedTimeDocumentManager.build_index()
# 创建用户代理对象
ua = UserAgent()
ua.browser = UserAgentBrowser()
ua.browser.Family = useragent.browser.family # 浏览器家族
ua.browser.Version = useragent.browser.version_string # 浏览器版本
ua.os = UserAgentOS()
ua.os.Family = useragent.os.family # 操作系统家族
ua.os.Version = useragent.os.version_string # 操作系统版本
ua.device = UserAgentDevice()
ua.device.Family = useragent.device.family # 设备家族
ua.device.Brand = useragent.device.brand # 设备品牌
ua.device.Model = useragent.device.model # 设备型号
ua.string = useragent.ua_string # 原始用户代理字符串
ua.is_bot = useragent.is_bot # 是否为机器人
# 创建文档对象使用时间戳作为ID
doc = ElapsedTimeDocument(
meta={
'id': int(
round(
time.time() *
1000)) # 使用当前时间戳作为文档ID
},
url=url,
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua, ip=ip)
# 保存文档使用geoip管道处理
doc.save(pipeline="geoip")
# 定义文章文档类
class ArticleDocument(Document):
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 正文使用IK分词器
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 标题使用IK分词器
author = Object(properties={
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 作者昵称
'id': Integer() # 作者ID
})
category = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 分类名称
'id': Integer() # 分类ID
})
tags = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 标签名称
'id': Integer() # 标签ID
})
pub_time = Date() # 发布时间
status = Text() # 文章状态
comment_status = Text() # 评论状态
type = Text() # 文章类型
views = Integer() # 浏览量
article_order = Integer() # 文章排序
class Index:
name = 'blog' # 索引名称
settings = {
"number_of_shards": 1, # 分片数量
"number_of_replicas": 0 # 副本数量
}
class Meta:
doc_type = 'Article' # 文档类型
# 文章文档管理器类
class ArticleDocumentManager():
def __init__(self):
self.create_index()
def create_index(self):
# 创建文章索引
ArticleDocument.init()
def delete_index(self):
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 删除索引忽略400和404错误
es.indices.delete(index='blog', ignore=[400, 404])
def convert_to_doc(self, articles):
# 将文章模型转换为文档对象
return [
ArticleDocument(
meta={
'id': article.id}, # 使用文章ID作为文档ID
body=article.body,
title=article.title,
author={
'nickname': article.author.username,
'id': article.author.id},
category={
'name': article.category.name,
'id': article.category.id},
tags=[
{
'name': t.name,
'id': t.id} for t in article.tags.all()], # 转换标签列表
pub_time=article.pub_time,
status=article.status,
comment_status=article.comment_status,
type=article.type,
views=article.views,
article_order=article.article_order) for article in articles]
def rebuild(self, articles=None):
# 重建索引
ArticleDocument.init()
articles = articles if articles else Article.objects.all() # 如果没有指定文章,则获取所有文章
docs = self.convert_to_doc(articles) # 转换为文档对象
for doc in docs:
doc.save() # 保存文档
def update_docs(self, docs):
# 更新文档
for doc in docs:
doc.save()

@ -0,0 +1,31 @@
# 导入日志模块
import logging
# 导入Django表单相关模块
from django import forms
# 导入Haystack搜索表单基类
from haystack.forms import SearchForm
# 获取日志记录器
logger = logging.getLogger(__name__)
# 自定义博客搜索表单继承自Haystack的SearchForm
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,29 @@
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入Elasticsearch相关文档和管理器
from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
ELASTICSEARCH_ENABLED
# TODO 参数化
# 自定义管理命令类,用于构建搜索索引
class Command(BaseCommand):
# 命令的帮助信息
help = 'build search index'
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 检查Elasticsearch是否启用
if ELASTICSEARCH_ENABLED:
# 构建耗时文档的索引
ElaspedTimeDocumentManager.build_index()
# 创建耗时文档管理器实例并初始化
manager = ElapsedTimeDocument()
manager.init()
# 创建文章文档管理器实例
manager = ArticleDocumentManager()
# 删除现有索引
manager.delete_index()
# 重新构建索引
manager.rebuild()

@ -0,0 +1,20 @@
# 导入Django管理命令基类
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,18 @@
# 导入Django管理命令基类
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,60 @@
# 导入获取用户模型的函数
from django.contrib.auth import get_user_model
# 导入密码哈希函数
from django.contrib.auth.hashers import make_password
# 导入Django管理命令基类
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()
# 循环创建20篇测试文章
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,69 @@
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入蜘蛛通知工具
from djangoblog.spider_notify import SpiderNotify
# 导入获取当前站点工具
from djangoblog.utils import get_current_site
# 导入博客模型
from blog.models import Article, Tag, Category
# 获取当前站点域名
site = get_current_site().domain
# 自定义管理命令类用于通知百度搜索引擎URL更新
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') # 帮助信息
# 根据相对路径获取完整URL的方法
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)
# 初始化URL列表
urls = []
# 如果类型是文章或全部获取所有已发布文章的URL
if type == 'article' or type == 'all':
for article in Article.objects.filter(status='p'): # 只获取已发布的文章
urls.append(article.get_full_url())
# 如果类型是标签或全部获取所有标签的URL
if type == 'tag' or type == 'all':
for tag in Tag.objects.all():
url = tag.get_absolute_url() # 获取标签的相对URL
urls.append(self.get_full_url(url)) # 转换为完整URL并添加到列表
# 如果类型是分类或全部获取所有分类的URL
if type == 'category' or type == 'all':
for category in Category.objects.all():
url = category.get_absolute_url() # 获取分类的相对URL
urls.append(self.get_full_url(url)) # 转换为完整URL并添加到列表
# 输出开始通知的信息显示URL数量
self.stdout.write(
self.style.SUCCESS(
'start notify %d urls' %
len(urls)))
# 调用百度蜘蛛通知接口批量提交URL
SpiderNotify.baidu_notify(urls)
# 输出完成通知的信息
self.stdout.write(self.style.SUCCESS('finish notify'))

@ -0,0 +1,82 @@
# 导入requests库用于HTTP请求
import requests
# 导入Django管理命令基类
from django.core.management.base import BaseCommand
# 导入静态文件URL处理
from django.templatetags.static import static
# 导入保存用户头像的工具函数
from djangoblog.utils import save_user_avatar
# 导入OAuth用户模型
from oauth.models import OAuthUser
# 导入根据类型获取OAuth管理器的函数
from oauth.oauthmanager import get_manager_by_type
# 自定义管理命令类,用于同步用户头像
class Command(BaseCommand):
# 命令的帮助信息
help = 'sync user avatar'
# 测试图片URL是否可访问的方法
def test_picture(self, url):
try:
# 发送HTTP GET请求测试图片URL设置2秒超时
if requests.get(url, timeout=2).status_code == 200:
# 返回200状态码表示图片可访问
return True
except:
# 发生任何异常超时、连接错误等都返回False
pass
# 命令的主要处理逻辑
def handle(self, *args, **options):
# 获取静态文件基础URL
static_url = static("../")
# 获取所有OAuth用户
users = OAuthUser.objects.all()
# 输出开始同步的用户数量
self.stdout.write(f'开始同步{len(users)}个用户头像')
# 遍历每个用户
for u in users:
# 输出开始同步当前用户的信息
self.stdout.write(f'开始同步:{u.nickname}')
# 获取用户当前的头像URL
url = u.picture
# 如果当前有头像URL
if url:
# 检查URL是否以静态文件路径开头
if url.startswith(static_url):
# 测试静态图片是否可访问
if self.test_picture(url):
# 如果可以访问,跳过此用户,继续下一个
continue
else:
# 如果静态图片不可访问,检查是否有元数据
if u.metadata:
# 根据用户类型获取对应的OAuth管理器
manage = get_manager_by_type(u.type)
# 从元数据中获取新的头像URL
url = manage.get_picture(u.metadata)
# 保存新头像并返回本地URL
url = save_user_avatar(url)
else:
# 没有元数据,使用默认头像
url = static('blog/img/avatar.png')
else:
# 如果不是静态文件URL直接保存头像并返回本地URL
url = save_user_avatar(url)
else:
# 如果没有头像URL使用默认头像
url = static('blog/img/avatar.png')
# 如果成功获取到头像URL
if url:
# 输出同步完成的信息
self.stdout.write(
f'结束同步:{u.nickname}.url:{url}')
# 更新用户的头像URL
u.picture = url
# 保存用户信息
u.save()
# 输出同步结束信息
self.stdout.write('结束同步')

@ -0,0 +1,65 @@
# 导入日志模块
import logging
# 导入时间模块
import time
# 导入IP获取工具
from ipware import get_client_ip
# 导入用户代理解析工具
from user_agents import parse
# 导入Elasticsearch相关配置和管理器
from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
# 获取日志记录器
logger = logging.getLogger(__name__)
# 在线中间件类,用于记录页面渲染时间和用户访问信息
class OnlineMiddleware(object):
def __init__(self, get_response=None):
# 初始化中间件保存get_response函数
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地址
ip, _ = get_client_ip(request)
# 解析用户代理信息
user_agent = parse(http_user_agent)
# 检查响应是否为流式响应(非流式响应才能处理内容)
if not response.streaming:
try:
# 计算页面渲染耗时
cast_time = time.time() - start_time
# 如果启用了Elasticsearch
if ELASTICSEARCH_ENABLED:
# 将耗时转换为毫秒并保留两位小数
time_taken = round((cast_time) * 1000, 2)
# 获取请求的URL路径
url = request.path
# 导入Django时区工具
from django.utils import timezone
# 创建耗时记录文档
ElaspedTimeDocumentManager.create(
url=url, # 请求URL
time_taken=time_taken, # 耗时(毫秒)
log_datetime=timezone.now(), # 当前时间
useragent=user_agent, # 用户代理信息
ip=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,227 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 导入Django设置
from django.conf import settings
# 导入数据库迁移相关模块
from django.db import migrations, models
import django.db.models.deletion
# 导入时间工具
import django.utils.timezone
# 导入Markdown编辑器字段
import mdeditor.fields
class Migration(migrations.Migration):
# 标记为初始迁移
initial = True
# 声明依赖的迁移
dependencies = [
# 依赖可交换的用户模型
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# 定义迁移操作序列
operations = [
# 创建网站配置模型
migrations.CreateModel(
name='BlogSettings',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 网站名称字段
('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')),
# 网站描述字段
('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')),
# 网站SEO描述字段
('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
# 网站关键词字段
('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
# 文章摘要长度字段
('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
# 侧边栏文章数量字段
('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
# 侧边栏评论数量字段
('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
# 文章页面评论数量字段
('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
# 是否显示谷歌广告字段
('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
# 谷歌广告代码字段
('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
# 是否开启网站评论功能字段
('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
# 备案号字段
('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
# 网站统计代码字段
('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
# 是否显示公安备案字段
('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
# 公安备案号字段
('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
],
options={
# 模型显示名称(单数)
'verbose_name': '网站配置',
# 模型显示名称(复数)
'verbose_name_plural': '网站配置',
},
),
# 创建友情链接模型
migrations.CreateModel(
name='Links',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 链接名称字段,唯一
('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
# 链接地址字段
('link', models.URLField(verbose_name='链接地址')),
# 排序字段,唯一
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
# 是否启用字段
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
# 显示类型字段,使用选择项
('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
# 模型显示名称(单数)
'verbose_name': '友情链接',
# 模型显示名称(复数)
'verbose_name_plural': '友情链接',
# 默认按排序字段升序排列
'ordering': ['sequence'],
},
),
# 创建侧边栏模型
migrations.CreateModel(
name='SideBar',
fields=[
# 主键ID自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 标题字段
('name', models.CharField(max_length=100, verbose_name='标题')),
# 内容字段
('content', models.TextField(verbose_name='内容')),
# 排序字段,唯一
('sequence', models.IntegerField(unique=True, verbose_name='排序')),
# 是否启用字段
('is_enable', models.BooleanField(default=True, verbose_name='是否启用')),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
options={
# 模型显示名称(单数)
'verbose_name': '侧边栏',
# 模型显示名称(复数)
'verbose_name_plural': '侧边栏',
# 默认按排序字段升序排列
'ordering': ['sequence'],
},
),
# 创建标签模型
migrations.CreateModel(
name='Tag',
fields=[
# 主键ID自增AutoField
('id', models.AutoField(primary_key=True, serialize=False)),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 标签名称字段,唯一
('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')),
# 标签slug字段用于URL
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
],
options={
# 模型显示名称(单数)
'verbose_name': '标签',
# 模型显示名称(复数)
'verbose_name_plural': '标签',
# 默认按名称升序排列
'ordering': ['name'],
},
),
# 创建分类模型
migrations.CreateModel(
name='Category',
fields=[
# 主键ID自增AutoField
('id', models.AutoField(primary_key=True, serialize=False)),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 分类名称字段,唯一
('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')),
# 分类slug字段用于URL
('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
# 权重排序字段,越大越靠前
('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
# 父级分类外键,支持多级分类
('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
],
options={
# 模型显示名称(单数)
'verbose_name': '分类',
# 模型显示名称(复数)
'verbose_name_plural': '分类',
# 默认按权重倒序排列
'ordering': ['-index'],
},
),
# 创建文章模型
migrations.CreateModel(
name='Article',
fields=[
# 主键ID自增AutoField
('id', models.AutoField(primary_key=True, serialize=False)),
# 创建时间字段
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间字段
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 文章标题字段,唯一
('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
# 文章正文字段使用Markdown编辑器
('body', mdeditor.fields.MDTextField(verbose_name='正文')),
# 发布时间字段
('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
# 文章状态字段,使用选择项
('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
# 评论状态字段,使用选择项
('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
# 类型字段,使用选择项(文章或页面)
('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')),
# 浏览量字段,正整数
('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
# 文章排序字段,数字越大越靠前
('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
# 是否显示TOC目录字段
('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')),
# 作者外键,关联用户模型
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
# 分类外键
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')),
# 标签多对多关系
('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')),
],
options={
# 模型显示名称(单数)
'verbose_name': '文章',
# 模型显示名称(复数)
'verbose_name_plural': '文章',
# 默认按文章排序倒序、发布时间倒序排列
'ordering': ['-article_order', '-pub_time'],
# 指定获取最新记录的依据字段
'get_latest_by': 'id',
},
),
]

@ -0,0 +1,27 @@
# Generated by Django 4.1.7 on 2023-03-29 06:08
from django.db import migrations, models
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
('blog', '0001_initial'), # 依赖于blog应用的初始迁移
]
# 定义本迁移要执行的操作序列
operations = [
# 向BlogSettings模型添加新字段公共尾部
migrations.AddField(
model_name='blogsettings', # 指定要修改的模型名称
name='global_footer', # 新字段名称
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'), # 文本字段,允许为空,默认值为空字符串
),
# 向BlogSettings模型添加新字段公共头部
migrations.AddField(
model_name='blogsettings', # 指定要修改的模型名称
name='global_header', # 新字段名称
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'), # 文本字段,允许为空,默认值为空字符串
),
]

@ -0,0 +1,20 @@
# Generated by Django 4.2.1 on 2023-05-09 07:45
from django.db import migrations, models
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
('blog', '0002_blogsettings_global_footer_and_more'), # 依赖于blog应用的第二个迁移
]
# 定义本迁移要执行的操作序列
operations = [
# 向BlogSettings模型添加新字段评论审核开关
migrations.AddField(
model_name='blogsettings', # 指定要修改的模型名称
name='comment_need_review', # 新字段名称
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'), # 布尔字段默认值为False不需要审核
),
]

@ -0,0 +1,32 @@
# Generated by Django 4.2.1 on 2023-05-09 07:51
from django.db import migrations
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
('blog', '0003_blogsettings_comment_need_review'), # 依赖于blog应用的第三个迁移
]
# 定义本迁移要执行的操作序列
operations = [
# 重命名字段将analyticscode改为analytics_code
migrations.RenameField(
model_name='blogsettings', # 指定要修改的模型名称
old_name='analyticscode', # 原字段名称
new_name='analytics_code', # 新字段名称
),
# 重命名字段将beiancode改为beian_code
migrations.RenameField(
model_name='blogsettings', # 指定要修改的模型名称
old_name='beiancode', # 原字段名称
new_name='beian_code', # 新字段名称
),
# 重命名字段将sitename改为site_name
migrations.RenameField(
model_name='blogsettings', # 指定要修改的模型名称
old_name='sitename', # 原字段名称
new_name='site_name', # 新字段名称
),
]

@ -0,0 +1,363 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
# 依赖可交换的用户模型
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
# 依赖于blog应用的第四个迁移
('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
]
# 定义本迁移要执行的操作序列
operations = [
# 修改Article模型的元选项国际化
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
),
# 修改Category模型的元选项国际化
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
),
# 修改Links模型的元选项国际化
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
),
# 修改Sidebar模型的元选项国际化
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
# 修改Tag模型的元选项国际化
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
# 删除Article模型的created_time字段
migrations.RemoveField(
model_name='article',
name='created_time',
),
# 删除Article模型的last_mod_time字段
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
# 删除Category模型的created_time字段
migrations.RemoveField(
model_name='category',
name='created_time',
),
# 删除Category模型的last_mod_time字段
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
# 删除Links模型的created_time字段
migrations.RemoveField(
model_name='links',
name='created_time',
),
# 删除Sidebar模型的created_time字段
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
# 删除Tag模型的created_time字段
migrations.RemoveField(
model_name='tag',
name='created_time',
),
# 删除Tag模型的last_mod_time字段
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
# 向Article模型添加creation_time字段
migrations.AddField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Article模型添加last_modify_time字段
migrations.AddField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 向Category模型添加creation_time字段
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Category模型添加last_modify_time字段
migrations.AddField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 向Links模型添加creation_time字段
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Sidebar模型添加creation_time字段
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Tag模型添加creation_time字段
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向Tag模型添加last_modify_time字段
migrations.AddField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Article模型的article_order字段的显示名称
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
# 修改Article模型的author字段的显示名称
migrations.AlterField(
model_name='article',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 修改Article模型的body字段的显示名称
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
# 修改Article模型的category字段的显示名称
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
),
# 修改Article模型的comment_status字段的显示名称和选项值
migrations.AlterField(
model_name='article',
name='comment_status',
field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
),
# 修改Article模型的pub_time字段的显示名称
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
# 修改Article模型的show_toc字段的显示名称
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
# 修改Article模型的status字段的显示名称和选项值
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
),
# 修改Article模型的tags字段的显示名称
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
# 修改Article模型的title字段的显示名称
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
# 修改Article模型的type字段的显示名称和选项值
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
),
# 修改Article模型的views字段的显示名称
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
# 修改BlogSettings模型的article_comment_count字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'),
),
# 修改BlogSettings模型的article_sub_length字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'),
),
# 修改BlogSettings模型的google_adsense_codes字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
),
# 修改BlogSettings模型的open_site_comment字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'),
),
# 修改BlogSettings模型的show_google_adsense字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'),
),
# 修改BlogSettings模型的sidebar_article_count字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'),
),
# 修改BlogSettings模型的sidebar_comment_count字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
),
# 修改BlogSettings模型的site_description字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'),
),
# 修改BlogSettings模型的site_keywords字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
),
# 修改BlogSettings模型的site_name字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'),
),
# 修改BlogSettings模型的site_seo_description字段的显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
# 修改Category模型的index字段的显示名称
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='index'),
),
# 修改Category模型的name字段的显示名称
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
),
# 修改Category模型的parent_category字段的显示名称
migrations.AlterField(
model_name='category',
name='parent_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
),
# 修改Links模型的is_enable字段的显示名称
migrations.AlterField(
model_name='links',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'),
),
# 修改Links模型的last_mod_time字段的显示名称
migrations.AlterField(
model_name='links',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Links模型的link字段的显示名称
migrations.AlterField(
model_name='links',
name='link',
field=models.URLField(verbose_name='link'),
),
# 修改Links模型的name字段的显示名称
migrations.AlterField(
model_name='links',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
),
# 修改Links模型的sequence字段的显示名称
migrations.AlterField(
model_name='links',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# 修改Links模型的show_type字段的显示名称和选项值
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
),
# 修改Sidebar模型的content字段的显示名称
migrations.AlterField(
model_name='sidebar',
name='content',
field=models.TextField(verbose_name='content'),
),
# 修改Sidebar模型的is_enable字段的显示名称
migrations.AlterField(
model_name='sidebar',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# 修改Sidebar模型的last_mod_time字段的显示名称
migrations.AlterField(
model_name='sidebar',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Sidebar模型的name字段的显示名称
migrations.AlterField(
model_name='sidebar',
name='name',
field=models.CharField(max_length=100, verbose_name='title'),
),
# 修改Sidebar模型的sequence字段的显示名称
migrations.AlterField(
model_name='sidebar',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# 修改Tag模型的name字段的显示名称
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
]

@ -0,0 +1,24 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
from django.db import migrations
class Migration(migrations.Migration):
# 声明本迁移依赖的前一个迁移文件
dependencies = [
# 依赖于blog应用的第五个迁移
('blog', '0005_alter_article_options_alter_category_options_and_more'),
]
# 定义本迁移要执行的操作序列
operations = [
# 修改BlogSettings模型的元选项国际化
migrations.AlterModelOptions(
name='blogsettings', # 指定要修改的模型名称
options={
'verbose_name': 'Website configuration', # 单数形式的显示名称(英文)
'verbose_name_plural': 'Website configuration' # 复数形式的显示名称(英文)
},
),
]

@ -0,0 +1,407 @@
# 导入日志模块
import logging
# 导入正则表达式模块
import re
# 导入抽象方法装饰器
from abc import abstractmethod
# 导入Django配置和模型相关模块
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 _
# 导入Markdown编辑器字段
from mdeditor.fields import MDTextField
# 导入slug生成工具
from uuslug import slugify
# 导入自定义工具函数
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
# 获取日志记录器
logger = logging.getLogger(__name__)
# 链接显示类型选择类
class LinkShowType(models.TextChoices):
I = ('i', _('index')) # 首页显示
L = ('l', _('list')) # 列表页显示
P = ('p', _('post')) # 文章页面显示
A = ('a', _('all')) # 全站显示
S = ('s', _('slide')) # 友情链接页面显示
# 基础模型抽象类
class BaseModel(models.Model):
id = models.AutoField(primary_key=True) # 自增主键
creation_time = models.DateTimeField(_('creation time'), default=now) # 创建时间
last_modify_time = models.DateTimeField(_('modify time'), default=now) # 最后修改时间
def save(self, *args, **kwargs):
# 检查是否为文章视图更新操作
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
# 如果是视图更新,直接更新数据库
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
# 否则正常保存并处理slug字段
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):
# 获取完整URL
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')) # 文章正文使用Markdown编辑器
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now) # 发布时间
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p') # 文章状态
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o') # 评论状态
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') # 内容类型
views = models.PositiveIntegerField(_('views'), default=0) # 浏览量
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE) # 作者外键
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0) # 文章排序
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) # 是否显示目录
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False) # 分类外键
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) # 标签多对多关系
def body_to_string(self):
return self.body
def __str__(self):
return self.title
class Meta:
ordering = ['-article_order', '-pub_time'] # 默认排序
verbose_name = _('article') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
get_latest_by = 'id' # 获取最新记录的依据字段
def get_absolute_url(self):
# 获取文章绝对URL
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) # 缓存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) # 缓存100分钟
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
# 获取管理后台URL
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100) # 缓存100分钟
def next_article(self):
# 获取下一篇文章
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100) # 缓存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:
"""
# 从文章正文中提取第一张图片URL
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
# 分类模型类
class Category(BaseModel):
"""文章分类"""
name = models.CharField(_('category name'), max_length=30, unique=True) # 分类名称
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE) # 父级分类,支持多级分类
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # URL别名
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):
# 获取分类绝对URL
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10) # 缓存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) # 缓存10小时
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
:return:
"""
categorys = []
all_categorys = Category.objects.all()
def parse(category):
if category not in categorys:
categorys.append(category)
childs = all_categorys.filter(parent_category=category)
for child in childs:
if category not in categorys:
categorys.append(child)
parse(child)
parse(self)
return categorys
# 标签模型类
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True) # 标签名称
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # URL别名
def __str__(self):
return self.name
def get_absolute_url(self):
# 获取标签绝对URL
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10) # 缓存10小时
def get_article_count(self):
# 获取标签下的文章数量
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name'] # 按名称排序
verbose_name = _('tag') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
# 友情链接模型类
class Links(models.Model):
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True) # 链接名称
link = models.URLField(_('link')) # 链接地址
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='') # 网站SEO描述
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广告
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='') # Google广告代码
open_site_comment = models.BooleanField(_('open site comment'), default=True) # 是否开启网站评论
global_header = models.TextField("公共头部", null=True, blank=True, default='') # 全局头部内容
global_footer = models.TextField("公共尾部", null=True, blank=True, default='') # 全局尾部内容
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='') # 备案号
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='') # 网站统计代码
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False) # 是否显示公安备案
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='') # 公安备案号
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False) # 评论是否需要审核
class Meta:
verbose_name = _('Website configuration') # 单数显示名称
verbose_name_plural = verbose_name # 复数显示名称
def __str__(self):
return self.site_name
def clean(self):
# 验证只能有一个配置实例
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear() # 保存配置后清除缓存

@ -0,0 +1,28 @@
# 导入Haystack搜索索引相关模块
from haystack import indexes
# 导入文章模型
from blog.models import Article
# 定义文章搜索索引类继承自SearchIndex和Indexable
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
# 定义主搜索字段document=True表示这是主要的搜索字段
# use_template=True表示使用模板文件来构建搜索内容
text = indexes.CharField(document=True, use_template=True)
def get_model(self):
"""
返回与此索引关联的Django模型类
:return: Article模型类
"""
return Article
def index_queryset(self, using=None):
"""
返回要建立索引的查询集
这里只对已发布(status='p')的文章建立索引
:param using: 使用的搜索引擎别名
:return: 已发布文章的查询集
"""
return self.get_model().objects.filter(status='p')

@ -0,0 +1,9 @@
.button {
border: none;
padding: 4px 80px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
}

@ -0,0 +1,47 @@
let wait = 60;
function time(o) {
if (wait == 0) {
o.removeAttribute("disabled");
o.value = "获取验证码";
wait = 60
return false
} else {
o.setAttribute("disabled", true);
o.value = "重新发送(" + wait + ")";
wait--;
setTimeout(function () {
time(o)
},
1000)
}
}
document.getElementById("btn").onclick = function () {
let id_email = $("#id_email")
let token = $("*[name='csrfmiddlewaretoken']").val()
let ts = this
let myErr = $("#myErr")
$.ajax(
{
url: "/forget_password_code/",
type: "POST",
data: {
"email": id_email.val(),
"csrfmiddlewaretoken": token
},
success: function (result) {
if (result != "ok") {
myErr.remove()
id_email.after("<ul className='errorlist' id='myErr'><li>" + result + "</li></ul>")
return
}
myErr.remove()
time(ts)
},
error: function (e) {
alert("发送失败,请重试")
}
}
);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,13 @@
/*!
* IE10 viewport hack for Surface/desktop Windows 8 bug
* Copyright 2014-2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
/*
* See the Getting Started docs for more information:
* http://getbootstrap.com/getting-started/#support-ie10-width
*/
@-ms-viewport { width: device-width; }
@-o-viewport { width: device-width; }
@viewport { width: device-width; }

@ -0,0 +1,58 @@
body {
padding-top: 40px;
padding-bottom: 40px;
background-color: #fff;
}
.form-signin {
max-width: 330px;
padding: 15px;
margin: 0 auto;
}
.form-signin-heading {
margin: 0 0 15px;
font-size: 18px;
font-weight: 400;
color: #555;
}
.form-signin .checkbox {
margin-bottom: 10px;
font-weight: normal;
}
.form-signin .form-control {
position: relative;
height: auto;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
padding: 10px;
font-size: 16px;
}
.form-signin .form-control:focus {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: 10px;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
}
.card {
width: 304px;
padding: 20px 25px 30px;
margin: 0 auto 25px;
background-color: #f7f7f7;
border-radius: 2px;
-webkit-box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
}
.card-signin {
width: 354px;
padding: 40px;
}
.card-signin .profile-img {
display: block;
width: 96px;
height: 96px;
margin: 0 auto 10px;
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

@ -0,0 +1,51 @@
// NOTICE!! DO NOT USE ANY OF THIS JAVASCRIPT
// IT'S JUST JUNK FOR OUR DOCS!
// ++++++++++++++++++++++++++++++++++++++++++
/*!
* Copyright 2014-2015 Twitter, Inc.
*
* Licensed under the Creative Commons Attribution 3.0 Unported License. For
* details, see https://creativecommons.org/licenses/by/3.0/.
*/
// Intended to prevent false-positive bug reports about Bootstrap not working properly in old versions of IE due to folks testing using IE's unreliable emulation modes.
(function () {
'use strict';
function emulatedIEMajorVersion() {
var groups = /MSIE ([0-9.]+)/.exec(window.navigator.userAgent)
if (groups === null) {
return null
}
var ieVersionNum = parseInt(groups[1], 10)
var ieMajorVersion = Math.floor(ieVersionNum)
return ieMajorVersion
}
function actualNonEmulatedIEMajorVersion() {
// Detects the actual version of IE in use, even if it's in an older-IE emulation mode.
// IE JavaScript conditional compilation docs: https://msdn.microsoft.com/library/121hztk3%28v=vs.94%29.aspx
// @cc_on docs: https://msdn.microsoft.com/library/8ka90k2e%28v=vs.94%29.aspx
var jscriptVersion = new Function('/*@cc_on return @_jscript_version; @*/')() // jshint ignore:line
if (jscriptVersion === undefined) {
return 11 // IE11+ not in emulation mode
}
if (jscriptVersion < 9) {
return 8 // IE8 (or lower; haven't tested on IE<8)
}
return jscriptVersion // IE9 or IE10 in any mode, or IE11 in non-IE11 mode
}
var ua = window.navigator.userAgent
if (ua.indexOf('Opera') > -1 || ua.indexOf('Presto') > -1) {
return // Opera, which might pretend to be IE
}
var emulated = emulatedIEMajorVersion()
if (emulated === null) {
return // Not IE
}
var nonEmulated = actualNonEmulatedIEMajorVersion()
if (emulated !== nonEmulated) {
window.alert('WARNING: You appear to be using IE' + nonEmulated + ' in IE' + emulated + ' emulation mode.\nIE emulation modes can behave significantly differently from ACTUAL older versions of IE.\nPLEASE DON\'T FILE BOOTSTRAP BUGS based on testing in IE emulation modes!')
}
})();

@ -0,0 +1,23 @@
/*!
* IE10 viewport hack for Surface/desktop Windows 8 bug
* Copyright 2014-2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
// See the Getting Started docs for more information:
// http://getbootstrap.com/getting-started/#support-ie10-width
(function () {
'use strict';
if (navigator.userAgent.match(/IEMobile\/10\.0/)) {
var msViewportStyle = document.createElement('style')
msViewportStyle.appendChild(
document.createTextNode(
'@-ms-viewport{width:auto!important}'
)
)
document.querySelector('head').appendChild(msViewportStyle)
}
})();

@ -0,0 +1,273 @@
/*
Styles for older IE versions (previous to IE9).
*/
body {
background-color: #e6e6e6;
}
body.custom-background-empty {
background-color: #fff;
}
body.custom-background-empty .site,
body.custom-background-white .site {
box-shadow: none;
margin-bottom: 0;
margin-top: 0;
padding: 0;
}
.assistive-text,
.site .screen-reader-text {
clip: rect(1px 1px 1px 1px);
}
.full-width .site-content {
float: none;
width: 100%;
}
img.size-full,
img.size-large,
img.header-image,
img.wp-post-image,
img[class*="align"],
img[class*="wp-image-"],
img[class*="attachment-"] {
width: auto; /* Prevent stretching of full-size and large-size images with height and width attributes in IE8 */
}
.author-avatar {
float: left;
margin-top: 8px;
margin-top: 0.571428571rem;
}
.author-description {
float: right;
width: 80%;
}
.site {
box-shadow: 0 2px 6px rgba(100, 100, 100, 0.3);
margin: 48px auto;
max-width: 960px;
overflow: hidden;
padding: 0 40px;
}
.site-content {
float: left;
width: 65.104166667%;
}
body.template-front-page .site-content,
body.attachment .site-content,
body.full-width .site-content {
width: 100%;
}
.widget-area {
float: right;
width: 26.041666667%;
}
.site-header h1,
.site-header h2 {
text-align: left;
}
.site-header h1 {
font-size: 26px;
line-height: 1.846153846;
}
.main-navigation ul.nav-menu,
.main-navigation div.nav-menu > ul {
border-bottom: 1px solid #ededed;
border-top: 1px solid #ededed;
display: inline-block !important;
text-align: left;
width: 100%;
}
.main-navigation ul {
margin: 0;
text-indent: 0;
}
.main-navigation li a,
.main-navigation li {
display: inline-block;
text-decoration: none;
}
.ie7 .main-navigation li a,
.ie7 .main-navigation li {
display: inline;
}
.main-navigation li a {
border-bottom: 0;
color: #6a6a6a;
line-height: 3.692307692;
text-transform: uppercase;
}
.main-navigation li a:hover {
color: #000;
}
.main-navigation li {
margin: 0 40px 0 0;
position: relative;
}
.main-navigation li ul {
margin: 0;
padding: 0;
position: absolute;
top: 100%;
z-index: 1;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
}
.ie7 .main-navigation li ul {
clip: inherit;
display: none;
left: 0;
overflow: visible;
}
.main-navigation li ul ul,
.ie7 .main-navigation li ul ul {
top: 0;
left: 100%;
}
.main-navigation ul li:hover > ul,
.main-navigation ul li:focus > ul,
.main-navigation .focus > ul {
border-left: 0;
clip: inherit;
overflow: inherit;
height: inherit;
width: inherit;
}
.ie7 .main-navigation ul li:hover > ul,
.ie7 .main-navigation ul li:focus > ul {
display: block;
}
.main-navigation li ul li a {
background: #efefef;
border-bottom: 1px solid #ededed;
display: block;
font-size: 11px;
line-height: 2.181818182;
padding: 8px 10px;
width: 180px;
}
.main-navigation li ul li a:hover {
background: #e3e3e3;
color: #444;
}
.main-navigation .current-menu-item > a,
.main-navigation .current-menu-ancestor > a,
.main-navigation .current_page_item > a,
.main-navigation .current_page_ancestor > a {
color: #636363;
font-weight: bold;
}
.main-navigation .menu-toggle {
display: none;
}
.entry-header .entry-title {
font-size: 22px;
}
#respond form input[type="text"] {
width: 46.333333333%;
}
#respond form textarea.blog-textarea {
width: 79.666666667%;
}
.template-front-page .site-content,
.template-front-page article {
overflow: hidden;
}
.template-front-page.has-post-thumbnail article {
float: left;
width: 47.916666667%;
}
.entry-page-image {
float: right;
margin-bottom: 0;
width: 47.916666667%;
}
/* IE Front Page Template Widget fix */
.template-front-page .widget-area {
clear: both;
}
.template-front-page .widget {
width: 100% !important;
border: none;
}
.template-front-page .widget-area .widget,
.template-front-page .first.front-widgets,
.template-front-page.two-sidebars .widget-area .front-widgets {
float: left;
margin-bottom: 24px;
width: 51.875%;
}
.template-front-page .second.front-widgets,
.template-front-page .widget-area .widget:nth-child(odd) {
clear: right;
}
.template-front-page .first.front-widgets,
.template-front-page .second.front-widgets,
.template-front-page.two-sidebars .widget-area .front-widgets + .front-widgets {
float: right;
margin: 0 0 24px;
width: 39.0625%;
}
.template-front-page.two-sidebars .widget,
.template-front-page.two-sidebars .widget:nth-child(even) {
float: none;
width: auto;
}
/* add input font for <IE9 Password Box to make the bullets show up */
input[type="password"] {
font-family: Helvetica, Arial, sans-serif;
}
/* RTL overrides for IE7 and IE8
-------------------------------------------------------------- */
.rtl .site-header h1,
.rtl .site-header h2 {
text-align: right;
}
.rtl .widget-area,
.rtl .author-description {
float: left;
}
.rtl .author-avatar,
.rtl .site-content {
float: right;
}
.rtl .main-navigation ul.nav-menu,
.rtl .main-navigation div.nav-menu > ul {
text-align: right;
}
.rtl .main-navigation ul li ul li,
.rtl .main-navigation ul li ul li ul li {
margin-left: 40px;
margin-right: auto;
}
.rtl .main-navigation li ul ul {
position: absolute;
bottom: 0;
right: 100%;
z-index: 1;
}
.ie7 .rtl .main-navigation li ul ul {
position: absolute;
bottom: 0;
right: 100%;
z-index: 1;
}
.ie7 .rtl .main-navigation ul li {
z-index: 99;
}
.ie7 .rtl .main-navigation li ul {
position: absolute;
bottom: 100%;
right: 0;
z-index: 1;
}
.ie7 .rtl .main-navigation li {
margin-right: auto;
margin-left: 40px;
}
.ie7 .rtl .main-navigation li ul ul ul {
position: relative;
z-index: 1;
}

@ -0,0 +1,74 @@
/* Make clicks pass-through */
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: red;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
/* Fancy blur effect */
#nprogress .peg {
display: block;
position: absolute;
right: 0px;
width: 100px;
height: 100%;
box-shadow: 0 0 10px #29d, 0 0 5px #29d;
opacity: 1.0;
-webkit-transform: rotate(3deg) translate(0px, -4px);
-ms-transform: rotate(3deg) translate(0px, -4px);
transform: rotate(3deg) translate(0px, -4px);
}
/* Remove these to get rid of the spinner */
#nprogress .spinner {
display: block;
position: fixed;
z-index: 1031;
top: 15px;
right: 15px;
}
#nprogress .spinner-icon {
width: 18px;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-top-color: red;
border-left-color: red;
border-radius: 50%;
-webkit-animation: nprogress-spinner 400ms linear infinite;
animation: nprogress-spinner 400ms linear infinite;
}
.nprogress-custom-parent {
overflow: hidden;
position: relative;
}
.nprogress-custom-parent #nprogress .spinner,
.nprogress-custom-parent #nprogress .bar {
position: absolute;
}
@-webkit-keyframes nprogress-spinner {
0% { -webkit-transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); }
}
@keyframes nprogress-spinner {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

@ -0,0 +1,305 @@
.icon-sn-google {
background-position: 0 -28px;
}
.icon-sn-bg-google {
background-color: #4285f4;
background-position: 0 0;
}
.fa-sn-google {
color: #4285f4;
}
.icon-sn-github {
background-position: -28px -28px;
}
.icon-sn-bg-github {
background-color: #333;
background-position: -28px 0;
}
.fa-sn-github {
color: #333;
}
.icon-sn-weibo {
background-position: -56px -28px;
}
.icon-sn-bg-weibo {
background-color: #e90d24;
background-position: -56px 0;
}
.fa-sn-weibo {
color: #e90d24;
}
.icon-sn-qq {
background-position: -84px -28px;
}
.icon-sn-bg-qq {
background-color: #0098e6;
background-position: -84px 0;
}
.fa-sn-qq {
color: #0098e6;
}
.icon-sn-twitter {
background-position: -112px -28px;
}
.icon-sn-bg-twitter {
background-color: #50abf1;
background-position: -112px 0;
}
.fa-sn-twitter {
color: #50abf1;
}
.icon-sn-facebook {
background-position: -140px -28px;
}
.icon-sn-bg-facebook {
background-color: #4862a3;
background-position: -140px 0;
}
.fa-sn-facebook {
color: #4862a3;
}
.icon-sn-renren {
background-position: -168px -28px;
}
.icon-sn-bg-renren {
background-color: #197bc8;
background-position: -168px 0;
}
.fa-sn-renren {
color: #197bc8;
}
.icon-sn-tqq {
background-position: -196px -28px;
}
.icon-sn-bg-tqq {
background-color: #1f9ed2;
background-position: -196px 0;
}
.fa-sn-tqq {
color: #1f9ed2;
}
.icon-sn-douban {
background-position: -224px -28px;
}
.icon-sn-bg-douban {
background-color: #279738;
background-position: -224px 0;
}
.fa-sn-douban {
color: #279738;
}
.icon-sn-weixin {
background-position: -252px -28px;
}
.icon-sn-bg-weixin {
background-color: #00b500;
background-position: -252px 0;
}
.fa-sn-weixin {
color: #00b500;
}
.icon-sn-dotted {
background-position: -280px -28px;
}
.icon-sn-bg-dotted {
background-color: #eee;
background-position: -280px 0;
}
.fa-sn-dotted {
color: #eee;
}
.icon-sn-site {
background-position: -308px -28px;
}
.icon-sn-bg-site {
background-color: #00b500;
background-position: -308px 0;
}
.fa-sn-site {
color: #00b500;
}
.icon-sn-linkedin {
background-position: -336px -28px;
}
.icon-sn-bg-linkedin {
background-color: #0077b9;
background-position: -336px 0;
}
.fa-sn-linkedin {
color: #0077b9;
}
[class*=icon-sn-] {
display: inline-block;
background-image: url('../img/icon-sn.svg');
background-repeat: no-repeat;
width: 28px;
height: 28px;
vertical-align: middle;
background-size: auto 56px;
}
[class*=icon-sn-]:hover {
opacity: .8;
filter: alpha(opacity=80);
}
.btn-sn-google {
background: #4285f4;
}
.btn-sn-google:active, .btn-sn-google:focus, .btn-sn-google:hover {
background: #2a75f3;
}
.btn-sn-github {
background: #333;
}
.btn-sn-github:active, .btn-sn-github:focus, .btn-sn-github:hover {
background: #262626;
}
.btn-sn-weibo {
background: #e90d24;
}
.btn-sn-weibo:active, .btn-sn-weibo:focus, .btn-sn-weibo:hover {
background: #d10c20;
}
.btn-sn-qq {
background: #0098e6;
}
.btn-sn-qq:active, .btn-sn-qq:focus, .btn-sn-qq:hover {
background: #0087cd;
}
.btn-sn-twitter {
background: #50abf1;
}
.btn-sn-twitter:active, .btn-sn-twitter:focus, .btn-sn-twitter:hover {
background: #38a0ef;
}
.btn-sn-facebook {
background: #4862a3;
}
.btn-sn-facebook:active, .btn-sn-facebook:focus, .btn-sn-facebook:hover {
background: #405791;
}
.btn-sn-renren {
background: #197bc8;
}
.btn-sn-renren:active, .btn-sn-renren:focus, .btn-sn-renren:hover {
background: #166db1;
}
.btn-sn-tqq {
background: #1f9ed2;
}
.btn-sn-tqq:active, .btn-sn-tqq:focus, .btn-sn-tqq:hover {
background: #1c8dbc;
}
.btn-sn-douban {
background: #279738;
}
.btn-sn-douban:active, .btn-sn-douban:focus, .btn-sn-douban:hover {
background: #228330;
}
.btn-sn-weixin {
background: #00b500;
}
.btn-sn-weixin:active, .btn-sn-weixin:focus, .btn-sn-weixin:hover {
background: #009c00;
}
.btn-sn-dotted {
background: #eee;
}
.btn-sn-dotted:active, .btn-sn-dotted:focus, .btn-sn-dotted:hover {
background: #e1e1e1;
}
.btn-sn-site {
background: #00b500;
}
.btn-sn-site:active, .btn-sn-site:focus, .btn-sn-site:hover {
background: #009c00;
}
.btn-sn-linkedin {
background: #0077b9;
}
.btn-sn-linkedin:active, .btn-sn-linkedin:focus, .btn-sn-linkedin:hover {
background: #0067a0;
}
[class*=btn-sn-], [class*=btn-sn-]:active, [class*=btn-sn-]:focus, [class*=btn-sn-]:hover {
border: none;
color: #fff;
}
.btn-sn-more {
padding: 0;
}
.btn-sn-more, .btn-sn-more:active, .btn-sn-more:hover {
box-shadow: none;
}
[class*=btn-sn-] [class*=icon-sn-] {
background-color: transparent;
}

File diff suppressed because one or more lines are too long

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

Loading…
Cancel
Save